Server-side engine for the MemberJunction durable work queue: the Database transport driver and its SQL, the
publish coordinator and deduplication ledger, the operator, the sweeper, the in-process host that runs handlers,
and the WorkQueue.* Remote Operations. Browser-safe metadata (WorkQueueEngineBase, row types, binding builders,
topology validation) lives in @memberjunction/work-queue-base; transport-neutral contracts and the consumer
runtime live in @memberjunction/work-queue-core.
Design and contract: 02-implementation-overview.md and 03-interfaces-and-tables.md under plans/work-queue-1/ on the spec/work-queue branch; the handler author's rules are in guides/WORK_QUEUE_CONSUMER_GUIDE.md.
| Area | Exports |
|---|---|
| Engine | WorkQueueEngine (facade over WorkQueueEngineBase.Instance; drivers, Publish/PublishAs, GetDriver, OnDeadLettered) |
| Database transport | DatabaseTransportDriverFactory, DatabaseTransportDriver, DatabaseTransportConsumer, DatabaseTransportOperator, DATABASE_TRANSPORT_CAPABILITIES |
| SQL | WorkQueuePublishSql, WorkQueueConsumeSql, WorkQueueOperatorSql (one builder set for SQL Server and PostgreSQL, rendering calls to the spWorkQueue* procedures), RunInWorkQueueTransaction, TryAcquireSweepLock |
| Publishing | WorkQueuePublishCoordinator, DeduplicationLedger |
| Runtime | WorkQueueHost, WorkQueueSweeper, BaseWorkHandler, ResolveWorkHandler, SharedProviderSource |
| Operators | WorkQueueOperatorService, the seven WorkQueue*ServerOperation classes (@RegisterClass server halves of the WorkQueue.* Remote Operations) |
| Entities | Server subclasses that validate topology rows (WorkQueueTransportEntityServer, WorkQueueTopicEntityServer, WorkQueueSubscriptionEntityServer) and refuse Save()/Delete() on driver-owned rows (messages, deliveries, deduplication) |
| Testing | CreateDatabaseConformanceHarness — runs core's transport conformance cases against a live database |
Every queue statement is a static stored procedure named spWorkQueue* (32 of them, in
migrations/v6/V202610061701__v6.2.x__Work_Queue_Guarded_Write_Sprocs.sql). Runtime roles hold EXECUTE on the
procedures only — no direct DML on the queue tables — so the claim, settle, cancel and sweep rules cannot be
bypassed by a caller with a connection string. The TypeScript builders render EXEC on SQL Server and
SELECT * FROM schema."proc"(...) on PostgreSQL; the PostgreSQL functions are produced by the SQLConverter at release
time, never hand-written.
Consumers, the operator and the sweeper each own an independent executor (CreateIndependentInstance()),
never the shared provider's ambient transaction, so a claim can never be rolled back by an unrelated caller. The
sweep lock is a transaction-scoped application lock: one sweeper pass at a time across every instance.
Identifiers that come back from SQL Server are normalised to lower case before they reach any comparison
(guides/UUID_COMPARISON_GUIDE.md).
SQL Server databases need READ_COMMITTED_SNAPSHOT ON: without snapshot reads, publishers, claimers and the
scaler block each other. WorkQueue.ValidateBindings / mj queue validate-bindings probes the setting and reports
it as an Error issue with the ALTER DATABASE to run; check it before enabling the host on a new database.
pnpm test in this package is hermetic. To run core's 26 transport conformance cases against your own database:
MJ_WORKQUEUE_LIVE_DB=1 pnpm test
It reads the repository .env, uses the seeded Database transport (D1ED3F08-7008-4DA8-BA2B-7CD6A820AEB5),
names everything it creates so it can be recognised, and deletes it afterwards. The integration bundle
work-queue-runtime (IT105) runs the same cases plus the host, operators, sweeper and REST endpoint under
pnpm run test:integration.
Enable the host in mj.config.cjs on every instance that should process work:
workQueue: {
enabled: true,
systemUserEmail: 'system@example.org', // REQUIRED: an existing MJ user; handlers run as this user
subscriptions: [{ name: '*', concurrency: 4 }], // or name specific subscriptions
idlePollMinMs: 250,
idlePollMaxMs: 5000,
shutdownDrainMs: 8000,
sweeperEnabled: true,
sweeperIntervalMs: 60000,
reconcileIntervalMs: 30000,
}
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Start the host in this process |
systemUserEmail |
none that works — set it | The user handlers, the sweeper and the REST extension's engine run as. The shipped placeholder (not.set@nowhere.com) fails start-up with a message that says so |
subscriptions |
[{ name: '*', concurrency: 4 }] |
Which subscriptions this instance runs, and how many deliveries of each at once |
idlePollMinMs / idlePollMaxMs |
250 / 5000 | Poll back-off while a Database subscription is empty |
shutdownDrainMs |
8000 | How long running handlers get to finish on shutdown. The host can take up to 2× this (drain, abort with 'Shutdown', drain again), so the process supervisor's grace period must exceed it |
sweeperEnabled / sweeperIntervalMs |
true / 60000 |
Lease expiry and retention purge. Any number of instances may enable it: a database application lock lets one pass run at a time |
reconcileIntervalMs |
30000 | How often the host re-plans. 0 plans only at start |
MJ_DISABLE_WORK_QUEUE_HOST=1 turns the host off for one process without touching configuration.
Any number of instances may run the same subscription: claims are atomic against the database (or the cloud queue), leases are fenced, and each instance only decides what it runs.
Subscription metadata is cached by WorkQueueEngineBase (@memberjunction/work-queue-base, browser-safe). The server
WorkQueueEngine delegates to it, the same split as AIEngineBase/AIEngine. Import base symbols from
@memberjunction/work-queue-base directly — the engine package does not re-export them.
Every reconcileIntervalMs the host re-plans: a subscription set to Paused, a changed lease, or a newly added
subscription takes effect without a restart. GET /health/extensions (with WorkQueueServerExtension enabled)
reports each subscription's state:
| State | Meaning | Fix |
|---|---|---|
Running |
A consumer runtime is claiming | — |
Paused |
Subscription, topic or transport is not Active |
Set it Active |
HandlerNotRegistered |
No BaseWorkHandler under HandlerKey in this process |
Import the handler's package / check the key |
Unsupported |
The transport cannot honor the policy — for example Ordered on a cloud topic (Ordered needs a Database topic) |
See the reason text |
Error |
Driver or consumer could not start | See the reason text and server log |
To see a handler run before writing one, seed metadata-optional/work-queue-samples and use
@memberjunction/work-queue-samples (HelloWorldHandler, key samples.hello); its README is a ten-minute walkthrough of
publish, retry, dead-letter, replay, cancel and the three partition modes.
import { RegisterClass } from '@memberjunction/global';
import { Outcome, FatalWorkError, type WorkContext, type WorkMessage, type WorkOutcome } from '@memberjunction/work-queue-core';
import { BaseWorkHandler } from '@memberjunction/work-queue-engine';
interface BatchReady { batchUri: string; records: number }
@RegisterClass(BaseWorkHandler, 'integration.apply-batch')
export class ApplyBatchHandler extends BaseWorkHandler<BatchReady> {
public async Handle(message: WorkMessage<BatchReady>, context: WorkContext): Promise<WorkOutcome> {
if (!message.Payload?.batchUri) {
throw new FatalWorkError('batchUri missing'); // dead-letter now, no retry
}
await applyBatch(message.Payload.batchUri, this.Provider, this.ContextUser, context.Signal); // idempotent upserts
return Outcome.Complete();
}
}
Rules: handlers must be idempotent; use this.Provider / this.ContextUser (the host's system user) for every data
call; honor context.Signal and stop when it aborts; any other thrown error retries with backoff. With the default
HeartbeatMode = 'Auto' the runtime renews the lease itself, every min(LeaseSeconds / 3, 30 s). The full guidance
— idempotency keys, long-running work, abort reasons, publishing in order, filters — is in the consumer guide,
guides/WORK_QUEUE_CONSUMER_GUIDE.md.
A handler class must be loaded in the process that runs it: MJ resolves HandlerKey through the ClassFactory,
so the package that declares the handler has to appear in the host's class manifest (mj codegen manifest) or be
imported by the application. A key that resolves to nothing plans as HandlerNotRegistered and its deliveries stay
Pending for another instance.
| Task | CLI | Remote Operation |
|---|---|---|
| Publish a test message | mj queue publish --topic t --payload '{…}' [--partition-key k] [--count n] |
WorkQueueEngine.Instance.Publish(...) from MJ code; REST for external producers |
| Counts | mj queue stats [--subscription s] |
WorkQueue.GetSubscriptionStats |
| Dead letters | mj queue dead-letters --subscription s |
WorkQueue.ListDeadLetters |
| Blocked keys | mj queue partitions --subscription s --condition Blocked |
WorkQueue.ListPartitions |
| Retry a dead letter | mj queue replay --subscription s --delivery id |
WorkQueue.ReplayDeadLetter |
| Drop work, or cancel a running handler | mj queue discard --subscription s --delivery id --reason "…" |
WorkQueue.DiscardDelivery |
| Backlog for an autoscaler | mj queue backlog --subscription s |
WorkQueue.GetBacklog |
| Check bindings | mj queue validate-bindings [--transport t] |
WorkQueue.ValidateBindings |
| Run work in a container job | mj queue work --subscription s --once |
— |
Who may call the operations. Every caller needs entity permissions: Read on MJ: Work Queue Deliveries for
the read operations (stats, dead letters, partitions, backlog, validate-bindings) and Update on
MJ: Work Queue Subscriptions for replay and discard. API-key callers additionally need the workqueue:read or
workqueue:operate scope, which the MJAPI application allows (metadata/api-application-scopes). Messages and
deliveries are readable by administrative roles only: payloads may hold personal data.
Discard and cancel. Discarding a pending or dead-lettered delivery resolves it immediately. Discarding an
in-flight delivery is a cancel (cancelRequested: true): the row is flagged, the holder's next heartbeat — at
most 30 seconds away — reports Cancelled, the runtime aborts the handler with Signal.reason === 'Cancelled' and
acknowledges, and the row becomes Discarded as soon as the handler returns, freeing its Exclusive/Ordered key.
A handler that ignores its signal holds the key until its lease runs out; if the holder has died, lease expiry
discards the row. A cancelled delivery is never retried.
Alerting on dead letters. WorkQueueEngine.Instance.OnDeadLettered(event => …) is in-process only: it fires
in the process that dead-lettered the item. A dead letter produced by another instance's sweeper, or inside a one-shot
container job, reaches no listener elsewhere. Alert from WorkQueue.GetSubscriptionStats (DeadLettered > 0) on the
Database transport and from the DLQ alarms on AWS.
Instead of a long-running host, run one-shot jobs that claim a bounded amount of work and exit:
mj queue work --subscription venue-import --once # claim 1, run, drain, exit 0
mj queue work --subscription venue-import --once --max 5 --concurrency 2 --max-duration-ms 3300000
--max bounds deliveries received, so a job never claims work it will not run. --max-duration-ms stops claiming
after that long and drains; keep it below the scheduler's job deadline. SIGTERM and SIGINT drain the host in both
--once and long-running modes.
Exit codes. 0 when the host ran — including "the queue was empty", a spent budget, --max-duration-ms, a
signal, or a subscription an operator paused — so a scheduler never records a failure for idleness. Non-zero when
the requested subscription could not run at all: unknown name, HandlerNotRegistered, Unsupported, Error, or a
boot/configuration failure. A mis-deployed image therefore fails loudly instead of "succeeding" forever.
Scaler query. Give the scaler its own SELECT-only login (scripts/work-queue-scaler-login.sql) and use the query
below. It counts claimable Pending plus InFlight, applies single flight per key, and caps each count at 1000.
Counting InFlight is load-bearing: schedulers subtract running executions from the metric, so a Pending-only
count scales to zero while work is still running and starves the queue. Each half walks an index in order and needs
READ_COMMITTED_SNAPSHOT so it never blocks behind writers. A Paused subscription reports only its InFlight
rows; a blocked Ordered key contributes nothing because its waiting rows are not heads. On an Exclusive
subscription the query counts rows, not distinct keys, so it can over-count; the cost is a job that starts, claims
nothing and exits 0. mj queue backlog / WorkQueue.GetBacklog is exact — prefer it where the scaler can call an API.
-- SQL Server. @SubscriptionName = the subscription the scaled job runs. Replace __mj with your core schema.
SELECT
(SELECT COUNT(*) FROM (
SELECT TOP (1000) 1 AS x
FROM __mj.WorkQueueDelivery d
INNER JOIN __mj.WorkQueueSubscription s ON s.ID = d.SubscriptionID
WHERE s.Name = @SubscriptionName AND s.Status = 'Active'
AND d.Status = 'Pending' AND d.VisibleAt <= SYSDATETIMEOFFSET()
AND (d.PartitionKey IS NULL
OR (NOT EXISTS (SELECT 1 FROM __mj.WorkQueueDelivery f
WHERE f.SubscriptionID = d.SubscriptionID AND f.PartitionKey = d.PartitionKey AND f.Status = 'InFlight')
AND (s.PartitionMode <> 'Ordered'
OR NOT EXISTS (SELECT 1 FROM __mj.WorkQueueDelivery e
WHERE e.SubscriptionID = d.SubscriptionID AND e.PartitionKey = d.PartitionKey
AND e.OrderKey < d.OrderKey AND e.Status IN ('Pending', 'InFlight', 'DeadLettered')))))
) claimable)
+ (SELECT COUNT(*) FROM (
SELECT TOP (1000) 1 AS x
FROM __mj.WorkQueueDelivery d
INNER JOIN __mj.WorkQueueSubscription s ON s.ID = d.SubscriptionID
WHERE s.Name = @SubscriptionName AND d.Status = 'InFlight'
) inflight) AS Backlog;
PostgreSQL form: replace SYSDATETIMEOFFSET() with now(), quote identifiers ("__mj"."WorkQueueDelivery",
d."Status" …), drop TOP (1000) and end each inner SELECT with LIMIT 1000.
KEDA ScaledJob (one delivery per job, scale to zero, at most 5 at a time):
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: mj-work-queue-venue-import
spec:
jobTargetRef:
parallelism: 1
completions: 1
backoffLimit: 0 # the queue owns retries; never let the scheduler re-run a job
activeDeadlineSeconds: 3600 # hard stop; --max-duration-ms below is the graceful one
template:
spec:
restartPolicy: Never # required for Jobs; with backoffLimit 0 a failed pod is not restarted
terminationGracePeriodSeconds: 30 # > 2 × --shutdown-drain-ms, so a drain is never cut short
containers:
- name: worker
image: <your mj image>
args: ["queue", "work", "--subscription", "venue-import", "--once",
"--max-duration-ms", "3300000", "--shutdown-drain-ms", "10000"]
pollingInterval: 10
maxReplicaCount: 5
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
scalingStrategy:
strategy: accurate # the query already includes in-flight work
triggers:
- type: mssql
metadata:
targetValue: "1"
query: <the scaler query above, on one line, with the subscription name inlined>
authenticationRef:
name: mj-work-queue-scaler-auth # the SELECT-only login
Azure Container Apps event-driven jobs are the same shape: replicaTimeout above --max-duration-ms plus the drain,
replicaRetryLimit: 0, and the same query as the scale rule.
Sizing. LeaseSeconds must cover the job's worst heartbeat outage (a database failover), not just its runtime —
a lease that expires while the job is healthy lets a second job claim the same delivery. 2 × --shutdown-drain-ms
must fit inside the platform's termination grace period.
Ordered key is blockedmj queue partitions --subscription <s> --condition Blocked — note HeadDeliveryID and WaitingItems.mj queue dead-letters --subscription <s> — read Reason and LastError for that delivery.mj queue replay --subscription <s> --delivery <HeadDeliveryID> --note "<what changed>" — the head keeps its
position; the key resumes when it completes. If the work must be dropped instead:
mj queue discard … --reason "<why>".InFlightmj queue stats --subscription <s> — InFlight that never falls.mj queue discard --subscription <s> --delivery <id> --reason "<why>".
Expect cancelRequested: true; the row is Discarded within ~30 seconds if the handler honors its signal.LeaseSeconds + sweeperIntervalMs and
the delivery retries (or dead-letters on its final attempt). Check that some instance has sweeperEnabled: true.workQueue.enabled and different GRAPHQL_PORTs.None, one Exclusive and one Ordered subscription on a handler that sleeps
0–200 ms and fails 2 % of the time (TransientWorkError).POST /work-queue/topics/…/messages.kill -9 one instance twice, restart it each time; cancel ten in-flight deliveries.Completed, DeadLettered or Discarded; no Exclusive/Ordered key ever had two
InFlight rows (SELECT SubscriptionID, PartitionKey, COUNT(*) FROM WorkQueueDelivery WHERE Status = 'InFlight' AND PartitionKey IS NOT NULL GROUP BY SubscriptionID, PartitionKey HAVING COUNT(*) > 1 returns nothing throughout);
Ordered completion order per key equals OrderKey order; mj queue stats shows no stuck InFlight after the run.