diff --git a/.changeset/29918-lifecycle-first-sweep-fresh-store.md b/.changeset/29918-lifecycle-first-sweep-fresh-store.md new file mode 100644 index 00000000000..ca0b038457f --- /dev/null +++ b/.changeset/29918-lifecycle-first-sweep-fresh-store.md @@ -0,0 +1,13 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): the lifecycle sweep no longer runs 60 s after a boot that created its datastore from empty; its first sweep runs one interval later + +Clause-②: yes (widening) — the exported `LifecycleEngineLike` gains the optional member `wasDatastoreCreatedFromEmpty?(): boolean` (an engine without it keeps the old schedule); no accept set changes. +Metadata-protocol: none + +- `LifecycleService.start()` asks the engine, at the initial delay, whether this process created the datastore from empty (`ObjectQL.wasDatastoreCreatedFromEmpty()`, answered from every driver's schema-sync stats without a statement). When it did, the scheduled first sweep is skipped and the sweep runs on the interval, one interval later. Such a store holds no row older than the boot, and no declared window is shorter than an hour, so the skipped sweep had nothing to reap. It still read every declared object, the `lifecycle` settings and every lookup the reference audit walks: 130 statements in the first minute of a hosted tenant kernel's first boot. +- A store that existed before the boot, or an engine that cannot say, sweeps at the initial delay as before. A direct `sweep()` call always sweeps. `OS_LIFECYCLE_DISABLED`, `initialDelayMs` and `sweepIntervalMs` keep their meaning. +- `LifecycleEngineLike` gains the optional member `wasDatastoreCreatedFromEmpty?(): boolean`. An engine double that omits it keeps the old schedule. +- A re-boot on an existing store still sweeps at the initial delay. Persisting a last-swept watermark for that case is separate work. diff --git a/packages/objectql/src/lifecycle/lifecycle-service.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.test.ts index 97208cd5720..f675f8cf68d 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.test.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.test.ts @@ -2541,6 +2541,101 @@ describe('LifecycleService timers', () => { vi.useRealTimers(); } }); + + /** + * [#29918] A datastore this process created from empty holds no row older + * than the boot, so the first scheduled sweep is due one interval later. + * Every data-plane door the sweep can reach is counted: the reaps, the + * governance settings reads and the reference audit. + */ + function freshnessEngine(answer: () => boolean) { + const captured = captureEngine([ + { name: 'sys_job_run', lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } } }, + ]); + const audits: unknown[] = []; + const settingsReads: string[] = []; + captured.engine.wasDatastoreCreatedFromEmpty = answer; + captured.engine.inspectDanglingReferences = async (options: unknown) => { + audits.push(options); + return { findings: [], scannedObjects: [], unreadableObjects: [] }; + }; + const getSettings = () => ({ + async get(namespace: string, key: string) { + settingsReads.push(`${namespace}.${key}`); + return { value: undefined }; + }, + }); + return { ...captured, audits, settingsReads, getSettings }; + } + + it('[#29918] issues no sweep at the initial delay on a store this boot created, and sweeps one interval later', async () => { + vi.useFakeTimers(); + try { + const { engine, deletes, audits, settingsReads, getSettings } = freshnessEngine(() => true); + const svc = service(engine, { initialDelayMs: 1_000, sweepIntervalMs: 5_000, getSettings }); + + svc.start(); + await vi.advanceTimersByTimeAsync(1_000); + expect(deletes).toHaveLength(0); + expect(audits).toHaveLength(0); + expect(settingsReads).toEqual([]); + + await vi.advanceTimersByTimeAsync(5_000); + expect(deletes).toHaveLength(1); + expect(audits).toHaveLength(1); + expect(settingsReads.length).toBeGreaterThan(0); + svc.stop(); + } finally { + vi.useRealTimers(); + } + }); + + it('[#29918] sweeps at the initial delay when the store was there before this boot, or the engine cannot say', async () => { + vi.useFakeTimers(); + try { + const answers: Array<() => boolean> = [ + () => false, + () => { + throw new Error('no driver reports schema-sync stats'); + }, + ]; + for (const answer of answers) { + const { engine, deletes, audits } = freshnessEngine(answer); + const svc = service(engine, { initialDelayMs: 1_000, sweepIntervalMs: 5_000 }); + svc.start(); + await vi.advanceTimersByTimeAsync(1_000); + expect(deletes).toHaveLength(1); + expect(audits).toHaveLength(1); + svc.stop(); + } + } finally { + vi.useRealTimers(); + } + }); + + it('[#29918] a direct sweep() on a store this boot created still sweeps', async () => { + const { engine, deletes, audits } = freshnessEngine(() => true); + await service(engine).sweep(); + expect(deletes).toHaveLength(1); + expect(audits).toHaveLength(1); + }); + + it('[#29918] OS_LIFECYCLE_DISABLED still arms nothing on a store this boot created', async () => { + vi.useFakeTimers(); + process.env.OS_LIFECYCLE_DISABLED = '1'; + try { + const { engine, deletes, audits } = freshnessEngine(() => true); + const svc = service(engine, { initialDelayMs: 1_000, sweepIntervalMs: 5_000 }); + svc.start(); + await vi.advanceTimersByTimeAsync(20_000); + expect(deletes).toHaveLength(0); + expect(audits).toHaveLength(0); + svc.stop(); + } finally { + delete process.env.OS_LIFECYCLE_DISABLED; + vi.useRealTimers(); + } + }); }); /** diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index d2f8fc875b5..026c59ebf9a 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -118,6 +118,15 @@ export interface LifecycleEngineLike { inspectDanglingReferences?( options?: DanglingReferenceAuditOptions, ): Promise; + /** + * [#29918] Did this process create the datastore it talks to, from empty? + * `ObjectQL.wasDatastoreCreatedFromEmpty()` answers it from every driver's + * schema-sync stats, in process and without a statement. The scheduler asks + * it once, at the initial delay, to decide whether the first sweep is due + * yet. Optional: an engine without it is never treated as fresh, so its + * first sweep runs at the initial delay exactly as before. + */ + wasDatastoreCreatedFromEmpty?(): boolean; } export interface LifecycleObjectLike { @@ -503,7 +512,11 @@ export class LifecycleService { } /** Arm the periodic sweep. Idempotent; timers are unref'ed so a kernel - * shutdown is never held open by the lifecycle schedule. */ + * shutdown is never held open by the lifecycle schedule. + * + * [#29918] On a datastore this process created from empty, the first sweep + * is due one interval later instead of at the initial delay: see + * {@link firstSweepDueAtInitialDelay}. The interval is armed either way. */ start(): void { if (!this.enabled || this.timer || this.initialTimer) return; // A fresh bit per armed run — the one a previous sweep captured stays @@ -513,13 +526,51 @@ export class LifecycleService { const initial = this.opts.initialDelayMs ?? DEFAULT_LIFECYCLE_INITIAL_DELAY_MS; this.initialTimer = setTimeout(() => { this.initialTimer = undefined; - void this.sweep(); + if (this.firstSweepDueAtInitialDelay()) void this.sweep(); this.timer = setInterval(() => void this.sweep(), interval); this.timer.unref?.(); }, initial); this.initialTimer.unref?.(); } + /** + * [#29918] Whether the scheduled first sweep runs at the initial delay. + * + * Not when this process created the datastore from empty + * ({@link LifecycleEngineLike.wasDatastoreCreatedFromEmpty}). Every row in + * such a store was written by this process, so none is older than the + * initial delay, and no declared window is that short: the duration grammar's + * smallest unit is an hour ({@link parseLifecycleDuration}). The sweep would + * read every declared object, the governance settings and every lookup the + * reference audit walks, and find nothing to do. On a hosted tenant kernel + * that was 130 statements in the first minute of every first boot. The first + * sweep then runs on the interval, so deferring it costs at most one interval + * of overstay, which the hourly cadence already allows any row. + * + * Asked here, at the initial delay, and nowhere else. Schema sync has run by + * now, so the answer is final. Only the SCHEDULER asks it: a direct + * {@link sweep} call (the dogfood growth gate, `db:clean`-style tooling, a + * test) always sweeps. The reference audit is a leg of that same sweep, so + * it is deferred with it. + * + * An engine that cannot answer, or whose answer throws, is not fresh. That + * keeps the sweep at the initial delay, as before this method existed. + */ + private firstSweepDueAtInitialDelay(): boolean { + let fresh = false; + try { + fresh = this.opts.getEngine()?.wasDatastoreCreatedFromEmpty?.() === true; + } catch { + fresh = false; + } + if (fresh) { + this.opts.logger.debug?.( + '[lifecycle] this process created the datastore from empty; the first sweep runs one interval later', + ); + } + return !fresh; + } + /** * Disarm the schedule AND call off the work. *