feat(image-pipeline): add backend rollout flags
This commit is contained in:
@@ -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<Record<string, BackendAvailability>>;
|
||||
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);
|
||||
}
|
||||
|
||||
100
lib/image-pipeline/backend/feature-flags.ts
Normal file
100
lib/image-pipeline/backend/feature-flags.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
263
tests/image-pipeline/backend-feature-flags.test.ts
Normal file
263
tests/image-pipeline/backend-feature-flags.test.ts
Normal file
@@ -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>): 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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user