feat(image-pipeline): add backend rollout flags
This commit is contained in:
@@ -9,6 +9,10 @@ import {
|
|||||||
type ImagePipelineBackend,
|
type ImagePipelineBackend,
|
||||||
type PreviewBackendRequest,
|
type PreviewBackendRequest,
|
||||||
} from "@/lib/image-pipeline/backend/backend-types";
|
} 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";
|
type BackendFallbackReason = "unsupported_api" | "flag_disabled" | "runtime_error";
|
||||||
|
|
||||||
@@ -59,15 +63,50 @@ export function createBackendRouter(options?: {
|
|||||||
backends?: readonly ImagePipelineBackend[];
|
backends?: readonly ImagePipelineBackend[];
|
||||||
defaultBackendId?: string;
|
defaultBackendId?: string;
|
||||||
backendAvailability?: Readonly<Record<string, BackendAvailability>>;
|
backendAvailability?: Readonly<Record<string, BackendAvailability>>;
|
||||||
|
featureFlags?: BackendFeatureFlags;
|
||||||
onFallback?: (event: BackendFallbackEvent) => void;
|
onFallback?: (event: BackendFallbackEvent) => void;
|
||||||
}): BackendRouter {
|
}): BackendRouter {
|
||||||
const configuredBackends = options?.backends?.length ? [...options.backends] : [cpuBackend];
|
const configuredBackends = options?.backends?.length ? [...options.backends] : [cpuBackend];
|
||||||
const byId = new Map(configuredBackends.map((backend) => [backend.id.toLowerCase(), backend]));
|
const byId = new Map(configuredBackends.map((backend) => [backend.id.toLowerCase(), backend]));
|
||||||
const defaultBackend =
|
const configuredDefaultBackend =
|
||||||
byId.get(options?.defaultBackendId?.toLowerCase() ?? "") ??
|
byId.get(options?.defaultBackendId?.toLowerCase() ?? "") ??
|
||||||
byId.get(CPU_BACKEND_ID) ??
|
byId.get(CPU_BACKEND_ID) ??
|
||||||
configuredBackends[0] ??
|
configuredBackends[0] ??
|
||||||
cpuBackend;
|
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();
|
const normalizedDefaultId = defaultBackend.id.toLowerCase();
|
||||||
|
|
||||||
function readAvailability(backendId: string): BackendAvailability | undefined {
|
function readAvailability(backendId: string): BackendAvailability | undefined {
|
||||||
@@ -102,6 +141,14 @@ export function createBackendRouter(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availability = readAvailability(normalizedHint);
|
const availability = readAvailability(normalizedHint);
|
||||||
|
if (!isBackendEnabledByFlags(normalizedHint)) {
|
||||||
|
return {
|
||||||
|
backend: defaultBackend,
|
||||||
|
fallbackReason: "flag_disabled",
|
||||||
|
requestedBackend: normalizedHint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (availability?.enabled === false) {
|
if (availability?.enabled === false) {
|
||||||
return {
|
return {
|
||||||
backend: defaultBackend,
|
backend: defaultBackend,
|
||||||
@@ -190,12 +237,14 @@ export function createBackendRouter(options?: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultRouter = createBackendRouter();
|
const rolloutRouter = createBackendRouter({
|
||||||
|
featureFlags: getBackendFeatureFlags(),
|
||||||
|
});
|
||||||
|
|
||||||
export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void {
|
export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void {
|
||||||
defaultRouter.runPreviewStep(request);
|
rolloutRouter.runPreviewStep(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runFullPipelineWithBackendRouter(request: FullBackendRequest): void {
|
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