Adapter Architecture
Overview
Section titled “Overview”This document describes the design philosophy behind Queuert’s adapter system, including factory patterns, context management, and notification optimization.
Provider vs Adapter
Section titled “Provider vs Adapter”Queuert uses a two-layer abstraction for external integrations:
- Provider — a minimal interface that users implement to wrap their chosen database or messaging client. It contains only low-level operations (
executeSql,withTransaction,publish/subscribe). Each driver library (pg, better-sqlite3, ioredis, etc.) gets its own provider implementation. - Adapter — a high-level interface that Queuert builds from a provider via a
create*factory function. Adapters contain the full domain logic (job lifecycle, state transitions, notification semantics) and are whatcreateClientandcreateInProcessWorkerconsume.
The factory transforms a provider into an adapter:
PgStateProvider → createPgStateAdapter() → StateAdapterSqliteStateProvider → createSqliteStateAdapter() → StateAdapterPgNotifyProvider → createPgNotifyAdapter() → NotifyAdapterRedisNotifyProvider → createRedisNotifyAdapter() → NotifyAdapter createNatsNotifyAdapter() → NotifyAdapterThis separation keeps driver-specific code isolated in the provider while the adapter layer remains database-agnostic. Users only implement the provider; they never implement the adapter interface directly.
Conformance
Section titled “Conformance”Because each create* factory produces an adapter with the same contract regardless of the provider underneath, Queuert ships a conformance suite that validates any provider-built adapter against that contract.
The suite is exposed as a framework-agnostic runner under the queuert/conformance subpath. Users wire it into a single test() block from their framework of choice; internal Queuert specs go through the same case list via a thin vitest binding so there’s no drift between end-user validation and internal coverage.
See the Conformance reference for the API and the Custom Adapters guide for a walkthrough.
Async Factory Pattern
Section titled “Async Factory Pattern”Public-facing adapter factories are async for consistency — including the in-process ones (createInProcessStateAdapter, createInProcessNotifyAdapter), even though they perform no I/O.
Rationale
Section titled “Rationale”- Consistency: All public factories follow the same async pattern, reducing cognitive load
- Future-proofing: Factories can add initialization I/O without breaking API
- Explicit async: Callers know to
awaitand handle potential errors
StateAdapter Design
Section titled “StateAdapter Design”Atomic Operations Principle
Section titled “Atomic Operations Principle”StateAdapter methods should complete in a single database round-trip wherever the database engine supports it, and in a constant number of round trips everywhere else. This is a core design principle:
- O(1) round trips: The number of database operations a method executes must not depend on how many jobs it affects. One is the target; a small fixed number above one (for example, a locking
SELECTbefore theUPDATEit guards) is acceptable where the engine requires it - O(n) is incorrect: If an adapter implementation requires multiple round trips proportional to input size, the implementation is wrong
- Batch operations: Array-accepting methods must use batch SQL (multi-row INSERT, UPDATE with IN/ANY clauses, CTEs) — never loops
This principle ensures predictable performance and proper atomicity.
Context Architecture
Section titled “Context Architecture”The StateAdapter type accepts two generic parameters: TTxContext (transaction context containing database client/session info) and TJobId (the job ID type for input parameters).
The context is named TTxContext (transaction context) because it’s exclusively used within transactions. When you call withTransaction, the callback receives a context that represents an active transaction.
StateProvider Interface
Section titled “StateProvider Interface”Users create a StateProvider implementation to integrate with their database client. A state provider supplies:
transactionConcurrency—"serialized"or"concurrent"; declares whether twowithTransactioncallbacks can run at the same time (e.g. a single SQLite connection is"serialized", a Postgres pool is"concurrent")withTransaction— wraps a callback in a database transaction, passing the transaction context (TTxContext) to the callbackexecuteSql— executes a SQL string with optional params. WhentxCtxis provided, uses that transaction; when omitted, acquires and releases its own connectionwithSavepoint(optional) — runs a callback inside a savepoint within an existing transaction; when omitted, the adapter falls back to a built-in savepoint implementationclose(optional) — only needed when the provider owns resources beyond the caller-supplied client/pool
txCtx Semantics
Section titled “txCtx Semantics”Whether txCtx is optional depends on what the method does:
- Mutating methods require it (
createJobs,continueJobs,addJobsBlockers,unblockJobs,startJobAttempt,extendJobAttempt,completeJobs,reclaimExpiredJobAttempt,rescheduleJobs,deleteChains). A write is never standalone in practice — it has to commit or roll back together with the caller’s other writes, including the notify and observability side effects buffered ontransactionHooks. The type enforces this, so a missingtxCtxis a compile error rather than a silently auto-committed write. - Read-only methods leave it optional (
getChains,getJobs,getJobBlockers,getStartAttemptDelayMs,listJobTypeNames,listChainTypeNames,countByJobTypeNames,countByChainTypeNames,listChains,listJobs,listChainJobs,listBlockedJobs). With atxCtxthe read joins the caller’s transaction and sees its uncommitted writes; without one the adapter acquires its own connection, executes, and releases. lock: "exclusive"requires it.getChainsandgetJobsdon’t mutate rows, but a write-intent lock only lasts as long as the transaction that took it, so the parameter type pairslockwith a mandatorytxCtx.getChainslocks each chain’s head row;getJobslocks each job and its chain’s head row, in a consistent id order.completeJobsrelies on that head lock already being held — take it withgetJobs({ lock: "exclusive" })earlier in the same transaction, or a blocker committed in the meantime is missed and its job stays blocked against a completed chain.
At the provider layer, executeSql keeps an unconditionally optional txCtx — that’s what lets the adapter run reads on their own connection, and DDL operations (like CREATE INDEX CONCURRENTLY) that cannot run inside a transaction at all.
NotifyProvider Interface
Section titled “NotifyProvider Interface”A notify provider supplies three capabilities — no transaction context, connections are managed internally:
publish— sends a message to a channelsubscribe— listens on a channel, calling a callback on each message; returns an unsubscribe functionclose(optional) — only needed when the provider owns resources (e.g. a dedicated LISTEN client)
NotifyAdapter Design
Section titled “NotifyAdapter Design”Broadcast Semantics
Section titled “Broadcast Semantics”All notifications use broadcast (pub/sub) semantics with three notify/listen pairs: job scheduling, chain completion, and attempt loss. See the NotifyAdapter type TSDoc for method details.
Wake-Hint Methods
Section titled “Wake-Hint Methods”To prevent thundering herd when many workers are idle, the publisher attaches a per-typeName budget that gates how many listeners actually wake. Hints are an opt-in pair of methods on NotifyAdapter, both keyed by typeName:
provideWakeHint(typeName, count)— publisher addscountto the budget. Composes additively across concurrent publishers (twoprovideWakeHint(t, 3)calls yield a budget of 6).consumeWakeHint(typeName)— listener atomically claims one slot. Returnstrueif a slot was claimed, or if no budget is currently tracked (graceful degradation). Returnsfalseonly when an explicit budget was set and is now exhausted.
Flow when scheduling N jobs of typeName:
- Publisher calls
provideWakeHint(typeName, N)followed bynotifyJobScheduled(typeName). - Each receiving worker calls
consumeWakeHint(typeName). The first N returntrue(worker queries the database); subsequent calls returnfalse(worker skips). - When the hint key never existed or the TTL expired,
consumeWakeHintfalls back totrueso listeners don’t silently miss wakeups.
Adapters that don’t support hints implement the pair as no-ops (provideWakeHint: async () => {}, consumeWakeHint: async () => true) — no parameter lies, no thundering-herd protection, but everything else still works.
Callback Pattern
Section titled “Callback Pattern”All listen* methods accept a callback and return a dispose function. Subscription is active when the promise resolves, and the callback is called synchronously when notifications arrive (no race condition).
Lifecycle and Teardown
Section titled “Lifecycle and Teardown”Both StateAdapter and NotifyAdapter expose close(): Promise<void>. The contract:
- Idempotent — calling
close()a second time is a no-op. - Cascades into the provider when defined —
adapter.close()invokesprovider.close?.(). Providercloseis optional, so pass-through providers (postgres.js state,pg.Poolstate,better-sqlite3/node:sqlitestate, postgres.js notify, user-owned redis clients) simply omit it. Only providers that own resources beyond the caller-supplied client/pool (e.g. thepg.Poolnotify provider with its dedicated LISTEN client) need to implement it. - Force-tears shared listeners —
NotifyAdapter.close()tears down the pg/redis/nats shared-listener multiplex regardless of remaining callbacks, waits for any in-flightsubscribeto complete, then releases the provider’s dedicated LISTEN/subscribe client. - Post-close behavior — after close,
notify*/listen*/publish/subscribereject. Previously returned unsubscribe functions are safe to call (no-op).
Recommended teardown order:
await stopWorker(); // 1. Stop polling, drain in-flight jobsawait notifyAdapter.close(); // 2. Unsubscribe listeners, release LISTEN clientawait stateAdapter.close(); // 3. Release state-provider resources (if any)await pool.end(); // 4. Finally, close caller-owned clients/poolsObservabilityAdapter Design
Section titled “ObservabilityAdapter Design”The ObservabilityAdapter provides two observability mechanisms:
-
Metrics: Methods accept primitive data types (not domain objects) for decoupling and stability. Counters, histograms, and gauges track worker lifecycle, job events, and durations.
-
Tracing:
startJobSpanandstartAttemptSpanmethods return handles for managing span lifecycle. Spans follow OpenTelemetry messaging conventions with PRODUCER spans for job creation and CONSUMER spans for processing.
When no adapter is provided, a noop implementation is used automatically, making observability opt-in. See OTEL Tracing for span hierarchy and OTEL Metrics for available metrics. See OTEL Internals for adapter architecture and trace context propagation.
Transactional Buffering
Section titled “Transactional Buffering”Observability events emitted inside database transactions are buffered and only flushed after the transaction commits. If the transaction rolls back, buffered events are discarded – no misleading metrics or spans leak out. Buffering uses TransactionHooks – the same mechanism that flushes notify events on commit.
Buffered – events that represent write claims inside transactions:
- Creation:
chainCreated,jobCreated,jobBlocked, and PRODUCER span ends from chain creation and continuation - Completion:
jobCompleted,jobDuration,completeJobSpan(workerless),chainCompleted,chainDuration,completeBlockerSpan,jobUnblocked - Worker complete: continuation PRODUCER span ends from the complete transaction
- Error handling:
jobAttemptFailedfrom the error-handling transaction
Not buffered – events that either need immediate context or occur outside transactions:
- Span starts: Need trace context immediately for DB writes that store trace IDs
- Events outside transactions:
jobAttemptStarted,jobAttemptDuration,jobAttemptExtended,jobAttemptCompleted(emitted once the complete transaction has committed), attempt span ends (these occur outside the guarded transaction) - Read-only observations: events that observe state without making write claims
Self-Cleaning
Section titled “Self-Cleaning”Each transactional phase runs inside a savepoint, so buffered observability events roll back with the writes when the phase throws – partial events from a failed operation never accumulate in the buffer. The checkpoint callback on each hook definition captures the buffer position, and the savepoint restores it on rollback.
See Also
Section titled “See Also”- OTEL Metrics — Counters, histograms, and gauges
- OTEL Tracing — Span hierarchy and messaging conventions
- OTEL Internals — Adapter architecture, W3C context propagation, and transactional buffering
- Client API — Mutation and query methods
- In-Process Worker — Worker lifecycle and attempt management