Skip to content

04 · Jobs engine (jobs, lanes, schedules, Kafka target) ​

As-built reference for the TMS v2 phase-1 jobs engine. Repo for every path:line below is bigc-tms-core-v2 (worktree wt-v2-prototype-core) unless a line says store-v2.

1. Summary ​

The jobs engine is core-v2's one durable work queue. A module calls sideEffect.enqueue(target, payload, options, tx) inside its own Mongo transaction, so a committed business change always has its job rows and a rolled-back one has none. Rows go to one of two lanes. The fast lane is for short sends such as Kafka: 64 partitions, one owner pod per partition, rows run in key + version order. The long lane is for handlers that take minutes: up to 10 rows are reserved with a claim token and a 5-min lease that is renewed every minute. When the target answered with a failure, the row is retried 5 times (1/2/4/8/16 min) and then goes DEAD. When the target could not be reached, the target is paused and no attempt is spent. Schedules are crons declared in code: every pod ticks, and each slot becomes one long-lane job whose dedupe key is <code>:<slot>. Admins list jobs, retry or skip DEAD rows, and enable, disable or override schedules.

repobranchshafolders
bigc-tms-core-v2origin/main-v2-prototype3d70475a (jobs feature 38dad930; changed by 3dd1c5a0: Kafka target moved to src/kafka, job_stats and demo schedules removed, request context added)src/jobs/, src/schedules/, src/kafka/kafka.target.ts, src/common/utils/transaction.ts, caller src/delivery_order/delivery_order.service.ts
bigc-tms-store-v2main-v2-prototype0851a3asrc/pages/setting/outbound-events/, src/pages/api/outbound_events/, src/apis/{client,server}/outbound_event/

2. File map ​

fileresponsibility
src/jobs/jobs.constants.tsEnums JobStatus (PENDING/RUNNING/DEAD), JobLane, DeliveryMode, and every tuning number: partitions, batch sizes, retry delays, poll, lease, probe, the JOBS_WORKERS switch
src/jobs/jobs.schema.tsjobs collection and its 4 indexes
src/jobs/job_partitions.schema.tsjob_partitions (64 lease rows) and job_workers (one liveness row per pod)
src/jobs/job_targets.registry.tsIn-memory target registry: name → lane, handler, lossy, delivery mode, exclusive; per-code max attempts
src/jobs/side_effect.service.tsEnqueue API (enqueue, enqueueMany), partition hash, dedupe pre-filter, lossy hand-off to Bull after commit
src/common/utils/transaction.tsinTransaction(conn, work): one Mongo transaction plus afterCommit hooks
src/jobs/fast_lane.worker.tsJobOutcome (shared outcome path for both lanes), FastLaneWorker (poll, ordered batch, per-row in-flight mark, takeover close), jobContext, nextPollDelay, retryDelayMs
src/jobs/partition_lease.service.tsPartition seeding and repair, lease heartbeat, fair-share acquire and release, in-flight marks, pod id
src/jobs/long_lane.worker.tsLong lane reserve with claim token, lease heartbeat, reclaim after expiry, exclusive-code filter, background batches
src/jobs/target_pause.tsTargetUnreachable, TargetOutcomeUnknown errors and the per-pod pause map with a 30 s probe
src/jobs/lossy.processor.tsBull processor for lossy targets (one try, dropped on failure)
src/jobs/jobs.controller.ts, jobs.dto.tsAdmin API GET /jobs, POST /jobs/:id/retry, POST /jobs/:id/skip
src/jobs/jobs.module.tsWires the models, Bull queue, the clock, pod id and the workers; exports JobTargetsRegistry and SideEffectService
src/schedules/schedules.schema.tsschedules collection
src/schedules/schedules.constants.tsMissed-slot policy enum, the schedule: target prefix, tick, grace, timezone offset, default retry count
src/schedules/cron.tsOwn 5-field cron parser (next, intervalMs) at a fixed +07:00
src/schedules/schedules.service.tsdeclare(), boot sync, tick, slot run (late-slot skip and run_once collapse), admin operations
src/schedules/schedules.controller.ts, schedules.dto.tsAdmin API /schedules
src/schedules/schedules.module.tsWires the model, imports JobsModule, exports SchedulesService
src/kafka/kafka.target.tsRegisters the kafka:<topic> fast-lane targets, sends a job through the pod producer, sorts Kafka errors into outcome classes
src/kafka/kafka.producer.tsOne long-lived producer per pod; kafkajs retries are off, connect timeout 3 s, request timeout 10 s
src/delivery_order/delivery_order.service.tsThe only business caller in this build: it enqueues the DO-update Kafka jobs inside the DO transaction

