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
14 changes: 14 additions & 0 deletions .changeset/17612-db-queue-idle-backoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/core": minor
"@objectstack/service-queue": minor
"@objectstack/service-messaging": patch
---

`DbQueueAdapter` backs off while `sys_job_queue` is idle instead of polling flat at 1 s, and the loop that does it is now published from `@objectstack/core` as `DispatchLoop` (#17612).

A registered-but-idle queue issued **3600 candidate reads an hour, per queue**, whatever was in the table — on a remote driver, 3600 HTTP round trips an hour of pure idle cost. Measured over one simulated idle hour on the engine boundary the adapter really talks to: **3601 reads before, 124 after**, with the flat-poll number re-measured on the same harness as a control so the new one is a reading about the backoff rather than about a loop that stopped ticking.

- **One mechanism, not a third copy.** The idle-backoff loop was written for `NotificationDispatcher` (#17610), shared with `HttpDispatcher` (#17623), and lived unexported inside `@objectstack/service-messaging`. `DbQueueAdapter` was the third polling worker needing it. It moves to `@objectstack/core` — the package all three already depend on — because it is a timing primitive owned by neither the messaging domain nor the queue domain, and having `service-queue` depend on `service-messaging` to reach it would invert the dependency direction. **New export from `@objectstack/core`: `DispatchLoop`, `DispatchLoopOptions`, `DEFAULT_MAX_IDLE_INTERVAL_MS`.**
- **Nothing published moved.** `@objectstack/service-messaging` exports only its `index`, which never carried the loop; its two dispatchers now import it from `@objectstack/core` and its own surface is byte-unchanged.
- **New option `DbQueueAdapterOptions.maxIdleIntervalMs`** (default 30 s). Each tick that claims nothing doubles the delay to the next from `pollIntervalMs` up to this ceiling; anything claimed, and every wake, snaps it straight back. **Setting it at or below `pollIntervalMs` restores the flat poll exactly.**
- ⚠️ **What the backoff costs, and what it does not.** Work published through this adapter now wakes the loop, so a due `publish()` and `replay()` are picked up at the base interval as before — the ceiling is never on their latency path. What it does cost is up to `maxIdleIntervalMs` of extra latency on work this process was never told about: a row another node wrote, a deferred row coming due, a crashed worker's lease expiring. A deferred `publish()` deliberately does **not** wake the loop, since that tick would claim nothing and would throw the backoff away.
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The timer loop both outbox dispatchers run — `NotificationDispatcher` over
* `sys_notification_delivery` and `HttpDispatcher` over `sys_http_delivery`.
* The timer loop every polling worker in the platform runs —
* `NotificationDispatcher` over `sys_notification_delivery`, `HttpDispatcher`
* over `sys_http_delivery`, and `DbQueueAdapter` over `sys_job_queue`.
*
* #17610 wrote this loop inside `NotificationDispatcher`. #17623 found
* `HttpDispatcher` still on the fixed 500 ms `setInterval` the notification side
* had just left, and moved the loop here, so the two dispatchers run one
* implementation of these rules instead of two copies that can drift:
* had just left, and pulled the loop out into one implementation of these rules
* instead of two copies that can drift. #17612 found the third copy —
* `DbQueueAdapter` on a flat 1 s `setInterval` — and that is why the loop
* lives HERE, in `@objectstack/core`, rather than in either service: it is a
* timing primitive, owned by neither the messaging domain nor the queue
* domain, and a service-to-service dependency between them to share it would
* invert the direction (a queue service depending on a messaging service).
* `@objectstack/core` is the package all three already depend on.
*
* The rules, one implementation:
*
* - **Never two ticks at once.** A tick asked for while one is running (a
* {@link DispatchLoop.wake}, typically) becomes ONE follow-up tick, run the
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export * from './dependency-resolver.js';
// Export Phase 3 components - Package lifecycle management
export * from './namespace-resolver.js';

// [#17612] The one polling-worker timer loop — tick coalescing, idle backoff
// and a final stop() — shared by every worker that drains a table
// (service-messaging's two outbox dispatchers, service-queue's DbQueueAdapter).
// It lives here because it is a timing primitive belonging to neither domain.
export * from './dispatch-loop.js';

// Re-export contracts from @objectstack/spec for backward compatibility
export type {
Logger,
Expand Down
4 changes: 2 additions & 2 deletions packages/services/service-messaging/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { MessagingChannel, MessagingChannelContext, Notification, SendResul
import type { AckResult, ClaimedDeliveryRecord, INotificationOutbox, NotificationDeliveryRecord } from './outbox.js';
import { classifyDeliveryAttempt } from './backoff.js';
import { renderDigest } from './digest-render.js';
import { DispatchLoop } from './dispatch-loop.js';
import { DispatchLoop } from '@objectstack/core';

/** Minimal channel-registry surface the dispatcher needs (MessagingService satisfies it). */
export interface ChannelRegistry {
Expand Down Expand Up @@ -49,7 +49,7 @@ export interface NotificationDispatcherLogger {
* {@link NotificationDispatcherOptions.maxIdleIntervalMs}. It lives with the
* loop both dispatchers run (#17623) and stays exported from here.
*/
export { DEFAULT_MAX_IDLE_INTERVAL_MS } from './dispatch-loop.js';
export { DEFAULT_MAX_IDLE_INTERVAL_MS } from '@objectstack/core';

export interface NotificationDispatcherOptions {
nodeId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
* The loop used to tick every `intervalMs` (500 ms) forever, whatever it found —
* the shape #17610 removed from `NotificationDispatcher`. Both dispatchers now
* run the one `DispatchLoop` (`dispatch-loop.ts`), whose mechanics are pinned in
* run the one `DispatchLoop` (`@objectstack/core`), whose mechanics are pinned in
* full through the notification side in `dispatcher-idle-backoff.test.ts`. This
* file pins the same behaviour through `HttpDispatcher`, plus what only the HTTP
* side has: a retry schedule the backoff must not starve, and its own ingress —
Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-messaging/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { DispatchCluster, DispatchLockHandle } from './dispatcher.js';
import { DispatchLoop } from './dispatch-loop.js';
import { DispatchLoop } from '@objectstack/core';
import { classifyAttempt, sendOnce, type FetchImpl } from './http-sender.js';
import type { HttpAckResult, HttpClaimCredential, HttpDelivery, IHttpOutbox } from './http-outbox.js';

Expand Down
69 changes: 57 additions & 12 deletions packages/services/service-queue/src/db-queue-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
QueueHandler,
} from '@objectstack/spec/contracts';
import { SysJobQueue } from '@objectstack/platform-objects/audit';
import { DispatchLoop, DEFAULT_MAX_IDLE_INTERVAL_MS } from '@objectstack/core';
import {
SYSTEM_CTX,
uid,
Expand Down Expand Up @@ -135,6 +136,21 @@ export interface LifecycleFloorRegistrar {
export interface DbQueueAdapterOptions {
/** Polling interval for the worker loop (ms, default 1000) */
pollIntervalMs?: number;
/**
* [#17612] Ceiling of the IDLE backoff, in ms (default
* {@link DEFAULT_MAX_IDLE_INTERVAL_MS}, 30 s). Each tick that claims nothing
* doubles the delay to the next from `pollIntervalMs` up to this value; any
* tick that claims work, and every {@link DbQueueAdapter.wake}, snaps it
* straight back to `pollIntervalMs`.
*
* A value at or below `pollIntervalMs` disables the backoff and restores the
* flat poll. What the backoff costs is latency on work this process was not
* told about — a row another node wrote, a deferred row coming due, a
* crashed worker's lease expiring: never noticed more than this long after it
* became claimable. Work published through THIS adapter wakes the loop, so it
* is unaffected.
*/
maxIdleIntervalMs?: number;
/** Max messages claimed per poll tick (default 10) */
batchSize?: number;
/** Lease duration before another worker may reclaim (ms, default 30000) */
Expand Down Expand Up @@ -192,8 +208,7 @@ export class DbQueueAdapter implements IQueueService {
private readonly opts: Required<Omit<DbQueueAdapterOptions, 'workerId'>> & { workerId: string };

private readonly handlers = new Map<string, RegisteredHandler[]>();
private timer?: ReturnType<typeof setInterval>;
private running = false;
private loop?: DispatchLoop;

constructor(args: {
engine: JobEngine;
Expand All @@ -207,6 +222,7 @@ export class DbQueueAdapter implements IQueueService {
const o = args.options ?? {};
this.opts = {
pollIntervalMs: o.pollIntervalMs ?? 1000,
maxIdleIntervalMs: o.maxIdleIntervalMs ?? DEFAULT_MAX_IDLE_INTERVAL_MS,
batchSize: o.batchSize ?? 10,
leaseMs: o.leaseMs ?? 30_000,
idempotencyWindowMs: o.idempotencyWindowMs ?? 24 * 60 * 60 * 1000,
Expand Down Expand Up @@ -353,6 +369,13 @@ export class DbQueueAdapter implements IQueueService {
updated_at: now.toISOString(),
}, { context: SYSTEM_CTX });

// [#17612] Tick now rather than wait out the idle backoff — but only for a
// row that is claimable THIS instant. A deferred row is exactly the case
// the loop's contract already covers (noticed within one backed-off
// interval of coming due), and waking for it would reset the backoff to
// `pollIntervalMs` for a tick guaranteed to claim nothing.
if (Date.parse(scheduledFor) <= now.getTime()) this.wake();

return id;
}

Expand Down Expand Up @@ -426,6 +449,9 @@ export class DbQueueAdapter implements IQueueService {
scheduled_for: now.toISOString(),
updated_at: now.toISOString(),
}, { context: SYSTEM_CTX });
// [#17612] `replay` re-arms the row for RIGHT NOW, so it wakes the loop
// unconditionally — there is no deferred case here to discriminate.
this.wake();
}

async purgeFailed(messageId: string): Promise<void> {
Expand All @@ -440,19 +466,38 @@ export class DbQueueAdapter implements IQueueService {
// ── Worker lifecycle ─────────────────────────────────────────────

start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
if (this.running) return;
this.running = true;
this.pollOnce()
.catch((err) => { this.logger?.warn?.('DbQueueAdapter: poll tick failed', err); })
.finally(() => { this.running = false; });
}, this.opts.pollIntervalMs);
(this.timer as any)?.unref?.();
if (this.loop) return;
// [#17612] The shared polling loop (`@objectstack/core`), not a bare
// `setInterval`: it coalesces overlapping ticks — the job the `running`
// flag here used to do — AND backs off while the queue is idle. A flat 1 s
// poll cost 3600 candidate SELECTs an hour per registered queue whatever
// was in the table; on a remote driver every one of those is an HTTP round
// trip. The same shape #17610 removed from `NotificationDispatcher` and
// #17623 from `HttpDispatcher`; this was the third copy.
this.loop = new DispatchLoop({
intervalMs: this.opts.pollIntervalMs,
maxIdleIntervalMs: this.opts.maxIdleIntervalMs,
runTick: () => this.pollOnce(),
onTickError: (err) => { this.logger?.warn?.('DbQueueAdapter: poll tick failed', err); },
});
this.loop.start();
}

async stop(): Promise<void> {
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
if (!this.loop) return;
const loop = this.loop;
this.loop = undefined;
await loop.stop();
}

/**
* [#17612] Work is claimable now: run a tick at once and reset the idle
* backoff. A no-op while the worker is stopped — `start()` ticks immediately
* anyway. This is what keeps {@link DbQueueAdapterOptions.maxIdleIntervalMs}
* off the latency path for anything published through this process.
*/
wake(): void {
this.loop?.wake();
}

/** Test-friendly synchronous poll. */
Expand Down
Loading
Loading