fix(images): bound external runtime processing
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user