feat(housekeeping): orchestrate partial providers
This commit is contained in:
1 parent
60968409aa
commit
4c399873d1
4 files changed
+306
No files matched your search
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
ok,
|
||||||
|
} from "../contracts";
|
||||||
|
import { orchestrateProviders, type ProviderPolicy } from "./orchestrate";
|
||||||
|
import type { HousekeepingProvider } from "./run-provider";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy: ProviderPolicy<Item> = {
|
||||||
|
timeoutMs: 2_000,
|
||||||
|
perProviderLimit: 2,
|
||||||
|
combinedLimit: 3,
|
||||||
|
sort: (items) => [...items].sort((left, right) => left.order - right.order),
|
||||||
|
dedupeKey: (item) => item.key,
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedContext: HousekeepingCapabilityContext = {
|
||||||
|
actor: { id: 42, username: "operator", rank: 0 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => slug === "housekeeping.read",
|
||||||
|
hasAny: (...slugs) => slugs.includes("housekeeping.read"),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => slug === "housekeeping.read"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function provider(
|
||||||
|
id: string,
|
||||||
|
items: readonly Item[],
|
||||||
|
): HousekeepingProvider<Item> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async () => ok(items, `${id}-correlation`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("orchestrateProviders", () => {
|
||||||
|
it("preserves a successful sibling when another provider times out", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const slow: HousekeepingProvider<Item> = {
|
||||||
|
id: "slow",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async (_context, signal) =>
|
||||||
|
await new Promise((_, reject) => {
|
||||||
|
signal.addEventListener("abort", () => reject(new Error("aborted")));
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = orchestrateProviders(
|
||||||
|
[slow, provider("fast", [{ key: "fast", label: "Fast", order: 1 }])],
|
||||||
|
allowedContext,
|
||||||
|
policy,
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
|
||||||
|
expect(await result).toMatchObject({
|
||||||
|
items: [{ key: "fast", label: "Fast", order: 1 }],
|
||||||
|
errors: [{ providerId: "slow", code: "TIMEOUT" }],
|
||||||
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first duplicate before applying deterministic sorting", async () => {
|
||||||
|
const result = await orchestrateProviders(
|
||||||
|
[
|
||||||
|
provider("first", [
|
||||||
|
{ key: "shared", label: "First", order: 2 },
|
||||||
|
{ key: "unique", label: "Unique", order: 1 },
|
||||||
|
]),
|
||||||
|
provider("second", [{ key: "shared", label: "Second", order: 0 }]),
|
||||||
|
],
|
||||||
|
allowedContext,
|
||||||
|
policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
{ key: "unique", label: "Unique", order: 1 },
|
||||||
|
{ key: "shared", label: "First", order: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces per-provider and combined caps before sorting", async () => {
|
||||||
|
const result = await orchestrateProviders(
|
||||||
|
[
|
||||||
|
provider("first", [
|
||||||
|
{ key: "one", label: "One", order: 3 },
|
||||||
|
{ key: "two", label: "Two", order: 2 },
|
||||||
|
{ key: "discarded", label: "Discarded", order: 0 },
|
||||||
|
]),
|
||||||
|
provider("second", [
|
||||||
|
{ key: "three", label: "Three", order: 1 },
|
||||||
|
{ key: "combined-discarded", label: "Combined discarded", order: 0 },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
allowedContext,
|
||||||
|
policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toEqual([
|
||||||
|
{ key: "three", label: "Three", order: 1 },
|
||||||
|
{ key: "two", label: "Two", order: 2 },
|
||||||
|
{ key: "one", label: "One", order: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
HousekeepingErrorCode,
|
||||||
|
} from "../contracts";
|
||||||
|
import { createCorrelationId } from "../correlation";
|
||||||
|
import { type HousekeepingProvider, runProvider } from "./run-provider";
|
||||||
|
|
||||||
|
export interface ProviderPolicy<T> {
|
||||||
|
timeoutMs: number;
|
||||||
|
perProviderLimit: number;
|
||||||
|
combinedLimit: number;
|
||||||
|
sort(items: readonly T[]): readonly T[];
|
||||||
|
dedupeKey(item: T): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderBatchResult<T> {
|
||||||
|
items: readonly T[];
|
||||||
|
errors: readonly { providerId: string; code: HousekeepingErrorCode }[];
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function orchestrateProviders<T>(
|
||||||
|
providers: readonly HousekeepingProvider<T>[],
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
policy: ProviderPolicy<T>,
|
||||||
|
): Promise<ProviderBatchResult<T>> {
|
||||||
|
const providerResults = await Promise.all(
|
||||||
|
providers.map((provider) =>
|
||||||
|
runProvider(provider, context, policy.timeoutMs),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const items: T[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const errors: { providerId: string; code: HousekeepingErrorCode }[] = [];
|
||||||
|
|
||||||
|
for (const result of providerResults) {
|
||||||
|
if (result.error !== undefined) {
|
||||||
|
errors.push({ providerId: result.providerId, code: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of result.items.slice(0, policy.perProviderLimit)) {
|
||||||
|
if (items.length >= policy.combinedLimit) break;
|
||||||
|
const key = policy.dedupeKey(item);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: policy.sort(items),
|
||||||
|
errors,
|
||||||
|
correlationId: createCorrelationId(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
ok,
|
||||||
|
} from "../contracts";
|
||||||
|
import { type HousekeepingProvider, runProvider } from "./run-provider";
|
||||||
|
|
||||||
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 999 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => granted.includes(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => granted.includes(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => granted.includes(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runProvider", () => {
|
||||||
|
it("aborts a slow provider at its 2,000 ms deadline and returns TIMEOUT", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let observedAbort = false;
|
||||||
|
const provider: HousekeepingProvider<string> = {
|
||||||
|
id: "slow",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async (_context, signal) =>
|
||||||
|
await new Promise((_, reject) => {
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
observedAbort = true;
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runProvider(provider, context(["housekeeping.read"]), 2_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
|
||||||
|
expect(await result).toEqual({
|
||||||
|
providerId: "slow",
|
||||||
|
items: [],
|
||||||
|
error: "TIMEOUT",
|
||||||
|
});
|
||||||
|
expect(observedAbort).toBe(true);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a provider whose required capability is absent", async () => {
|
||||||
|
let invoked = false;
|
||||||
|
const provider: HousekeepingProvider<string> = {
|
||||||
|
id: "restricted",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async () => {
|
||||||
|
invoked = true;
|
||||||
|
return ok(["private"], "provider-correlation");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(await runProvider(provider, context([]), 2_000)).toEqual({
|
||||||
|
providerId: "restricted",
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
expect(invoked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps one thrown provider failure to one INTERNAL partial error", async () => {
|
||||||
|
const provider: HousekeepingProvider<string> = {
|
||||||
|
id: "broken",
|
||||||
|
capability: anyCapability("housekeeping.read"),
|
||||||
|
run: async () => {
|
||||||
|
throw new Error("database unavailable");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await runProvider(provider, context(["housekeeping.read"]), 2_000),
|
||||||
|
).toEqual({ providerId: "broken", items: [], error: "INTERNAL" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { satisfiesCapability } from "../capability-context";
|
||||||
|
import type {
|
||||||
|
CapabilityRequirement,
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
HousekeepingErrorCode,
|
||||||
|
HousekeepingResult,
|
||||||
|
} from "../contracts";
|
||||||
|
|
||||||
|
export interface HousekeepingProvider<T> {
|
||||||
|
id: string;
|
||||||
|
capability: CapabilityRequirement;
|
||||||
|
run(
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<HousekeepingResult<readonly T[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderRunResult<T> {
|
||||||
|
providerId: string;
|
||||||
|
items: readonly T[];
|
||||||
|
error?: HousekeepingErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runProvider<T>(
|
||||||
|
provider: HousekeepingProvider<T>,
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<ProviderRunResult<T>> {
|
||||||
|
if (!satisfiesCapability(context, provider.capability)) {
|
||||||
|
return { providerId: provider.id, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const run = provider
|
||||||
|
.run(context, controller.signal)
|
||||||
|
.then(
|
||||||
|
(result): ProviderRunResult<T> =>
|
||||||
|
result.ok
|
||||||
|
? { providerId: provider.id, items: result.data }
|
||||||
|
: { providerId: provider.id, items: [], error: result.error.code },
|
||||||
|
)
|
||||||
|
.catch(
|
||||||
|
(): ProviderRunResult<T> => ({
|
||||||
|
providerId: provider.id,
|
||||||
|
items: [],
|
||||||
|
error: "INTERNAL",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const timeoutResult = new Promise<ProviderRunResult<T>>((resolve) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
resolve({ providerId: provider.id, items: [], error: "TIMEOUT" });
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([run, timeoutResult]);
|
||||||
|
} finally {
|
||||||
|
if (timeout !== undefined) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user