Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/queue-idempotency-window-instant-compare.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/service-queue": patch
---

fix(service-queue): compare the publish idempotency window as instants, not strings (#13993)

`DbQueueAdapter#publish` deduped terminal rows with
`String(row.created_at) >= windowStart` — a lexicographic compare of the raw
driver value against canonical ISO text. On Postgres/MySQL the builtin audit
column `created_at` comes out of the record read door as a JS `Date`, whose
`String()` begins with a weekday letter, unconditionally above the ISO
window-start's leading digit — so the predicate was always true: any terminal
(`completed`/`dlq`) row with that idempotency key blocked re-publish forever,
and `publish()` returned the old id having enqueued nothing. Silent message
loss on the production default drivers; SQLite (ISO text on both sides) was
always correct, which is why every existing test stayed green.

The check now normalises `created_at` to an instant (the #13382
`canonicalVersionInstant` shape: `Date`, epoch-ms number, or absolute ISO
text) and compares epoch milliseconds, so every dialect gets the declared
window semantics. The `pending`/`running` arm — which blocks regardless of
age — is untouched, and SQLite verdicts are unchanged. A `created_at` that
denotes no instant cannot be inside a window measured on the `created_at`
axis and no longer blocks.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13993] The publish idempotency window, driven through every `created_at`
* materialisation a driver actually hands out of the record read door.
*
* The defect: `DbQueueAdapter#publish` compared
* `String(row.created_at) >= windowStart` — lexicographic text against
* canonical ISO text. On Postgres/MySQL the builtin audit column `created_at`
* comes back as a JS `Date` (pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`), whose `String()`
* starts with a weekday LETTER (0x41–0x5A), unconditionally above the ISO
* text's leading digit `'2'` (0x32) — so the predicate was TRUE for every
* terminal row, the window never expired, and `publish()` returned the old id
* having enqueued nothing: silent message loss on the production default
* drivers. SQLite hands back ISO-Z text on both sides, so the SQLite arm was
* always correct — which is why every existing test stayed green, and why the
* ISO cases below are the CONTROL group: they must keep passing unchanged.
*
* The discriminating `Date` input exists in CI only inside
* `@objectstack/driver-sql` today (#13973 point 4), so this pin lives in THIS
* package driving hand-made `Date`s — deliberately NOT by widening any
* required job's package set (#13567, maintainer decision).
*
* Assertions are DIRECTIONAL, not literal: out-of-window must stop blocking,
* in-window must keep blocking, and `pending`/`running` rows must block
* regardless of age (that arm bypasses the time compare entirely).
*/

import { describe, it, expect } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
} from '@objectstack/objectql';
import { DbQueueAdapter } from './db-queue-adapter.js';

/**
* Minimal engine double — only the surface `publish()` touches. `update()` and
* `delete()` are unreachable from `publish()`, but they still open with the
* engine's own dispatch predicates so this fake can never drift looser than
* ObjectQL's contract (`check:engine-double-contract`).
*/
function makeFakeEngine(seed: any[] = []) {
const rows: any[] = [...seed];
return {
rows,
async find(_table: string, opts: any = {}) {
const out = opts?.where
? rows.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// Refuse combinators rather than reading them as field names.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...rows];
// The caller's bound, by PRESENCE — `limit: 0` must bound to zero rows.
return typeof opts?.limit === 'number' ? out.slice(0, opts.limit) : out;
},
async insert(_table: string, data: any) {
rows.push({ ...data });
return { id: data.id };
},
async update(_table: string, data: any, options?: any): Promise<never> {
assertEngineUpdateDispatch(data, options);
throw new Error('not reachable from publish()');
},
async delete(_table: string, options?: any): Promise<never> {
assertEngineDeleteDispatch(options);
throw new Error('not reachable from publish()');
},
};
}

/** Frozen "now" so window edges are deterministic. */
const NOW_MS = Date.parse('2026-08-30T10:00:00.000Z');
const WINDOW_MS = 60_000;

function makeAdapter(seed: any[]) {
const engine = makeFakeEngine(seed);
const adapter = new DbQueueAdapter({
engine,
clock: { now: () => new Date(NOW_MS) },
options: { autoStart: false, idempotencyWindowMs: WINDOW_MS },
});
return { engine, adapter };
}

function terminalRow(id: string, status: 'completed' | 'dlq', createdAt: unknown) {
return {
id,
queue: 'q',
idempotency_key: 'k',
status,
created_at: createdAt,
};
}

describe('[#13993] publish idempotency window vs created_at materialisation', () => {
describe('Date side (Postgres/MySQL/Mongo hand the audit column back as a JS Date)', () => {
it('an OUT-OF-WINDOW terminal Date row no longer blocks — publish enqueues a NEW message', async () => {
// Pre-fix this row blocked FOREVER: String(Date) begins with a weekday
// letter, lexicographically above the ISO windowStart's digit.
const { engine, adapter } = makeAdapter([
terminalRow('row_old', 'completed', new Date(NOW_MS - 2 * WINDOW_MS)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old');
const inserted = engine.rows.find((r) => r.id === id);
expect(inserted).toBeDefined();
expect(inserted.status).toBe('pending');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal Date row still blocks — old id back, nothing enqueued', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent', 'dlq', new Date(NOW_MS - WINDOW_MS / 2)),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent');
expect(engine.rows).toHaveLength(1);
});
});

describe('ISO-text side (SQLite/turso/wasm/memory status quo — the CONTROL group)', () => {
// The lexicographic compare was CORRECT on ISO-Z text (order = chronology).
// These two must hold before AND after the fix; a red here is a regression
// in the only arm that ever worked.
it('an OUT-OF-WINDOW terminal ISO row does not block (as it never did on SQLite)', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_old_iso', 'completed', new Date(NOW_MS - 2 * WINDOW_MS).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_old_iso');
expect(engine.rows).toHaveLength(2);
});

it('an IN-WINDOW terminal ISO row still blocks', async () => {
const { engine, adapter } = makeAdapter([
terminalRow('row_recent_iso', 'completed', new Date(NOW_MS - WINDOW_MS / 2).toISOString()),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_recent_iso');
expect(engine.rows).toHaveLength(1);
});
});

describe('epoch-ms number (pre-canonical / hand-migrated SQLite column)', () => {
it('windowed verdicts hold for a numeric created_at too', async () => {
const outOfWindow = makeAdapter([
terminalRow('row_old_num', 'completed', NOW_MS - 2 * WINDOW_MS),
]);
const idA = await outOfWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idA).not.toBe('row_old_num');

const inWindow = makeAdapter([
terminalRow('row_recent_num', 'completed', NOW_MS - WINDOW_MS / 2),
]);
const idB = await inWindow.adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(idB).toBe('row_recent_num');
});
});

describe('reverse control: the non-terminal arm bypasses the time compare', () => {
// pending/running block REGARDLESS of age — prove the fix did not narrow
// that arm. Both materialisations, both statuses, absurdly old stamps.
it('a pending row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_pending', queue: 'q', idempotency_key: 'k', status: 'pending', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_pending');
expect(engine.rows).toHaveLength(1);
}
});

it('a running row blocks however old, Date and ISO alike', async () => {
for (const createdAt of [
new Date(NOW_MS - 1000 * WINDOW_MS),
new Date(NOW_MS - 1000 * WINDOW_MS).toISOString(),
]) {
const { engine, adapter } = makeAdapter([
{ id: 'row_running', queue: 'q', idempotency_key: 'k', status: 'running', created_at: createdAt },
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).toBe('row_running');
expect(engine.rows).toHaveLength(1);
}
});
});

describe('a created_at that denotes no instant', () => {
it('cannot be inside a window measured on the created_at axis — does not block', async () => {
// Documented decision (createdAtInstantMs): duplicate delivery is
// tolerated by contract; "suppress forever" is the defect. Pre-fix this
// very value DID block forever ('n' is above '2' lexicographically).
const { engine, adapter } = makeAdapter([
terminalRow('row_opaque', 'completed', 'not-an-instant'),
]);
const id = await adapter.publish('q', { x: 1 }, { idempotencyKey: 'k' });
expect(id).not.toBe('row_opaque');
expect(engine.rows).toHaveLength(2);
});
});
});
64 changes: 62 additions & 2 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,57 @@ import {

const QUEUE_TABLE = 'sys_job_queue';

/**
* [#13993] An ISO-8601 date-time that denotes an ABSOLUTE instant — it
* carries an explicit `Z` or a numeric offset, so reading it never consults
* the process timezone. Same shape as the OCC seam's `ABSOLUTE_ISO_INSTANT`
* (#13382, `packages/metadata-protocol/src/protocol.ts`): a string this
* pattern rejects does not denote an instant and is not guessed at.
*/
const ABSOLUTE_ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;

/**
* [#13993] A `created_at` as a driver hands it out of the record read door,
* read as epoch milliseconds — or null when the value does not denote an
* instant.
*
* `created_at` is a builtin audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and the dialects genuinely disagree on
* its materialisation (the domain below is the one #13382 measured and #13973
* re-measured, pinned in `driver-sql`'s
* `sql-driver-13567-audit-stamp-materialisation.test.ts`):
*
* - **JS `Date`** — Postgres / MySQL (`timestamptz` / `DATETIME(3)` are
* instants and the driver materialises them as `Date` on purpose;
* `SqlDriver.withPostgresCalendarDayAsText` says so in as many words).
* - **canonical ISO-8601 UTC text** — SQLite and its turso / sqlite-wasm
* siblings, and `driver-memory`. This adapter writes `toISOString()`.
* - **`number`, epoch milliseconds** — a pre-canonical or hand-migrated
* SQLite column; the legacy datetime repair is keyed on declared
* `Field.datetime` columns, and the engine-injected audit columns are not
* in that set.
* - **anything else** — not an instant. Returns null, and the caller treats
* the row as OUTSIDE the window: the dedup window is measured on the
* `created_at` axis, so a row that cannot be placed on that axis cannot be
* inside it (and duplicate delivery is tolerated by contract — see
* `claimBatch` — while "suppress forever" is the very defect #13993
* removes).
*/
function createdAtInstantMs(value: unknown): number | null {
let ms: number;
if (value instanceof Date) {
ms = value.getTime();
} else if (typeof value === 'number') {
ms = value;
} else if (typeof value === 'string' && ABSOLUTE_ISO_INSTANT.test(value.trim())) {
ms = Date.parse(value.trim());
} else {
return null;
}
return Number.isFinite(ms) ? ms : null;
}

/**
* How long a `completed` row survives before the platform Reaper deletes it.
*
Expand DownExpand Up@@ -247,7 +298,7 @@ export class DbQueueAdapter implements IQueueService {
// constructor — which makes "the reaper deleted a row the dedup check
// needed" unrepresentable rather than merely unlikely.
if (opts.idempotencyKey) {
const windowStart = new Date(now.getTime() - this.opts.idempotencyWindowMs).toISOString();
const windowStartMs = now.getTime() - this.opts.idempotencyWindowMs;
const existing = await this.engine.find(QUEUE_TABLE, {
where: {
queue,
Expand All@@ -259,7 +310,16 @@ export class DbQueueAdapter implements IQueueService {
});
const blocking = (existing ?? []).find((row: any) => {
if (row.status === 'pending' || row.status === 'running') return true;
return String(row.created_at ?? '') >= windowStart;
// [#13993] Compare INSTANTS, not strings (#13382's shape). The old
// `String(row.created_at) >= windowStart` was a lexicographic compare
// whose left side, on Postgres/MySQL, is a `Date.toString()` starting
// with a weekday LETTER — unconditionally above the ISO text's digit —
// so every terminal row blocked forever and publish() silently
// enqueued nothing. An instant compare gives every materialisation the
// same verdict; a row whose created_at denotes no instant cannot be
// inside the window (see createdAtInstantMs).
const createdAtMs = createdAtInstantMs(row.created_at);
return createdAtMs !== null && createdAtMs >= windowStartMs;
});
if (blocking) return String(blocking.id);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3241,6 +3241,16 @@
"verb": "findOne",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts",
"verb": "update",
"pinned": 1
},
{
"file": "packages/services/service-queue/src/db-queue-adapter.test.ts",
"verb": "delete",
Expand Down
Loading