3. Data model ​

jobs (model job, jobs.schema.ts:14-82) ​

fieldtypereqdefaultenum / validationindexnote
codestringyes———Business name of the job. Defaults to target at enqueue (side_effect.service.ts:65). Selects the per-code max attempts (job_targets.registry.ts:49-51)
targetstringyes—must be registered when enqueued (side_effect.service.ts:52)—kafka:<topic> or schedule:<code>
lanestringyes—fast | long (set from the target, side_effect.service.ts:67)part of the PENDING index—
keystringno———Ordering key, for example the DO number. Doubles as the Kafka message key
partitionnumberyes—0..63 = md5(key || dedupe_key) mod 64 (side_effect.service.ts:29-32,69)part of the PENDING index—
versionnumberno0——Order inside a key (fast_lane.worker.ts:185)
payloadMixedno———Left out of list responses (jobs.controller.ts:20)
dedupe_keystringyesuuid v4 when not given (side_effect.service.ts:63)—uniqueIdempotency key
statusstringnoPENDINGPENDING / RUNNING / DEAD (schema does not enforce the enum)partial filtersOnly the long lane writes RUNNING
attemptsnumberno0——+1 per answered failure only
next_run_atDatenoset to now at enqueue—part of the PENDING index—
last_errorstringno—cut to 2000 chars (fast_lane.worker.ts:37)——
claimed_bystringno—pod id—long lane
lease_untilDateno——partial index while RUNNINGlong lane row lease
claim_tokenstringno—uuid per reserve—long lane
outcome_tokenstringno———fast lane: the in-flight token under which the outcome was written (jobs.schema.ts:60-62)
running_codestringno——unique partial while RUNNINGSet only for exclusive targets (long_lane.worker.ts:95)
closed_atDateno———Set when the row goes DEAD
trace_idstringnofrom the request context, or a new one——side_effect.service.ts:57
actorObjectnofrom the request context——Opened again as via: system when the job runs (fast_lane.worker.ts:42-47)
headersObjectno———Extra Kafka headers (kafka.target.ts:83)
created_at, updated_atDateauto———timestamps

Indexes (jobs.schema.ts:84-96):

keysoptionsserves
{lane, partition, next_run_at}partial status: PENDINGfast lane read, long lane PENDING read
{lease_until}partial status: RUNNINGlong lane reclaim
{dedupe_key}uniqueenqueue idempotency
{running_code}unique, partial status: RUNNING, running_code existsat most one RUNNING row per exclusive code across pods

The collection has no TTL index. A finished row is deleted (fast_lane.worker.ts:93). A DEAD row stays until an admin retries or skips it.

job_partitions (model job_partition, job_partitions.schema.ts:11-32) ​

fieldtypereqdefaultindexnote
partitionnumberyes—unique (built by hand before seeding, partition_lease.service.ts:42)0..63
ownerstringnonull{owner}pod id
lease_untilDateno——now + 30 s, renewed every 10 s
inflight[string]no[]—id of the job row being sent now (at most one per row in practice, fast_lane.worker.ts:208)
inflight_tokenstringno——uuid per send. Compared with jobs.outcome_token on takeover

job_workers (model job_worker, job_partitions.schema.ts:42-53) ​

fieldtypereqindexnote
pod_idstringyesuniquehostname:pid:uuid8 (partition_lease.service.ts:11)
heartbeat_atDateyes{heartbeat_at}A pod counts as live when this is newer than 30 s. Deleted on clean shutdown (partition_lease.service.ts:104)

schedules (model schedule, schedules.schema.ts:12-44) ​

fieldtypereqdefaultenumindexowner
codestringyes——uniquecode
cronstringyes—5-field cron—code (overwritten at every boot, schedules.service.ts:88)
enabledbooleannotrue—part of the partial indexadmin (set only on insert, schedules.service.ts:92)
cron_overridestringno—5-field cron—admin
missed_slot_policystringnorun_oncerun_once | skip—code. Default: skip when the interval is 5 min or less, else run_once (schedules.service.ts:36-38)
retry_countnumberno3——code. Used as the total number of runs per slot (schedules.service.ts:99)
last_run_atDateno———$max of slot times that ran (schedules.service.ts:184)
last_skipped_atDateno———$max of slot times skipped at run time (schedules.service.ts:179)
next_run_atDateno——{next_run_at} partial enabled: trueengine

Redis ​

