fix(images): bound external runtime processing
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 10s
CI / integration (pull_request) Failing after 2m14s

This commit is contained in:
ekklesia
2026-10-10 10:54:33 -05:00
parent 871d198221
commit 88038476b3
4 changed files with 49 additions and 8 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable. # remains reachable after its branch is deleted; the full commit pin makes the checkout immutable.
EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
EKKLESIA_PLATFORM_REF=refs/pull/90/head EKKLESIA_PLATFORM_REF=refs/pull/90/head
EKKLESIA_PLATFORM_COMMIT=6e64b3c5f955452b41dbf1aac4c5557841d1ff8a EKKLESIA_PLATFORM_COMMIT=a0e9948820a4e9b30b207ca3a0a98b3204eb8493
+5 -4
View File
@@ -177,10 +177,11 @@ export async function processSignageImage(bytes: Uint8Array): Promise<SignagePro
height: metadata.height as number, height: metadata.height as number,
width: metadata.width as number, width: metadata.width as number,
}; };
const [display, thumbnail] = await Promise.all([ // Keep peak native CPU and memory bounded inside the shared API process. Upload concurrency is
renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION), // already bounded at the module UI; running both renditions for every upload in parallel would
renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION), // multiply that concurrency again and can starve the database and storage clients on a small host.
]); const display = await renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION);
const thumbnail = await renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION);
return { return {
byteSize: bytes.byteLength, byteSize: bytes.byteLength,
+18 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { import {
MODULE_STORAGE_ERROR_CODES,
type ModuleStorage, type ModuleStorage,
type ModuleStorageBucket, type ModuleStorageBucket,
ModuleStorageError, type ModuleStorageErrorCode,
} from "@ekklesia/module-sdk"; } from "@ekklesia/module-sdk";
import { SignageError } from "../errors.js"; import { SignageError } from "../errors.js";
import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js"; import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js";
@@ -17,6 +18,20 @@ export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket {
return storage.bucket(SIGNAGE_IMAGE_BUCKET); return storage.bucket(SIGNAGE_IMAGE_BUCKET);
} }
/** Host errors cross the installed-package boundary from a different copy of the SDK, so class
* identity is not part of the contract (ADR 0049). Recognize only the SDK's closed, sanitized
* storage-error shape; provider messages and causes remain inaccessible. */
function storageErrorCode(error: unknown): ModuleStorageErrorCode | undefined {
if (!(error instanceof Error) || error.name !== "ModuleStorageError") {
return undefined;
}
const code = (error as { readonly code?: unknown }).code;
return typeof code === "string" &&
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code)
? (code as ModuleStorageErrorCode)
: undefined;
}
/** /**
* Object keys are scoped to the asset they belong to. * Object keys are scoped to the asset they belong to.
* *
@@ -41,7 +56,7 @@ export function renditionObjectKey(assetId: string, kind: string): string {
* not a caller's business. * not a caller's business.
*/ */
function storageFailure(error: unknown): SignageError { function storageFailure(error: unknown): SignageError {
if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") { if (storageErrorCode(error) === "STORAGE_OBJECT_NOT_FOUND") {
return new SignageError("SIGNAGE_NOT_FOUND"); return new SignageError("SIGNAGE_NOT_FOUND");
} }
return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE"); return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE");
@@ -150,7 +165,7 @@ export async function ingestNativeImage(
if (error instanceof SignageError) { if (error instanceof SignageError) {
throw error; throw error;
} }
if (error instanceof ModuleStorageError) { if (storageErrorCode(error) !== undefined) {
throw storageFailure(error); throw storageFailure(error);
} }
throw new SignageError("SIGNAGE_OPERATION_FAILED"); throw new SignageError("SIGNAGE_OPERATION_FAILED");
+25
View File
@@ -1,3 +1,4 @@
import type { ModuleStorage } from "@ekklesia/module-sdk";
import sharp from "sharp"; import sharp from "sharp";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { SignageError } from "../src/server/errors.js"; import { SignageError } from "../src/server/errors.js";
@@ -372,6 +373,30 @@ describe("native image ingest", () => {
expect(store.images.size).toBe(0); expect(store.images.size).toBe(0);
}); });
it("recognizes a sanitized storage failure from the host's separate SDK copy", async () => {
const bucket = storage.bucket("native-images");
const externalError = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
const externalStorage: ModuleStorage = {
bucket: () => ({ ...bucket, put: () => Promise.reject(externalError) }),
buckets: () => ["native-images"],
};
await expectSignageCode(
ingestNativeImage({
bytes: await makeImage({ format: "png", height: 360, width: 640 }),
displayName: "welcome.png",
expiresAt: null,
filename: "welcome.png",
storage: externalStorage,
store,
}),
"SIGNAGE_STORAGE_UNAVAILABLE",
);
});
it("treats a race with an identical upload as a success rather than a failure", async () => { it("treats a race with an identical upload as a success rather than a failure", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 }); const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const first = await ingest(bytes); const first = await ingest(bytes);