|
| 1 | +--- |
| 2 | +"@objectstack/objectql": minor |
| 3 | +"@objectstack/service-queue": patch |
| 4 | +--- |
| 5 | + |
| 6 | +fix(objectql,service-queue): a `lifecycle` settings override can no longer undercut a consumer's retention floor (#5195) |
| 7 | + |
| 8 | +ADR-0057 P4 lets an operator override any object's retention window per |
| 9 | +environment and per tenant through the `lifecycle` settings namespace. Until now |
| 10 | +the only validation on that override was **does it parse** — and a retention |
| 11 | +window is not only the operator's business: other code can depend on the rows |
| 12 | +still being there. |
| 13 | + |
| 14 | +`sys_job_queue` is the worked example. `DbQueueAdapter` deduplicates publishes by |
| 15 | +comparing a terminal row's `created_at` against its idempotency window, so the |
| 16 | +dedup check only means anything while that row still exists; #5179 made the |
| 17 | +ordering an invariant by refusing, at construction, an idempotency window longer |
| 18 | +than the object's **declared** retention. A settings override the constructor |
| 19 | +cannot see walks straight around it: |
| 20 | + |
| 21 | +```jsonc |
| 22 | +// lifecycle → retention_overrides |
| 23 | +{ "sys_job_queue": { "maxAge": "1h" } } |
| 24 | +``` |
| 25 | + |
| 26 | +completed rows are reaped an hour after they are written, publish keeps |
| 27 | +deduplicating against 24h, and duplicate deliveries resume **with nothing in any |
| 28 | +log**. |
| 29 | + |
| 30 | +**New: retention floors.** A consumer may now declare, at runtime, the shortest |
| 31 | +window its own contract survives: |
| 32 | + |
| 33 | +```ts |
| 34 | +lifecycle.registerRetentionFloor('sys_job_queue', { |
| 35 | + policy: 'retention', // or 'ttl' |
| 36 | + minWindowMs: 24 * 60 * 60 * 1000, |
| 37 | + declaredBy: 'com.objectstack.service.queue', |
| 38 | + consequence: '…what silently breaks below it', |
| 39 | + remedy: '…the settings change that makes an override legal', |
| 40 | +}); |
| 41 | +``` |
| 42 | + |
| 43 | +- An override below the floor — **global or tenant-scoped** — is **rejected**, |
| 44 | + and the declared window keeps running. Not clamped to the floor: clamping |
| 45 | + would enforce a third number written in neither the declaration nor the |
| 46 | + settings, and that number would move whenever an unrelated package changed |
| 47 | + its floor. Rejection has exactly one fallback, the declaration, which is |
| 48 | + already how an unparseable override resolves. |
| 49 | +- The rejection is `error`-level and carries both the consequence and the fix, |
| 50 | + because what it prevents leaves the system looking entirely healthy. It is |
| 51 | + also on the sweep report as `LifecycleSweepReport.floorViolations` — machine- |
| 52 | + readable, every sweep. |
| 53 | +- A **declared** window below a registered floor is reported the same way and |
| 54 | + still enforced: refusing to reap would trade a broken consumer contract for |
| 55 | + the unbounded table #5179 just closed. |
| 56 | +- Objects with no registered floor are completely unaffected — P4 overrides |
| 57 | + behave exactly as before. |
| 58 | + |
| 59 | +Floors are runtime wiring, not spec surface (the same call ADR-0057's reap-guard |
| 60 | +amendment makes), plus a reason of their own: the queue's floor **is** |
| 61 | +`DbQueueAdapterOptions.idempotencyWindowMs`, a per-kernel construction option, so |
| 62 | +a static key on the object's `lifecycle` block could only ever be a second copy |
| 63 | +of it that drifts. No `packages/spec` change. |
| 64 | + |
| 65 | +`QueueServicePlugin` registers `sys_job_queue`'s floor on `kernel:ready`, |
| 66 | +carrying the window the adapter was actually constructed with — so a non-default |
| 67 | +`db.idempotencyWindowMs` is covered too. The ordering is now enforced from both |
| 68 | +sides: the constructor rejects a too-long `idempotencyWindowMs`, the floor |
| 69 | +rejects a too-short `maxAge`. |
| 70 | + |
| 71 | +New exports from `@objectstack/objectql`: `LifecycleRetentionFloor`, |
| 72 | +`LifecycleFloorViolation`, plus `LifecycleService.registerRetentionFloor()`. |
| 73 | +`LifecycleLoggerLike` gained an optional `error()` (absent ⇒ falls back to |
| 74 | +`warn`), and `LifecycleSweepReport` gained `floorViolations`. |
0 commit comments