key patternvalueTTLnote
bull:side_effect_lossy:* (Bull defaults on QUEUE_REDIS_URL, app.module.ts:40-42)lossy job {target, code, key, payload, trace_id, actor, headers}removed on complete or fail (side_effect.service.ts:119)Only lossy targets use it, and no lossy target is registered in this build

The engine keeps no other Redis state. Pause state lives in pod memory (target_pause.ts:26-30).

Relations ​

  • jobs.partition → job_partitions.partition (logical). job_partitions.inflight[] → jobs._id.
  • jobs.target = schedule:<code> and jobs.code → schedules.code. A slot is jobs.payload.slot (ISO string) with dedupe_key = <code>:<slotISO> (schedules.service.ts:34).
  • DO-update jobs: jobs.key = delivery_order.delivery_no, jobs.version = job_counters.seq (delivery_order.service.ts:159-166).

4. API ​

Global prefix /api, URI versioning v1 (main.ts:32-37), so the routes are /api/v1/.... A ValidationPipe with transform and implicit conversion runs on every route (main.ts:46-49). All routes below need role admin through the global AuthGuard (jobs.controller.ts:22, schedules.controller.ts:15, guard common/guards/auth.guard.ts:34,79-80). Any other role, system_admin included, gets 403. Errors use throwError, which returns {statusCode, code, message[]} with status 400 when none is given (common/utils/error.ts:4-16).

method + routerequestresponseerrorscode
GET /api/v1/jobsquery status (enum), target, code, key, page ≥ 1, limit 1..100 (default 100){data: Job[] (no payload, _id kept, lean), total, page, limit}, sorted _id desc400 when validation failsjobs.controller.ts:33-43, jobs.dto.ts:7-36
POST /api/v1/jobs/:id/retry—The updated row without payload: status PENDING, attempts 0, next_run_at now; claim fields unset404 not_found (unknown or invalid id) · 409 invalid_status (not DEAD, or lost a race)jobs.controller.ts:46-60,71-76
POST /api/v1/jobs/:id/skip—{_id, skipped: true}; the row is deleted and a log line written with result=skipped404 · 409 as abovejobs.controller.ts:62-69
GET /api/v1/schedules—every schedules row sorted by code—schedules.controller.ts:23-26, schedules.service.ts:116-118
POST /api/v1/schedules/:code/enable—the updated row. Going from disabled to enabled resets next_run_at to the next slot after now404 not_foundschedules.service.ts:120-125
POST /api/v1/schedules/:code/disable—the updated row404same
PUT /api/v1/schedules/:code/cron_overridebody {cron_override: string}the updated row; next_run_at follows the override404 · 400 invalid_cronschedules.service.ts:127-131, schedules.dto.ts:3-6
DELETE /api/v1/schedules/:code/cron_override—the updated row; override unset, next_run_at back on the code cron404schedules.service.ts:133-137

In-process API (the main one):

callcontractcode
SideEffectService.enqueue(target, payload, {code?, key?, version?, dedupe_key?, headers?}, tx?)Returns {inserted, lossy}. An unknown target throws unknown_target before any writeside_effect.service.ts:16-27,46-48
SideEffectService.enqueueMany(items, tx?)One insertMany for every must-not-lose row, joined to tx.session. Lossy rows go to Bull through tx.afterCommitside_effect.service.ts:51-89
JobTargetsRegistry.register({name, lane, handler, lossy?, delivery_mode?, exclusive?})A duplicate name throws duplicate_target. Delivery mode defaults to at_most_oncejob_targets.registry.ts:7-29
SchedulesService.declare({code, cron, handler, missed_slot_policy?, retry_count?})Call it from onModuleInit. It validates the cron, throws duplicate_schedule on a repeat code, and registers target schedule:<code> as long lane and exclusiveschedules.service.ts:26-32,55-65
inTransaction(conn, work)Runs work(tx) in session.withTransaction. Hooks run only after the commit; a hook error is logged, not throwncommon/utils/transaction.ts:12-32
handler errorsTargetUnreachable → pause, no attempt spent · TargetOutcomeUnknown → DEAD at once on at-most-once (pauses unless target_up), treated as unreachable on at-least-once · {dead_now: true} → DEAD at once, pause state left as is · any other error → answered failuretarget_pause.ts:5-24, fast_lane.worker.ts:67-83

5. Conditions ​

