From fd4f8f4f3bd0e5228afa07dea90fbb5908619b76 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 21:33:00 +0200 Subject: [PATCH] feat(image-pipeline): add backend rollout flags --- lib/image-pipeline/backend/backend-router.ts | 57 +++- lib/image-pipeline/backend/feature-flags.ts | 100 +++++++ .../backend-feature-flags.test.ts | 263 ++++++++++++++++++ 3 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 lib/image-pipeline/backend/feature-flags.ts create mode 100644 tests/image-pipeline/backend-feature-flags.test.ts diff --git a/lib/image-pipeline/backend/backend-router.ts b/lib/image-pipeline/backend/backend-router.ts index a19e1e2..7428459 100644 --- a/lib/image-pipeline/backend/backend-router.ts +++ b/lib/image-pipeline/backend/backend-router.ts @@ -9,6 +9,10 @@ import { type ImagePipelineBackend, type PreviewBackendRequest, } from "@/lib/image-pipeline/backend/backend-types"; +import { + getBackendFeatureFlags, + type BackendFeatureFlags, +} from "@/lib/image-pipeline/backend/feature-flags"; type BackendFallbackReason = "unsupported_api" | "flag_disabled" | "runtime_error"; @@ -59,15 +63,50 @@ export function createBackendRouter(options?: { backends?: readonly ImagePipelineBackend[]; defaultBackendId?: string; backendAvailability?: Readonly>; + featureFlags?: BackendFeatureFlags; onFallback?: (event: BackendFallbackEvent) => void; }): BackendRouter { const configuredBackends = options?.backends?.length ? [...options.backends] : [cpuBackend]; const byId = new Map(configuredBackends.map((backend) => [backend.id.toLowerCase(), backend])); - const defaultBackend = + const configuredDefaultBackend = byId.get(options?.defaultBackendId?.toLowerCase() ?? "") ?? byId.get(CPU_BACKEND_ID) ?? configuredBackends[0] ?? cpuBackend; + const cpuFallbackBackend = byId.get(CPU_BACKEND_ID) ?? configuredDefaultBackend; + const featureFlags = options?.featureFlags; + + function isBackendEnabledByFlags(backendId: string): boolean { + if (!featureFlags) { + return true; + } + + const normalizedBackendId = backendId.toLowerCase(); + + if (featureFlags.forceCpu) { + return normalizedBackendId === CPU_BACKEND_ID; + } + + if (normalizedBackendId === "webgl") { + return featureFlags.webglEnabled; + } + + if (normalizedBackendId === "wasm") { + return featureFlags.wasmEnabled; + } + + return true; + } + + function resolveDefaultBackend(): ImagePipelineBackend { + if (!isBackendEnabledByFlags(configuredDefaultBackend.id)) { + return cpuFallbackBackend; + } + + return configuredDefaultBackend; + } + + const defaultBackend = resolveDefaultBackend(); const normalizedDefaultId = defaultBackend.id.toLowerCase(); function readAvailability(backendId: string): BackendAvailability | undefined { @@ -102,6 +141,14 @@ export function createBackendRouter(options?: { } const availability = readAvailability(normalizedHint); + if (!isBackendEnabledByFlags(normalizedHint)) { + return { + backend: defaultBackend, + fallbackReason: "flag_disabled", + requestedBackend: normalizedHint, + }; + } + if (availability?.enabled === false) { return { backend: defaultBackend, @@ -190,12 +237,14 @@ export function createBackendRouter(options?: { }; } -const defaultRouter = createBackendRouter(); +const rolloutRouter = createBackendRouter({ + featureFlags: getBackendFeatureFlags(), +}); export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void { - defaultRouter.runPreviewStep(request); + rolloutRouter.runPreviewStep(request); } export function runFullPipelineWithBackendRouter(request: FullBackendRequest): void { - defaultRouter.runFullPipeline(request); + rolloutRouter.runFullPipeline(request); } diff --git a/lib/image-pipeline/backend/feature-flags.ts b/lib/image-pipeline/backend/feature-flags.ts new file mode 100644 index 0000000..94b4480 --- /dev/null +++ b/lib/image-pipeline/backend/feature-flags.ts @@ -0,0 +1,100 @@ +export const IMAGE_PIPELINE_BACKEND_FLAG_KEYS = { + forceCpu: "imagePipeline.backend.forceCpu", + webglEnabled: "imagePipeline.backend.webgl.enabled", + wasmEnabled: "imagePipeline.backend.wasm.enabled", +} as const; + +export type BackendFeatureFlags = { + forceCpu: boolean; + webglEnabled: boolean; + wasmEnabled: boolean; +}; + +export type BackendFeatureFlagReader = ( + key: (typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS)[keyof typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS], +) => unknown; + +const DEFAULT_BACKEND_FEATURE_FLAGS: BackendFeatureFlags = { + forceCpu: false, + webglEnabled: false, + wasmEnabled: false, +}; + +type RuntimeFeatureFlagStore = { + [IMAGE_PIPELINE_BACKEND_FLAG_KEYS.forceCpu]?: unknown; + [IMAGE_PIPELINE_BACKEND_FLAG_KEYS.webglEnabled]?: unknown; + [IMAGE_PIPELINE_BACKEND_FLAG_KEYS.wasmEnabled]?: unknown; +}; + +declare global { + interface Window { + __LEMONSPACE_FEATURE_FLAGS__?: RuntimeFeatureFlagStore; + } + + var __LEMONSPACE_FEATURE_FLAGS__: RuntimeFeatureFlagStore | undefined; +} + +function parseBooleanFlag(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + return undefined; + } + + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "off") { + return false; + } + } + + return undefined; +} + +function readFlagFromRuntimeStore( + key: (typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS)[keyof typeof IMAGE_PIPELINE_BACKEND_FLAG_KEYS], +): unknown { + const runtimeStore = + globalThis.__LEMONSPACE_FEATURE_FLAGS__ ?? + (typeof window !== "undefined" ? window.__LEMONSPACE_FEATURE_FLAGS__ : undefined); + if (runtimeStore && key in runtimeStore) { + return runtimeStore[key]; + } + + try { + if (typeof localStorage !== "undefined") { + return localStorage.getItem(key); + } + } catch { + return undefined; + } + + return undefined; +} + +export function getBackendFeatureFlags(readFlag?: BackendFeatureFlagReader): BackendFeatureFlags { + const reader = readFlag ?? readFlagFromRuntimeStore; + + return { + forceCpu: + parseBooleanFlag(reader(IMAGE_PIPELINE_BACKEND_FLAG_KEYS.forceCpu)) ?? + DEFAULT_BACKEND_FEATURE_FLAGS.forceCpu, + webglEnabled: + parseBooleanFlag(reader(IMAGE_PIPELINE_BACKEND_FLAG_KEYS.webglEnabled)) ?? + DEFAULT_BACKEND_FEATURE_FLAGS.webglEnabled, + wasmEnabled: + parseBooleanFlag(reader(IMAGE_PIPELINE_BACKEND_FLAG_KEYS.wasmEnabled)) ?? + DEFAULT_BACKEND_FEATURE_FLAGS.wasmEnabled, + }; +} diff --git a/tests/image-pipeline/backend-feature-flags.test.ts b/tests/image-pipeline/backend-feature-flags.test.ts new file mode 100644 index 0000000..d68f2c2 --- /dev/null +++ b/tests/image-pipeline/backend-feature-flags.test.ts @@ -0,0 +1,263 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types"; +import { createBackendRouter } from "@/lib/image-pipeline/backend/backend-router"; +import { + type BackendFeatureFlags, + getBackendFeatureFlags, +} from "@/lib/image-pipeline/backend/feature-flags"; + +function createStep() { + return { + nodeId: "n1", + type: "color-adjust", + params: { + hsl: { + hue: 0, + saturation: 0, + luminance: 0, + }, + temperature: 0, + tint: 0, + vibrance: 0, + }, + } as const; +} + +function createBackends() { + const cpuPreview = vi.fn(); + const cpuFull = vi.fn(); + const webglPreview = vi.fn(); + const webglFull = vi.fn(); + const wasmPreview = vi.fn(); + const wasmFull = vi.fn(); + + const backends: readonly ImagePipelineBackend[] = [ + { + id: "cpu", + runPreviewStep: cpuPreview, + runFullPipeline: cpuFull, + }, + { + id: "webgl", + runPreviewStep: webglPreview, + runFullPipeline: webglFull, + }, + { + id: "wasm", + runPreviewStep: wasmPreview, + runFullPipeline: wasmFull, + }, + ]; + + return { + backends, + cpuPreview, + cpuFull, + webglPreview, + webglFull, + wasmPreview, + wasmFull, + }; +} + +function createRouterFlags(overrides: Partial): BackendFeatureFlags { + return { + ...getBackendFeatureFlags(), + ...overrides, + }; +} + +describe("backend feature flags", () => { + it("forceCpu overrides all backend choices", () => { + const reasons: string[] = []; + const backend = createBackends(); + const router = createBackendRouter({ + backends: backend.backends, + backendAvailability: { + webgl: { + supported: true, + enabled: true, + }, + wasm: { + supported: true, + enabled: true, + }, + }, + featureFlags: createRouterFlags({ + forceCpu: true, + webglEnabled: true, + wasmEnabled: true, + }), + onFallback: (event) => { + reasons.push(event.reason); + }, + }); + + router.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + backendHint: "webgl", + }); + + router.runFullPipeline({ + pixels: new Uint8ClampedArray(4), + steps: [createStep()], + width: 1, + height: 1, + backendHint: "wasm", + }); + + expect(backend.cpuPreview).toHaveBeenCalledTimes(1); + expect(backend.cpuFull).toHaveBeenCalledTimes(1); + expect(backend.webglPreview).not.toHaveBeenCalled(); + expect(backend.webglFull).not.toHaveBeenCalled(); + expect(backend.wasmPreview).not.toHaveBeenCalled(); + expect(backend.wasmFull).not.toHaveBeenCalled(); + expect(reasons).toEqual(["flag_disabled", "flag_disabled"]); + }); + + it("webgl and wasm can be independently enabled or disabled", () => { + const reasonA: string[] = []; + const backendA = createBackends(); + const routerA = createBackendRouter({ + backends: backendA.backends, + backendAvailability: { + webgl: { + supported: true, + enabled: true, + }, + wasm: { + supported: true, + enabled: true, + }, + }, + featureFlags: createRouterFlags({ + forceCpu: false, + webglEnabled: true, + wasmEnabled: false, + }), + onFallback: (event) => { + reasonA.push(event.reason); + }, + }); + + routerA.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + backendHint: "webgl", + }); + routerA.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + backendHint: "wasm", + }); + + expect(backendA.webglPreview).toHaveBeenCalledTimes(1); + expect(backendA.wasmPreview).not.toHaveBeenCalled(); + expect(backendA.cpuPreview).toHaveBeenCalledTimes(1); + expect(reasonA).toEqual(["flag_disabled"]); + + const reasonB: string[] = []; + const backendB = createBackends(); + const routerB = createBackendRouter({ + backends: backendB.backends, + backendAvailability: { + webgl: { + supported: true, + enabled: true, + }, + wasm: { + supported: true, + enabled: true, + }, + }, + featureFlags: createRouterFlags({ + forceCpu: false, + webglEnabled: false, + wasmEnabled: true, + }), + onFallback: (event) => { + reasonB.push(event.reason); + }, + }); + + routerB.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + backendHint: "webgl", + }); + routerB.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + backendHint: "wasm", + }); + + expect(backendB.webglPreview).not.toHaveBeenCalled(); + expect(backendB.wasmPreview).toHaveBeenCalledTimes(1); + expect(backendB.cpuPreview).toHaveBeenCalledTimes(1); + expect(reasonB).toEqual(["flag_disabled"]); + }); + + it("defaults preserve cpu behavior when no explicit flags are set", () => { + const reasons: string[] = []; + const backend = createBackends(); + const router = createBackendRouter({ + backends: backend.backends, + backendAvailability: { + webgl: { + supported: true, + enabled: true, + }, + wasm: { + supported: true, + enabled: true, + }, + }, + featureFlags: getBackendFeatureFlags(), + onFallback: (event) => { + reasons.push(event.reason); + }, + }); + + router.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + backendHint: "webgl", + }); + + router.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + backendHint: "wasm", + }); + + router.runPreviewStep({ + pixels: new Uint8ClampedArray(4), + step: createStep(), + width: 1, + height: 1, + }); + + expect(backend.cpuPreview).toHaveBeenCalledTimes(3); + expect(backend.webglPreview).not.toHaveBeenCalled(); + expect(backend.wasmPreview).not.toHaveBeenCalled(); + expect(reasons).toEqual(["flag_disabled", "flag_disabled"]); + }); +});