Skip to content

Commit b5eb61d

Browse files
feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for (#30751)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D9gB7dhaBzxAcWk95SYN9CE)_ domain:server#1 · os-elon-musk · session_016WV4cxAY2io7s5aVJa6YL4 Fixes #29918 Fixes #30641 Clause-②: yes (widening) — `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (and with it `PLATFORM_PROVIDED_OBJECT_NAMES` / `isPlatformProvidedObjectName`) lists one more real platform object, `sys_lifecycle_state`, and the exported `LifecycleEngineLike` gains three optional members (`findOne`, `insert`, `update`); nothing that was accepted is refused. Metadata-protocol: added Skills-docs: none — `skills/**` and `content/docs/**` enumerate no platform-object roster and teach nothing about the lifecycle sweep's schedule; the object is internal clock state with no data API, so there is nothing for an author to write against it. Claim: 6672243354 (amended 6672382820) · Branch: `claude/project-thread-ie3sda` · Ruling-ref: 6663872259 (letter B), confirmed 6670551310 · Claim correction: 6670586478 · Spec consent: 6669304514, 6670346930 · Resumes: release 6671108523 (`claude/project-thread-0jjbbb@4a9c529`) ## 维护者速读 - 改了什么:objectql 生命周期清扫记住「上次扫完的时间」,存在新的系统状态对象 `sys_lifecycle_state`(引擎自有、无租户列、每个数据库一行)。内核启动 60 秒时读这一行:距上次扫完不到一小时就不扫,只花这一条读;超过一小时、没有这一行、读失败,照旧扫。全新建库那一轮仍按 #30214 延后一个间隔,并顺手写下这一行。spec 名册加一行 `sys_lifecycle_state`,与对象同一提交。 - 为什么改:每次内核重启都会在第一分钟跑满一遍清扫(cloud 托管内核上 130 条语句),cloud#3413 要求第一分钟不超过约 10 条。裁决 B(6663872259,确认 6670551310):水位线是引擎状态,不是设置。 - 风险与回滚:小。最坏情况是水位线写不进去,下次启动多扫一遍(等于今天的行为),写失败只记一次 warn。新增一个系统表(无数据 API、无设置键、无环境变量、不进 Setup)。回滚即撤 PR,多出的空表无害。 - 先看:`packages/objectql/src/lifecycle/lifecycle-service.ts`(`runFirstScheduledSweep`、`watermarkIsYoung`、`writeWatermark`,以及 `sweep()` 末尾只在完整成功时推进水位线),`packages/platform-objects/src/system/sys-lifecycle-state.object.ts`,`packages/spec/src/system/constants/platform-object-names.ts` 那一行。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/lifecycle/lifecycle-service.test.ts -t "sweep watermark"`,预期 9 条全绿,其中「younger than the interval」断言初始延迟时只发出一次读。 - 需要你做的:无,席位落地。 ## What changes **`@objectstack/objectql` — `LifecycleService`** (`lifecycle/lifecycle-service.ts`) - New exported constants `LIFECYCLE_STATE_OBJECT = 'sys_lifecycle_state'` and `LIFECYCLE_STATE_ROW_ID = 'lifecycle'`. - `LifecycleEngineLike` gains optional `findOne` / `insert` / `update`. An engine missing any of the three, or whose registry does not register `sys_lifecycle_state`, keeps no watermark and sweeps exactly as before; the object is never read when unregistered. - The scheduler's first tick (`runFirstScheduledSweep`): 1. a store this boot created from empty (#30214's `wasDatastoreCreatedFromEmpty`) still defers one interval, and now seeds the row at now without reading it; 2. otherwise one primary-key `findOne` of the row: a `last_swept_at` younger than the interval ⇒ no sweep, the read is the only statement; the interval is armed either way; 3. an older, missing, unparsable or future watermark, or a read that throws (logged once at `warn`) ⇒ sweep at the initial delay, as before. - `sweep()` advances the row (insert the first time, update after; the row's presence is remembered so no second read) only for a COMPLETED sweep: no policy error, the reference-audit leg ran (or was never meant to), and no teardown cut it short. Stamped with the sweep's start, which the cutoffs were taken from. A failed write is a functional degradation (the next boot sweeps again), logged at `warn` once and `debug` after, never thrown. - A direct `sweep()` call always sweeps. `OS_LIFECYCLE_DISABLED=1` still arms nothing. **`@objectstack/platform-objects`** — new `sys_lifecycle_state` (`system/sys-lifecycle-state.object.ts`): `isSystem`, `managedBy: 'engine-owned'`, `systemFields: { tenant: false }` (ADR-0131 D7 deployment-level state), `requiredPermissions: ['manage_platform_settings']`, `apiEnabled: false`, no `lifecycle` block (never swept), fields `id`, `last_swept_at`, `created_at`, `updated_at`. Registered by `PlatformObjectsPlugin` beside `sys_migration` and exported from `system/index.ts`. **`@objectstack/spec`** — the one roster line `'sys_lifecycle_state'` in `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (sorted between `sys_jwks` and `sys_member`), in the same commit as the object, per `domain:spec#3`'s consent (6669304514, 6670346930): the roster test holds the roster to the declared `*.object.ts` set both ways, so #30641 cannot land alone. Nothing else under `packages/spec` moves. **`@objectstack/cli`** — `ORGANIZATION_OWNERSHIP_INVENTORY` gains the `sys_lifecycle_state` row (deployment-level, `column-drop`). **`scripts/platform-object-tenancy-census.json`** — the new row (`reach: out`, `systemFields.tenant: false`) and the totals (registered 94, out of reach 47). **Changeset** — `minor` for spec / objectql / platform-objects / cli / plugin-audit, carrying both declaration lines and the `Skills-docs:` line. ## Tests - `packages/objectql/src/lifecycle/lifecycle-service.test.ts`, new describe "LifecycleService sweep watermark (#29918)", 9 pins: a younger watermark issues only the read; an interval-old watermark sweeps and advances; no row sweeps and writes it; a created-from-empty store defers, seeds without reading, and sweeps one interval later; a failed policy or a throwing audit leaves the row unchanged; a failing read sweeps and warns; an engine without the object is never asked; a direct `sweep()` sweeps and advances; `OS_LIFECYCLE_DISABLED` reads and writes nothing. - `packages/platform-objects`: `plugin.test.ts` (registration order, six infra objects, no routing triple), `deployment-level-no-tenant-column.test.ts` (no tenant column, platform-only reads). - Roster (`platform-object-names.test.ts`) and CLI inventory (`organization-ownership-inventory.test.ts`) unchanged and green with the new rows. ## Acceptance notes - **The carried head is one commit.** The released branch held the object (`28a633a3`) and the roster line (`2de76a73`) in two commits; the dispatch order requires them in one because the roster test holds both directions, so this branch squashes the released work onto current `main` (`6ef7f583`). The released branch is untouched. - **Docblock reattached.** On the released head, `runFirstScheduledSweep`'s docblock had been inserted between `firstSweepDueAtInitialDelay`'s docblock and its method, leaving that method undocumented and two docblocks stacked; this branch moves the new methods above it. No code moved. - **`Skills-docs:` spelling.** The release and the dispatch order spelled the value `no move`; the declared grammar (pm-dispatch SKILL.md) is `updated here | follow-up owed — FILES | follow-up #N | none — REASON`, so the changeset and this body say `none — REASON` with the same reason. - **A fresh store now issues one write at the initial delay** (the seed insert), not zero. The card's done-when allows "at most the watermark read"; the seed is the price of making a reboot inside the first interval skip too (card Expected item 2). One statement against the 130 measured. - **plugin-audit hunk (amendment 6673909090).** The first head's watermark writes were audited like any business write, so every completed sweep added a `sys_audit_log` and a `sys_activity` row about its own clock, which the next sweep archived (CI: the `audit-key-material` / `audit-mail-bodies` archive integration tests counted 2 archived rows where 1 is right). Ruling 6663872259 forbids an audit row per sweep and ADR-0057 section 3.3 forbids cleanup re-feeding the tables it drains. No write option bypasses the code-registered audit hooks and there is no object-level opt-out, so `sys_lifecycle_state` joins `SKIP_OBJECTS` beside `sys_job_queue`; `audit-writers.test.ts` pins zero rows for its insert and update, and the changeset gains `'@objectstack/plugin-audit': minor`. ## Verification Rework head `29c59f4d`: the rework report 6674667474 on #29918 records 96/96 derived gates and `pnpm lint` exit 0 (0 NOT-MEASURED), full dogfood 421 files / 3513 tests green, both `cli` archive integration tests 16/16, and `plugin-audit` 1146/1146. The block below is the first head's. All on head `f9f4c1b6` (base `main` `6ef7f583`), after a whole-tree `turbo run build` (119/119, lock VERDICT command-exit 0). <details><summary>Targeted suites and typecheck — all exit 0</summary> - `pnpm --filter @objectstack/objectql test`: Test Files 556 passed (556), Tests 11078 passed (11078) - `pnpm --filter @objectstack/objectql typecheck`: exit 0 - `pnpm --filter @objectstack/platform-objects test`: Test Files 116 passed (116), Tests 1581 passed (1581); `typecheck` exit 0 - `pnpm --filter @objectstack/spec exec vitest run src/system/constants/platform-object-names.test.ts`: 14 passed (14) - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date - `pnpm --filter @objectstack/cli exec vitest run --project unit src/utils/organization-ownership-inventory.test.ts`: 26 passed (26); `cli typecheck` exit 0 - Lock VERDICT command-exit 0. </details> <details><summary>Ablations — both gates turn their pin red; restore proven against HEAD</summary> Through `scripts/ablation-replace.mjs`, `vitest run src/lifecycle/lifecycle-service.test.ts -t "sweep watermark"` (the suite imports the service from source, so no build sits between mutation and run): - Read gate: the line in `watermarkIsYoung` that returns false for an unparsable, future or interval-old watermark (the `Number.isFinite` / `intervalMs` check) replaced by `return Boolean(0);` (anchor 1 to 0, blob `ba3f2b02` to `2f1db1a3`): 1 failed, 8 passed, the failure being "a reboot whose watermark is younger than the interval issues only the watermark read at the initial delay". A first attempt with the replacement `return false;` was refused by the tool as a no-op (the replacement is a substring of the anchor, count 7 to 7) and measured nothing. - Write gate: `if (audited && report.errors.length === 0 && !this.stopped) {` replaced by `if (true) {` (blob `ba3f2b02` to `7bd5ebd0`): 1 failed, 8 passed, the failure being "a sweep that fails a policy, or whose audit throws, leaves the watermark unchanged". - Each restore: blob equals HEAD `ba3f2b02`, `git diff HEAD` empty. </details> <details><summary>Derived gates — 94 of 94 run, all exit 0, plus pnpm lint</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 94 families on `f9f4c1b6`; each ran sequentially, exit code captured before any pipe, plus `pnpm lint` (exit 0). `--ran`: "94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED"; `pnpm lint` listed as outside the derivation. </details> <details><summary>Full dogfood — green</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock on `f9f4c1b6`: Test Files 421 passed, 3 skipped (424); Tests 3513 passed, 36 skipped (3549); VERDICT command-exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxAY2io7s5aVJa6YL4)_ <!-- signed: domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 612468c commit b5eb61d

13 files changed

Lines changed: 655 additions & 15 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/platform-objects': minor
5+
'@objectstack/cli': minor
6+
'@objectstack/plugin-audit': minor
7+
---
8+
9+
Clause-②: yes (widening) — `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (and so `PLATFORM_PROVIDED_OBJECT_NAMES` / `isPlatformProvidedObjectName`) lists one more real platform object, `sys_lifecycle_state`. Its readers resolve references against the roster and none refuses an author declaration, so a stack that references the object now resolves it instead of drawing the unknown-platform-name advisory; nothing that was accepted is refused.
10+
Metadata-protocol: added
11+
Skills-docs: none — `skills/**` and `content/docs/**` enumerate no platform object roster and teach nothing about the lifecycle sweep's schedule; the object is internal clock state with no data API, so there is nothing for an author to write against it.
12+
13+
The lifecycle sweep remembers when it last completed, so a kernel that reboots onto an existing datastore no longer re-sweeps within a minute of every boot.
14+
15+
`LifecycleService` now keeps a watermark in a new engine-owned, tenant-less object, `sys_lifecycle_state` (declared and registered by `@objectstack/platform-objects`, one row per datastore, id `lifecycle`). At the initial delay the scheduler reads that one row: when the last completed sweep is younger than the sweep interval, the first sweep waits for the interval and the read is the only statement issued. An older or missing watermark, a kernel without the object, or a read that fails (logged at `warn`) sweeps at the initial delay as before. Every sweep that completes with no policy error and a finished reference audit advances the row; a failed or partial sweep leaves it alone. A datastore the process created from empty still defers its first sweep one interval, and now seeds the row. A direct `sweep()` call still always sweeps.
16+
17+
The row is internal state, not a setting: no settings key, no environment variable, no audit row per sweep and no data API (`apiEnabled: false`). `@objectstack/plugin-audit` leaves `sys_lifecycle_state` out of `sys_audit_log` and `sys_activity`, so a sweep writes no ledger row for its own clock. `OS_LIFECYCLE_DISABLED=1` still arms nothing. `os migrate organization-ownership` inventories the new table as deployment-level state with no organization column.

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,14 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
759759
`${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: the environment's trusted plugin-publisher keys, ` +
760760
'read by key id under the system context, never by organization',
761761
},
762+
{
763+
object: 'sys_lifecycle_state',
764+
census: PMAIN,
765+
fate: 'column-drop',
766+
citation:
767+
`${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: the lifecycle sweeper's clock state, one row per ` +
768+
'datastore, written by the sweep under the system context, never by organization',
769+
},
762770
{ object: 'sys_scim_connection_credential', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] },
763771
{
764772
object: 'sys_flow_credential',

‎packages/objectql/src/lifecycle/lifecycle-service.test.ts‎

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2638,6 +2638,277 @@ describe('LifecycleService timers', () => {
26382638
});
26392639
});
26402640

2641+
/**
2642+
* [#29918] The sweep's watermark: one engine-owned row (`sys_lifecycle_state`,
2643+
* id `lifecycle`) holding when the scheduled sweep last completed, so a kernel
2644+
* that reboots onto an existing store reads one row at the initial delay
2645+
* instead of re-sweeping. Every door the sweep can reach is counted, so "only
2646+
* the read" is a statement count, not an inference.
2647+
*/
2648+
describe('LifecycleService sweep watermark (#29918)', () => {
2649+
const INTERVAL = 5_000;
2650+
const DELAY = 1_000;
2651+
2652+
function watermarkEngine(opts: {
2653+
row?: Record<string, unknown> | null;
2654+
fresh?: boolean;
2655+
registered?: boolean;
2656+
findOneImpl?: () => Promise<Record<string, unknown> | null>;
2657+
deleteImpl?: (object: string, options: any) => any;
2658+
auditImpl?: () => Promise<unknown>;
2659+
} = {}) {
2660+
const captured = captureEngine(
2661+
[{ name: 'sys_job_run', lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } } }],
2662+
{ deleteImpl: opts.deleteImpl },
2663+
);
2664+
const { engine } = captured;
2665+
const reads: Array<{ object: string; where: unknown; context: unknown }> = [];
2666+
const inserts: Array<{ object: string; data: Record<string, unknown>; context: unknown }> = [];
2667+
const updates: Array<{ object: string; data: Record<string, unknown>; context: unknown }> = [];
2668+
const audits: unknown[] = [];
2669+
const settingsReads: string[] = [];
2670+
let stored: Record<string, unknown> | null = opts.row ?? null;
2671+
engine.registry.getObject = (name: string) =>
2672+
name === 'sys_lifecycle_state' && opts.registered !== false ? { name } : undefined;
2673+
engine.wasDatastoreCreatedFromEmpty = () => opts.fresh === true;
2674+
engine.findOne = async (object: string, options: any) => {
2675+
reads.push({ object, where: options?.where, context: options?.context });
2676+
return opts.findOneImpl ? opts.findOneImpl() : stored;
2677+
};
2678+
engine.insert = async (object: string, data: any, options: any) => {
2679+
inserts.push({ object, data, context: options?.context });
2680+
stored = { ...data };
2681+
return data;
2682+
};
2683+
engine.update = async (object: string, data: any, options: any) => {
2684+
updates.push({ object, data, context: options?.context });
2685+
stored = { ...(stored ?? {}), ...data };
2686+
return data;
2687+
};
2688+
engine.inspectDanglingReferences = async (options: unknown) => {
2689+
audits.push(options);
2690+
if (opts.auditImpl) return opts.auditImpl();
2691+
return { findings: [], scannedObjects: [], unreadableObjects: [] };
2692+
};
2693+
const getSettings = () => ({
2694+
async get(namespace: string, key: string) {
2695+
settingsReads.push(`${namespace}.${key}`);
2696+
return { value: undefined };
2697+
},
2698+
});
2699+
/** Every statement the sweep (and the watermark) could issue, by door. */
2700+
const statements = () =>
2701+
captured.deletes.length + reads.length + inserts.length + updates.length + audits.length + settingsReads.length;
2702+
return { ...captured, reads, inserts, updates, audits, settingsReads, getSettings, statements, row: () => stored };
2703+
}
2704+
2705+
const iso = (ms: number) => new Date(ms).toISOString();
2706+
2707+
async function bootAndReachInitialDelay(
2708+
harness: ReturnType<typeof watermarkEngine>,
2709+
extra: Partial<ConstructorParameters<typeof LifecycleService>[0]> = {},
2710+
) {
2711+
const svc = service(harness.engine, {
2712+
initialDelayMs: DELAY,
2713+
sweepIntervalMs: INTERVAL,
2714+
getSettings: harness.getSettings,
2715+
...extra,
2716+
});
2717+
svc.start();
2718+
await vi.advanceTimersByTimeAsync(DELAY);
2719+
return svc;
2720+
}
2721+
2722+
it('a reboot whose watermark is younger than the interval issues only the watermark read at the initial delay', async () => {
2723+
vi.useFakeTimers();
2724+
try {
2725+
const harness = watermarkEngine({ row: { id: 'lifecycle', last_swept_at: iso(FIXED_NOW - (INTERVAL - 1)) } });
2726+
const svc = await bootAndReachInitialDelay(harness);
2727+
2728+
expect(harness.reads).toEqual([
2729+
{ object: 'sys_lifecycle_state', where: { id: 'lifecycle' }, context: expect.objectContaining({ isSystem: true }) },
2730+
]);
2731+
// Zero statements beyond the read: no reap, no settings, no audit, no write.
2732+
expect(harness.statements()).toBe(1);
2733+
expect(harness.deletes).toHaveLength(0);
2734+
2735+
// The next sweep is due on the interval, and it advances the watermark.
2736+
await vi.advanceTimersByTimeAsync(INTERVAL);
2737+
expect(harness.deletes).toHaveLength(1);
2738+
expect(harness.audits).toHaveLength(1);
2739+
expect(harness.updates).toEqual([
2740+
{
2741+
object: 'sys_lifecycle_state',
2742+
data: expect.objectContaining({ id: 'lifecycle', last_swept_at: iso(FIXED_NOW) }),
2743+
context: expect.objectContaining({ isSystem: true }),
2744+
},
2745+
]);
2746+
expect(harness.inserts).toHaveLength(0);
2747+
svc.stop();
2748+
} finally {
2749+
vi.useRealTimers();
2750+
}
2751+
});
2752+
2753+
it('a watermark one interval old or older sweeps at the initial delay and advances it', async () => {
2754+
vi.useFakeTimers();
2755+
try {
2756+
for (const age of [INTERVAL, INTERVAL * 10]) {
2757+
const harness = watermarkEngine({ row: { id: 'lifecycle', last_swept_at: iso(FIXED_NOW - age) } });
2758+
const svc = await bootAndReachInitialDelay(harness);
2759+
expect(harness.deletes).toHaveLength(1);
2760+
expect(harness.audits).toHaveLength(1);
2761+
// The boot read said the row exists, so the advance is one update, no second read.
2762+
expect(harness.reads).toHaveLength(1);
2763+
expect(harness.inserts).toHaveLength(0);
2764+
expect(harness.updates).toHaveLength(1);
2765+
expect(harness.row()).toMatchObject({ id: 'lifecycle', last_swept_at: iso(FIXED_NOW) });
2766+
svc.stop();
2767+
}
2768+
} finally {
2769+
vi.useRealTimers();
2770+
}
2771+
});
2772+
2773+
it('no watermark row on an existing store sweeps at the initial delay and writes it', async () => {
2774+
vi.useFakeTimers();
2775+
try {
2776+
const harness = watermarkEngine({ row: null });
2777+
const svc = await bootAndReachInitialDelay(harness);
2778+
expect(harness.deletes).toHaveLength(1);
2779+
expect(harness.audits).toHaveLength(1);
2780+
expect(harness.reads).toHaveLength(1);
2781+
expect(harness.updates).toHaveLength(0);
2782+
expect(harness.inserts).toEqual([
2783+
{
2784+
object: 'sys_lifecycle_state',
2785+
data: expect.objectContaining({ id: 'lifecycle', last_swept_at: iso(FIXED_NOW) }),
2786+
context: expect.objectContaining({ isSystem: true }),
2787+
},
2788+
]);
2789+
svc.stop();
2790+
} finally {
2791+
vi.useRealTimers();
2792+
}
2793+
});
2794+
2795+
it('a store this boot created still defers the first sweep, seeds the watermark without reading it, and sweeps one interval later', async () => {
2796+
vi.useFakeTimers();
2797+
try {
2798+
const harness = watermarkEngine({ fresh: true });
2799+
const svc = await bootAndReachInitialDelay(harness);
2800+
expect(harness.deletes).toHaveLength(0);
2801+
expect(harness.audits).toHaveLength(0);
2802+
expect(harness.settingsReads).toEqual([]);
2803+
expect(harness.reads).toHaveLength(0);
2804+
expect(harness.inserts).toEqual([
2805+
{
2806+
object: 'sys_lifecycle_state',
2807+
data: expect.objectContaining({ id: 'lifecycle', last_swept_at: iso(FIXED_NOW) }),
2808+
context: expect.objectContaining({ isSystem: true }),
2809+
},
2810+
]);
2811+
2812+
await vi.advanceTimersByTimeAsync(INTERVAL);
2813+
expect(harness.deletes).toHaveLength(1);
2814+
expect(harness.updates).toHaveLength(1);
2815+
svc.stop();
2816+
} finally {
2817+
vi.useRealTimers();
2818+
}
2819+
});
2820+
2821+
it('a sweep that fails a policy, or whose audit throws, leaves the watermark unchanged', async () => {
2822+
const before = { id: 'lifecycle', last_swept_at: iso(FIXED_NOW - INTERVAL * 10) };
2823+
const reapFails = watermarkEngine({
2824+
row: { ...before },
2825+
deleteImpl: () => {
2826+
throw new Error('driver refused the delete');
2827+
},
2828+
});
2829+
const failed = await service(reapFails.engine, { sweepIntervalMs: INTERVAL }).sweep();
2830+
expect(failed.errors.length).toBeGreaterThan(0);
2831+
expect(reapFails.inserts).toHaveLength(0);
2832+
expect(reapFails.updates).toHaveLength(0);
2833+
expect(reapFails.row()).toEqual(before);
2834+
2835+
const auditFails = watermarkEngine({
2836+
row: { ...before },
2837+
auditImpl: async () => {
2838+
throw new Error('audit could not read');
2839+
},
2840+
});
2841+
const partial = await service(auditFails.engine, { sweepIntervalMs: INTERVAL }).sweep();
2842+
expect(partial.errors).toEqual([]);
2843+
expect(auditFails.deletes).toHaveLength(1);
2844+
expect(auditFails.inserts).toHaveLength(0);
2845+
expect(auditFails.updates).toHaveLength(0);
2846+
expect(auditFails.row()).toEqual(before);
2847+
});
2848+
2849+
it('a watermark read that fails sweeps at the initial delay and says so at warn', async () => {
2850+
vi.useFakeTimers();
2851+
try {
2852+
const warns: string[] = [];
2853+
const harness = watermarkEngine({
2854+
findOneImpl: async () => {
2855+
throw new Error('connection refused');
2856+
},
2857+
});
2858+
const svc = await bootAndReachInitialDelay(harness, {
2859+
logger: { info: () => {}, warn: (m: string) => warns.push(m), debug: () => {} },
2860+
});
2861+
expect(harness.deletes).toHaveLength(1);
2862+
expect(harness.audits).toHaveLength(1);
2863+
expect(warns.filter((m) => m.includes('watermark could not be read'))).toHaveLength(1);
2864+
svc.stop();
2865+
} finally {
2866+
vi.useRealTimers();
2867+
}
2868+
});
2869+
2870+
it('an engine that does not register sys_lifecycle_state is never asked for it, and sweeps at the initial delay', async () => {
2871+
vi.useFakeTimers();
2872+
try {
2873+
const harness = watermarkEngine({ registered: false, row: { id: 'lifecycle', last_swept_at: iso(FIXED_NOW) } });
2874+
const svc = await bootAndReachInitialDelay(harness);
2875+
expect(harness.deletes).toHaveLength(1);
2876+
expect(harness.reads).toHaveLength(0);
2877+
expect(harness.inserts).toHaveLength(0);
2878+
expect(harness.updates).toHaveLength(0);
2879+
svc.stop();
2880+
} finally {
2881+
vi.useRealTimers();
2882+
}
2883+
});
2884+
2885+
it('a direct sweep() sweeps whatever the watermark says, and advances it', async () => {
2886+
const harness = watermarkEngine({ row: { id: 'lifecycle', last_swept_at: iso(FIXED_NOW) } });
2887+
await service(harness.engine, { sweepIntervalMs: INTERVAL }).sweep();
2888+
expect(harness.deletes).toHaveLength(1);
2889+
expect(harness.audits).toHaveLength(1);
2890+
// Unknown presence on a direct call: one read to decide, then the update.
2891+
expect(harness.reads).toHaveLength(1);
2892+
expect(harness.updates).toHaveLength(1);
2893+
});
2894+
2895+
it('OS_LIFECYCLE_DISABLED still arms nothing: no watermark read, no write', async () => {
2896+
vi.useFakeTimers();
2897+
process.env.OS_LIFECYCLE_DISABLED = '1';
2898+
try {
2899+
const harness = watermarkEngine({ row: null });
2900+
const svc = service(harness.engine, { initialDelayMs: DELAY, sweepIntervalMs: INTERVAL });
2901+
svc.start();
2902+
await vi.advanceTimersByTimeAsync(INTERVAL * 4);
2903+
expect(harness.statements()).toBe(0);
2904+
svc.stop();
2905+
} finally {
2906+
delete process.env.OS_LIFECYCLE_DISABLED;
2907+
vi.useRealTimers();
2908+
}
2909+
});
2910+
});
2911+
26412912
/**
26422913
* [#4551] The read-only referential-integrity audit rides this sweep's clock.
26432914
*

0 commit comments

Comments
 (0)