when …the system must …enforced attest
a business transaction enqueues and then abortsleave 0 job rows and 0 Bull jobsside_effect.service.ts:85,101-103, transaction.ts:17-24side_effect.service.spec.ts:88,104; delivery_order.service.spec.ts:235
the same dedupe_key is enqueued again (same or a later transaction)insert nothing and raise no error. The row is filtered out before the insert, because a duplicate-key error would abort the caller's transactionside_effect.service.ts:94-100, outside a transaction :105-111side_effect.service.spec.ts:72,82
two items in one call share a dedupe_keykeep the firstside_effect.service.ts:81untested
the target is not registeredthrow before any writeside_effect.service.ts:52, job_targets.registry.ts:35-39side_effect.service.spec.ts:121
key is absenttake the partition from dedupe_keyside_effect.service.ts:69side_effect.service.spec.ts:115
the target answered with a failureadd 1 to attempts and wait 1, 2, 4, 8, 16 min. The 6th failure (or the per-code max) → DEAD with closed_at and an job dead alert error logfast_lane.worker.ts:104-121, jobs.constants.ts:22-23fast_lane.worker.spec.ts:85,103,130
the target is unreachablepause the target on this pod for 30 s and keep attempts. The rest of that target's batch is skipped; other targets keep runningfast_lane.worker.ts:98-102,206,218, target_pause.ts:32-55fast_lane.worker.spec.ts:205,235; kafka.target.spec.ts:130,471
a target is pausedlet one probe read through every 30 s, only on the lane the target runs ontarget_pause.ts:47-55kafka.target.spec.ts:205,273
a fast-lane row of key K is waiting for a retryhold every later version of K on the same target. A DEAD row holds nothingfast_lane.worker.ts:173-183,220fast_lane.worker.spec.ts:148,162
a row succeededdelete itfast_lane.worker.ts:91-96fast_lane.worker.spec.ts:182
an at-most-once send got no answer (Kafka unknown)DEAD at once, never resent, target pausedfast_lane.worker.ts:74-77, kafka.target.ts:87kafka.target.spec.ts:235,351
Kafka answered REQUEST_TIMED_OUT / NETWORK_EXCEPTIONat-most-once → DEAD, target not paused; at-least-once → kept, attempts 0, target pausedkafka.target.ts:46,89, fast_lane.worker.ts:76-77kafka.target.spec.ts:415,434
the Kafka client rejects the record (for example an empty value)DEAD at once as "not-sent, invalid record", whatever the delivery modekafka.target.ts:45,91, fast_lane.worker.ts:72-73kafka.target.spec.ts:444,451
a fast-lane owner dies with a row in flightat-most-once → DEAD "in flight when its partition was taken over"; at-least-once → resent. A row whose outcome was already written under that token stays openfast_lane.worker.ts:249-264kafka.target.spec.ts:177,186,300,399; fast_lane.worker.spec.ts:310
a pod lost a partition lease between rowssend no further row of that partitionfast_lane.worker.ts:205,208-211, partition_lease.service.ts:109-117fast_lane.worker.spec.ts:260; kafka.target.spec.ts:364
a row was closed by a takeover while this pod still holds the leasenot send itfast_lane.worker.ts:213-215kafka.target.spec.ts:388
an exclusive code (every schedule) has a RUNNING row with a live leasekeep its other slots PENDING. Only exclusive targets wait on a busy codelong_lane.worker.ts:70,77-83, unique index jobs.schema.ts:93-96schedules.service.spec.ts:234,262,277,356
a long-lane row is RUNNING and its lease expiredlet any pod reclaim it and run it again from the startlong_lane.worker.ts:71long_lane.worker.spec.ts:98
a schedule slot's first run starts more than 60 s after its slot timepolicy skip → skip with reason previous_slot_running; run_once → skip only when a newer PENDING slot exists, otherwise run. Either skip records last_skipped_atschedules.service.ts:169-185,188-191schedules.service.spec.ts:234,352,354
a tick finds several missed slots, or one slot later than gracelog schedule missed. Under skip + late, enqueue nothing; else enqueue only the newest slot. Then move next_run_at past nowschedules.service.ts:139-167schedules.service.spec.ts:163
an admin disables a schedulecreate no new slots; a slot already enqueued still runsschedules.service.ts:107schedules.service.spec.ts:182
admin retry or skip hits a row that is not DEAD409jobs.controller.ts:57,66,74jobs.controller.spec.ts:71
a cron is malformed (not 5 fields, out of range, never fires within 5 years)throw invalid_cron (400)cron.ts:14,29-31,47,86schedules.service.spec.ts:68,213
JOBS_WORKERS=offrun no fast lane, long lane or tick on this pod. Schedule sync still runsjobs.constants.ts:40, fast_lane.worker.ts:144, long_lane.worker.ts:50, schedules.service.ts:69untested

6. States and flows ​

Job row states ​

fromeventtocode
—enqueue (must-not-lose)PENDING, attempts 0, next_run_at nowside_effect.service.ts:64-78
PENDING (fast)handler okdeletedfast_lane.worker.ts:93
PENDING (fast)answered failure, attempts < maxPENDING, attempts+1, next_run_at + backofffast_lane.worker.ts:113
PENDING / RUNNINGanswered failure at max, dead_now, or at-most-once with unknown outcomeDEAD, closed_atfast_lane.worker.ts:110-112
PENDING (fast)unreachablePENDING, unchangedfast_lane.worker.ts:98-102
PENDING (fast)owner died with the row in flight, at-most-onceDEADfast_lane.worker.ts:259-262
PENDING (long)reserveRUNNING, claim_token, lease now + 5 min, running_code when exclusivelong_lane.worker.ts:87-99
RUNNINGokdeletedlong_lane.worker.ts:114 + fast_lane.worker.ts:93
RUNNINGanswered failure or unreachablePENDING with the claim unset (RELEASE)long_lane.worker.ts:24-27
RUNNING, lease expiredreserve by any podRUNNING under a new tokenlong_lane.worker.ts:71
DEADadmin retryPENDING, attempts 0, next_run_at nowjobs.controller.ts:49-56
DEADadmin skipdeletedjobs.controller.ts:65

Fast-lane rows never become RUNNING. "Running" for them is the partition's inflight mark.

Enqueue ​

Fast lane poll ​

Partition lease heartbeat (every 10 s) ​

Fair share is ceil(64 / live pods), or max(floor, 1) while another live pod is below the floor (partition_lease.service.ts:80-96).

Long lane ​

Schedule tick and slot run ​

Schedule row ​

statehow it changescode
inserted (enabled, next_run_at = next slot)first boot that declares the codeschedules.service.ts:84-97
duenext_run_at <= now:107
advancedtick sets next_run_at = cron.next(now):162-165
disabledadmin; the tick ignores it:120-125
re-enablednext_run_at = cron.next(now), slots missed while disabled are dropped:123
overriddencron_override set; _cronOf prefers it:127-131,193-195

7. Jobs, schedules, queues, events ​

Every registered job target and schedule in the code ​

namelanehandler filetrigger (who enqueues)retrydelivery / idempotency
kafka:lastmiles.oms.do-update (code do_update_oms)fastsrc/kafka/kafka.target.ts:70-94DO create (delivery_order.service.ts:120) and DO status change (:152-153) when chanel_code ∈ DONJAI, CHOKCHAI, HLH (:27,173)6 runs (first + 1/2/4/8/16 min), then DEADat_most_once; dedupe do_update_oms:<delivery_no>:<version> (:190); key = delivery_no; header event_id = the payload's event id (:183,191)
kafka:lastmiles.staffapp.do-update (code do_update_staffapp)fastsamesame, chanel_code ∈ SPO, OD, HLH (:28,174)samesame pattern
kafka:lastmiles.staffapp.load-updatefastsamenone: registered from KAFKA_EVENTS (kafka.constants.ts:20), no caller enqueues itsameat_most_once
kafka:lastmiles.staffapp.task-updatefastsamenone (kafka.constants.ts:21)sameat_most_once
kafka:lastmiles.staffapp.driver-updatefastsamenone (kafka.constants.ts:22)sameat_most_once
kafka:<topic> from KAFKA_TARGET_TOPICSfastsamenone in code (env-declared, kafka.target.ts:11-16,64-67)sameat_most_once, or at_least_once with the :at_least_once suffix
schedule:<code>long, exclusivesrc/schedules/schedules.service.ts:59-64,169-185tick, from a declare() callretry_count total runs (default 3) at 1, 2 mindedupe <code>:<slotISO>
lossy targetsBullsrc/jobs/lossy.processor.ts—1 try, droppednone

No schedule is declared in this build. demo.schedules.ts was removed in 3dd1c5a0, and no call to SchedulesService.declare exists in src. No lossy target is registered either.

Kafka send details: the topic is the setting's topic_name override when the job's code is a declared producer event, else the target's topic (kafka.target.ts:81, kafka.config.ts:35-39). The value is JSON.stringify(payload). Headers are event_id = job._id, overridden by job.headers, plus traceparent and trace_id from the job context (kafka.target.ts:83, kafka.producer.ts:29-32,62). Error classes come from kafkaFailure (kafka.target.ts:40-51):

classexampleoutcome
answerednon-retriable protocol error, a non-Kafka errorretry path
not_sentconnect phase failed, ECONNREFUSED, broker or metadata not found, NOT_WRITTEN_TYPESTargetUnreachable → pause
unknownsend phase failed after a possible appendTargetOutcomeUnknown
timed_outREQUEST_TIMED_OUT, NETWORK_EXCEPTIONTargetOutcomeUnknown + target_up
invalidKafkaJSNonRetriableError raised by the clientdead_now

Every run writes one log line, job run code= target= dedupe_key= attempts= duration_ms= result= (fast_lane.worker.ts:85-87). DEAD writes job dead alert ... at error level (:119,263). The alert is that log line only.

8. Settings and env ​

keydefaulteffectread at
JOBS_WORKERS (env)onoff = this pod only enqueues: no fast lane, long lane or schedule tickjobs.constants.ts:40
KAFKA_TARGET_TOPICS (env)emptyExtra kafka:<topic> targets, topic[:at_least_once] separated by commas; can switch a declared topic to at-least-oncekafka.target.ts:64
QUEUE_REDIS_URL (env)—Bull connection for the lossy queueapp.module.ts:40-42
Kafka setting record, per-event topic_namedefault_topicTopic override for DO-update jobskafka.config.ts:35-39
JOBS_TEST_MONGO_URI (env, tests only)mongodb://localhost:27017/?replicaSet=rs0&directConnection=trueMongo for the jobs specs (a replica set is needed for transactions)fast_lane.worker.spec.ts:14 and the other specs
constants (code only)partitions 64, fast batch 100, long batch 10, retries [1,2,4,8,16] min, partial poll 1 s, idle 1/2/5 s, partition lease 30 s / heartbeat 10 s, long lease 5 min / heartbeat 1 min, probe 30 s—jobs.constants.ts:17-34
schedule constantstick 15 s, grace 60 s, offset +420 min, default retry 3, skip-by-default when interval ≤ 5 min—schedules.constants.ts:6-14
Kafka clientconnect 3 s, request 10 s, retries 0Caps one send well under the 30 s partition leasekafka.producer.ts:15-16

No jobs setting lives in the settings module. Retry, lease and batch numbers cannot be changed without a deploy.

9. Concurrency and recovery ​

situationwhat the code doescode
several pods boot on an empty DBEach pod removes duplicate partition rows, builds the unique index (retried up to 5 times), then upserts 64 rows; duplicate-key errors are ignoredpartition_lease.service.ts:37-62,128-144
pod joins or leavesThe heartbeat rebalances to the fair share. Busy partitions are never released. One atomic findOneAndUpdate per take, so two pods never hold one partitionpartition_lease.service.ts:65-97,157-175
pod dies mid fast-lane batchIts leases expire after 30 s and a live pod takes them. Only the row in flight is judged: at-most-once → DEAD, at-least-once → stays PENDING and is resent. Rows not yet started stay PENDINGfast_lane.worker.ts:198-222,249-264
pod stalls past its lease, then wakessetInflight fails on the lease guard, so the pod drops the partition and sends no more of it. A late outcome write still needs status: PENDING and the old attemptspartition_lease.service.ts:109-117, fast_lane.worker.ts:114-118
pod dies mid long-lane rowThe lease expires within 5 min of the last heartbeat; any pod reclaims the row and runs it from the start. The handler may therefore run twice: long lane has no at-most-once modelong_lane.worker.ts:71,132-139
two pods reserve long rows at onceupdateMany repeats the free filter, so each row ends with one token. Exclusive codes are guarded by the unique running_code indexlong_lane.worker.ts:86-104, jobs.schema.ts:93-96
same slot ticked by every podOne row per dedupe_key; next_run_at advance is guarded on its old valueschedules.service.ts:154-165
the same business event enqueued twiceFiltered before the insert, or swallowed as a duplicate key outside a transactionside_effect.service.ts:94-111
an inbound Kafka message delivered twiceDO side: event_key on activity logs rejects the replay, so no second job; the dedupe key repeats anywaydelivery_order.service.ts:150,190
Kafka broker down for daysUnreachable → paused, attempts kept, rows pile up PENDING; one probe every 30 s per pod; drains in key order when backtarget_pause.ts:47-55, kafka.target.spec.ts:205
a downstream rejects the same key foreverThat key is held until its row goes DEAD (about 31 min), then later versions flowfast_lane.worker.ts:173-183
graceful shutdown (SIGTERM)The fast lane stops polling, waits for the current tick, releases all partitions and deletes its job_workers row. The long lane waits for its batches. Kafka disconnects in onApplicationShutdown, after every onModuleDestroyfast_lane.worker.ts:151-162, long_lane.worker.ts:56-62, kafka.service.ts:77-80, main.ts:52
a pod's pause map is lost on restartHarmless: rows and attempts live in Mongotarget_pause.ts:26
old rows of another shape (no lane / partition)Never read: both lanes filter on lanefast_lane.worker.ts:170, long_lane.worker.ts:71, delivery_order.service.spec.ts:158

10. Web (store-v2) ​

store-v2 has no jobs page and no schedules page for this engine. D-214 parked the admin UI.

What exists is a page from the earlier kafka-outbound prototype (store commit e8c6338):

itempath (store-v2)note
page /setting/outbound-eventssrc/pages/setting/outbound-events/index.tsx:20-174Filters key, topic, status; a Retry button on DEAD rows; paging
menu entry "Outbound Events"src/constants/menu.ts:102-106No roles on the entry
Next proxy routessrc/pages/api/outbound_events/index.ts, [id]/retry.tsForward to API_GATEWAY + /outbound_events (src/apis/server/outbound_event/index.ts:17-23)
client typessrc/apis/client/outbound_event/types.ts:1-30Expect {items, total, page, limit} and rows with id, topic, event

This page is broken against core at 3d70475a. Core serves no /outbound_events route; the controller is jobs (jobs.controller.ts:23-26). The shapes do not match either: core returns data, not items; lean rows carry _id, not id; and core has target, not topic. The page has no Skip action and no schedules view.

11. Tests ​

All jobs specs need a Mongo replica set (JOBS_TEST_MONGO_URI). Run them with --maxWorkers=3.

speccovers
src/jobs/side_effect.service.spec.tsPartition hash; one insertMany in the session; dedupe inside and outside a transaction; abort → 0 rows; lossy only after commit; key absent; unknown target; inTransaction hook order
src/jobs/fast_lane.worker.spec.tsPoll delays; 250 rows → 100/100/50; backoff and DEAD at the 6th failure; key hold and order; success deletes; unreachable pause over 2 days; lost lease; 3 pods → 21–22 partitions each; no unsent row closed on takeover
src/jobs/partition_lease.service.spec.ts64 rows with 2 pods; 32/32 split; dead-pod takeover with inflight; busy never released; seeding race without an index; duplicate repair; restart is a no-op
src/jobs/long_lane.worker.spec.tsReserve ≤ 10 with one token and 5-min lease; 7-min run never reclaimed; reclaim after pod death; concurrent reserve; backoff and DEAD at the per-code count; unreachable; taken-over pod cannot finish
src/jobs/jobs.controller.spec.tsAdmin only; list filters and paging without payload; retry DEAD; 409 non-DEAD; 404; skip; late retry of an older version
src/schedules/schedules.service.spec.tsCron at +07:00; bad cron; default policy; one job per slot over 2 pods; retry inside a slot; DEAD slot then next slot; missed slots after downtime; disable; override; 404/400; one RUNNING per code (S-18); run_once collapse (S-19); other codes keep running while one exclusive code runs long (S-21/S-22)
src/kafka/kafka.target.spec.tsKey, headers, topics; delivery-mode config; every Kafka error class; outage drain; takeover at-most-once and at-least-once; hung broker; stall past lease; invalid records; target owned by the Kafka module
src/delivery_order/delivery_order.service.spec.ts:104-235DO change → one job → one Kafka message; duplicate DO update; broker down during the emit; old-shape rows ignored; wire contract (event_id, trace_id, bytes); topic override; rollback leaves no job

Untested: the JOBS_WORKERS=off switch, the lossy Bull processor end to end, the HTTP 403 for non-admin (only the role metadata is asserted), and any call from store-v2.

12. Gaps and known issues ​

#gapcode vs designrisk
G1store-v2 has no working jobs UI. The outbound-events page calls the missing /outbound_events with the wrong shape, and there is no schedules pagedesign parked the UI (D-214); the stale page remains in the menuAdmins see an error page; DEAD rows can only be handled through the API
G2No cancel or skip of PENDING or RUNNING rows, no job detail endpoint, and the payload is never returnedthe brief asks for list/retry/cancel; built: list/retry/skip-DEADA poisoned PENDING row can only wait for DEAD
G3DEAD rows never expire; job_stats (spec SC2, AC-08) was built and then removed in 3dd1c5a0; the DEAD TTL (30 d, disc:D-270) is not built; the alert is a log line onlydesign: TTL via expireAt('jobs_dead') (log-setting D-318)jobs grows with DEAD rows; there are no hourly counts and no paging alert
G4No schedule is declared and 3 Kafka targets (load/task/driver-update) have no producerthe engine is ready, its users are notThe schedules path runs only in tests
G5The Kafka send path does not check the setting enabled flag. send connects lazily with the stored brokers (kafka.producer.ts:55-80,106-121)—With Kafka disabled in settings, jobs still send when brokers are set, or pile up paused when none are
G6The long lane ignores delivery_mode. After lease expiry or a long stall, the handler runs again even if the first run finished its side effectdesign states at-most-once/at-least-once for the fast lane onlyLong-lane and schedule handlers must be idempotent
G7retry_count means total runs (3 = 1 run + 2 retries at 1, 2 min). It is keyed by code, so any job with the same code shares it (job_targets.registry.ts:49-51). Admins cannot change it; boot sync overwrites itdesign said "retry 3 inside the slot"Naming invites off-by-one changes
G8A slot skipped at tick time (late, skip) is only logged; last_skipped_at is not updated. The run-time skip reason previous_slot_running is also given when the real cause was pods down or a busy lane—Schedule history is incomplete
G9JOBS_WORKERS=off does not stop the Bull LossyProcessor. The lossy path has no retry and no registered user—Only matters once a lossy target appears
G10The fast-lane read sorts on {key, version, _id}, which is not index-backed, and the hold query has no limit. payload.slot (run_once collapse) and the admin list filters have no index; countDocuments scans—Polls get slower as a multi-day backlog grows
G11The cron parser is fixed at +07:00 and supports only *, lists, ranges and steps (no names, L, ? or seconds)D-205 (own parser, no dependency)—
G12Spec paths drifted: the Kafka target is src/kafka/kafka.target.ts, not src/jobs/targets/; the generic nextVersion(key, tx) counter (disc:D-265) is not built; DO keeps its own job_counters——
G13Pause state is per pod: each pod probes a down target on its own (D-195)as designedN pods make N probes per 30 s

13. How to add the next one ​

A. A new job target (side effect that must not be lost) ​

  1. In your module, import JobsModule (src/kafka/kafka.module.ts:18 is the pattern). The engine must import no business module; your module owns its target.
  2. Make a provider that implements OnModuleInit and injects JobTargetsRegistry. Call registry.register({ name: '<area>:<thing>', lane: JobLane.fast | JobLane.long, handler, delivery_mode? }). Copy src/kafka/kafka.target.ts:53-77.
  3. Write the handler so that it:
    • throws TargetUnreachable when nothing reached the target (connect refused, 5xx, client timeout before send);
    • throws TargetOutcomeUnknown when the request may have landed with no answer;
    • throws an error with dead_now: true when a resend can never succeed;
    • throws anything else for an answered failure;
    • caps each call well under 30 s on the fast lane (kafka.producer.ts:15);
    • is idempotent on the long lane (G6).
  4. Enqueue inside your business transaction. Copy delivery_order.service.ts:112-122,168-195: inTransaction(this.model.db, async (tx) => { ...write...; await sideEffect.enqueueMany([{ target, code, key, version, dedupe_key, payload, headers }], tx) }).
    • dedupe_key must be derived from the business change (for example <event>:<key>:<version>), never left random, or a replayed request makes a second job.
    • Use key + a growing version when receivers need per-entity order.
  5. Add a spec next to your target. Copy the fixtures in src/kafka/kafka.target.spec.ts (real Mongo replica set, injected clock, fake handler): ok, answered → DEAD after 6, unreachable → paused with attempts 0, takeover.

B. A new Kafka topic ​

  • Producer event: add {event, type: produce, default_topic} to KAFKA_EVENTS (kafka.constants.ts:16-23). The target kafka:<default_topic> registers itself, and the per-event topic_name setting can override the topic. Enqueue with target: kafkaTargetName(default_topic) and code: <event>.
  • Ad-hoc topic: set KAFKA_TARGET_TOPICS=topic[:at_least_once].

C. A new schedule ​

  1. Import SchedulesModule in your module.
  2. In a provider's onModuleInit, call schedules.declare({ code: 'snake_code', cron: 'm h dom mon dow', handler: async ({ slot, job }) => {...}, missed_slot_policy?, retry_count? }). The cron is read in +07:00.
  3. The handler runs on the long lane, one RUNNING slot per code across pods. It must be idempotent per slot, because a reclaim reruns it.
  4. Boot upserts the schedules row. Admins then enable or disable it and set cron_override through /api/v1/schedules.
  5. Add tests modelled on src/schedules/schedules.service.spec.ts:88-220: 2 pods → 1 job per slot, retry within a slot, missed-slot policy.

D. A new lane-level behaviour ​

Change JobOutcome (fast_lane.worker.ts:49-122), never a second outcome path. Both lanes share it through the guard and release arguments.