Skip to content

07 — Integrations and jobs ​

Everything TMS v2 sends to or receives from other systems, every background job and timer, and every log it keeps with how long it keeps it. Read this when you need to know "what talks to the outside, how does it retry, and what is left behind".

Built or designed. Every row is tagged:

  • Built — in the code of bigc-tms-core-v2, branch main-v2-prototype, commit 3d70475a (worktree /Users/thanosorn/Works/Software/tms/wt-v2-prototype-core).
  • Designed — only in the design documents (/Users/thanosorn/Works/Software/tms/agent-task/.work/tms-v2-discovery/modules/<module>/design.md, and the jobs and Kafka notes in /Users/thanosorn/Works/Software/tms/_docs/bigc-tms-core-v2/).

State machines for jobs, Kafka messages and attachments are in 04-states-and-flows.md.


1. Kafka ​

1.1 How the connection is configured (Built) ​

itemvaluewhere
settings recordkafka in the central settings module: bootstrap_servers, authen_type (PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL), authen_setting, enabled, events (topic name and on/off per event); sasl_password and ssl_key_pem stored encryptedsrc/kafka/kafka.settings.ts
change without deployeach pod re-reads settings on a Redis settings:changed message and every 60 s, then reconnects producer and consumers if the config changedkafka.service.ts _reconcile; settings.service.ts
consumer group<KAFKA_GROUP_PREFIX or KAFKA_CLIENT_ID or bigc-tms-core-v2>.<event>, one group per consume event, starts at the latest offsetkafka.consumer.ts _start
producerone long-lived producer per podkafka.producer.ts
pod statuseach pod writes kafka:status:<pod> to Redis every 10 s (TTL 30 s); GET /v1/kafka/status lists themkafka.service.ts
connection testPOST /v1/kafka/test-connection with an unsaved config, or the saved onekafka.controller.ts
extra outbound topicsenv KAFKA_TARGET_TOPICS="topic-a,topic-b:at_least_once" registers more send targetskafka.target.ts parseTopics

1.2 Topics ​

direventdefault topicpayload essentialshandleridempotency keystatus
indo_consumebigc.lastmiles.do-postthe OMS order: delivery_no, message_id, channel, source store, destination address and latlong, expected date and window, is_cod, cod_amount, items, optional statusKafkaDoConsume → DeliveryOrderService.upsertFromKafka (create if new delivery_no, else update)consume claim on message_id (fallback topic:partition:offset), plus a unique activity event_key do:<delivery_no>:msg:<message_id> so a replay changes nothingBuilt
outdo_update_omslastmiles.oms.do-updatev1 field set and order: delivery_no, external_delivery_no, order_type, dates, chanel, source and destination address, is_cod, cod_amount, items, Statusjobs fast lane → KafkaTarget.send; Kafka key = delivery_no; headers event_id, traceparent, trace_idjob dedupe_key do_update_oms:<delivery_no>:<version>; version from a per-DO counterBuilt for channels DONJAI, CHOKCHAI, HLH
outdo_update_staffapplastmiles.staffapp.do-updatev1 field set: as above plus delivery_type, invoice, pickup_info, POD, payment proof and driver blocks when present, status, notesamedo_update_staffapp:<delivery_no>:<version>Built for channels SPO, OD, HLH
outload_updatelastmiles.staffapp.load-update—target registered, nothing enqueues to it—Declared in code, no producer (v2 has no load entity)
outtask_updatelastmiles.staffapp.task-updatetask state for staffapptarget registered, nothing enqueues to it—Declared in code, no producer; Designed with execution
outdriver_updatelastmiles.staffapp.driver-updatedriver state for staffapptarget registered, nothing enqueues to it—Declared in code, no producer; Designed with workforce

When a DO message goes out (Built). One row per routed topic on every DO create (admin or Kafka), and on every update whose status differs from before. A channel not in either list sends nothing and logs a warning. The full payload mapping against v1 samples is parked; field shapes today follow src/delivery_order/do-update.payload.ts.

When it should go out (Designed). Only where v1 emits today, measured per trigger from PROD kafka_logs and mapped onto each v2 DO transition; both DO topics carry the same events during the store-by-store cut-over.

1.3 Inbound mechanics (Built) ​

stepwhat happens
claimper message, an upsert on kafka_consumed by consume_id; a done row skips it, a processing row younger than 5 min makes the batch stop and re-fetch from that offset after 5 s
handleeach message runs in its own request context carrying the producer's traceparent
permanent failurethe handler returns a reason (for example missing message_id): row upserted into kafka_consume_dlq (pending, attempts + 1), claim set failed, offset committed
crash or thrown errorclaims of the batch are deleted and the error is rethrown, so Kafka redelivers the batch
successclaim set done; any kafka_consume_dlq row for the id set resolved
logone kafka_logs row per handled message (direction in, body masked and capped)
replaynone; a DLQ row is applied again only if Kafka delivers the same id again. Designed: intake_message park-and-replay (see 04-states-and-flows.md §8a)

1.4 Outbound mechanics (Built) ​

Outbound Kafka uses an outbox: the business change and its jobs rows are written in one Mongo transaction, so a committed change always has its message and a rolled-back one has none.

rulevalue
order per DOrows partitioned by hash of delivery_no (64 partitions); the pod that leases a partition sends its rows sorted by key and version; a retrying row holds later versions of the same DO on the same topic
delivery modeat-most-once for every declared topic (a send with no answer goes DEAD for a person to retry or skip, never resent automatically); at-least-once can be set per topic through KAFKA_TARGET_TOPICS
failure classesbroker refused the record → retry with backoff; broker unreachable → pause the topic 30 s, attempts unchanged; no answer or broker timeout → DEAD on at-most-once; invalid record → DEAD at once
headersevent_id (job id), traceparent, trace_id
logone kafka_logs row per send (direction out)

2. Other external systems ​

systemoperationwhentimeoutretryloggedstatus
Google Route OptimizationPOST https://routeoptimization.googleapis.com/v1/projects/<id>:optimizeTours (avoid tolls and highways, road traffic on, polylines on)plan re_plan, move_stop, assign_stop, unassign_stop, inside the HTTP request120 snone; the caller gets the error and a failure row in plan_historygoogle_logs (auth header masked)Built
Google OAuth tokenservice-account token, scope cloud-platformbefore each optimise call30 snonegoogle_logsBuilt
Google Maps keypublic endpoint GET /v1/settings/public returns google_map_api_key to clients; the server makes no Maps callclient maps——noBuilt
Google optimise as a background jobsame API, called from a long-lane joboptimise and generate—jobs engine retrygoogle_logsDesigned
AWS S3presigned PUT (5 min) to v2/incoming/<uuid>, HeadObject, ranged GetObject of the first 4 KB (magic bytes), CopyObject to v2/files/<uuid>, DeleteObject, presigned GET (15 min)attachment upload, confirm, sweep, read10 s per call (S3_TIMEOUT_MS)none per call; a stuck scan is retried by the sweepapp log only (no DB log)Built
Storage configbucket, region, prefix, KMS, credential mode IAM role or static key; secret encryptedadmin Storage setting———Built
Malware scannerscan(bucket, key)attachment confirm——app logBuilt as a stub that always answers clean
Redissettings change pub/sub, Kafka pod status, Bull queues (side_effect_lossy, attachment_sweep), sessionsalways———Built
CloudFront for public filesstable URL for files whose purpose is publicattachment read———Designed (only purpose admin_file, private, is built)
SMTP emailaccount activation, staff PIN, other templatesthrough a jobs-backed notification outbox—5 / 15 / 30 min, then FAILED—Designed
SMS gatewayOTPRedis/Bull (may be lost, user asks again)———Designed
Driver pushtask offers and changesRedis/Bull lossy side effect—none—Designed

Every outbound call goes through one externalCall helper that sets the timeout (src/common/utils/external_call.ts). Writing a DB log is opt-in per module: today Google and Kafka have one, S3 does not.


3. Jobs engine (Built) ​

One engine runs both queued side effects and schedule slots. Source: src/jobs/, src/schedules/; walkthrough: tms-v2-jobs-engine-code-walkthrough.md.

parthow it worksnumbers
jobs collectionmust-not-lose work rows: PENDING, RUNNING, DEAD; a finished row is deleted, not marked doneunique dedupe_key; partial indexes on PENDING and RUNNING
fast laneshort handlers (Kafka sends). A pod leases whole partitions and drains them with no per-row claim write64 partitions in job_partitions; lease 30 s, heartbeat 10 s; batch ≤ 100
long lanelong handlers (schedule slots). Batch reserve with a claim token; exclusive targets run one at a time per code across all podsbatch ≤ 10; lease 5 min, heartbeat 1 min
pollfull batch → poll again at once; partial → 1 s; empty → 1 s, 2 s, then 5 s—
retryanswered failure → wait 1, 2, 4, 8, 16 min, then DEAD (6 runs); per-code override (schedules use their retry_count as the run limit)—
unreachable targetpaused 30 s per pod, one probe row let through, attempts not counted—
takeovera row in flight when its partition is taken over goes DEAD on an at-most-once target, runs again on an at-least-once one—
DEADlogged as job dead alert; holds nothing; admin POST /v1/jobs/:id/retry (back to PENDING, attempts 0) or POST /v1/jobs/:id/skip (row deleted); GET /v1/jobs lists rowsno TTL
lossy side effectsa target marked lossy goes to Bull side_effect_lossy after commit, one try, dropped on failureno lossy target is registered today
pods that only enqueueenv JOBS_WORKERS=off turns off lanes and the schedule tick on that pod—
trace of finished workone structured app-log line per run: code, target, dedupe_key, attempts, duration, resultnot stored in Mongo

3.1 Schedules (Built engine, no schedule declared) ​

itemvalue
declarea module calls schedules.declare({ code, cron, handler, missed_slot_policy?, retry_count? }) in onModuleInit; code owns cron, policy and retry count
config rowschedules collection: code, cron, enabled, cron_override, missed_slot_policy, retry_count (default 3), last_run_at, last_skipped_at, next_run_at
adminGET /v1/schedules, POST /v1/schedules/:code/enable and /disable, PUT and DELETE /v1/schedules/:code/cron_override
tickevery 15 s each pod enqueues every due slot as a long-lane job with dedupe_key <code>:<slot ISO time>; the unique key makes concurrent pods insert one row
time zonecrons fire in UTC+7 (Asia/Bangkok, no DST)
missed slotsa slot more than 1 min late is missed. Policy run_once (default) runs the newest missed slot once and skips older ones; skip (default for crons every 5 min or less) skips them; recorded in last_skipped_at
overlapone run at a time per schedule code across pods (exclusive target)
declared schedulesnone in production code; demo schedules live only in schedules.service.spec.ts

3.2 Every job and timer ​

codetriggerwhat it doesretry / missed-slot policystatus
kafka:lastmiles.oms.do-updateDO create, DO status changesends do_update_omsfast lane, 1-2-4-8-16 min then DEAD; at-most-onceBuilt
kafka:lastmiles.staffapp.do-updatesamesends do_update_staffappsameBuilt
kafka:lastmiles.staffapp.load-update, …task-update, …driver-updatenothing enqueues yetsend target onlysameBuilt target, no producer
schedule:<code>schedule tickruns a declared schedule handler for one slotlong lane, exclusive; retry_count runs (default 3), backoff as above; missed slots per policyBuilt engine, nothing declared
attachment_sweepBull repeat every 60 s (not the jobs engine)fails pending attachments older than 6 min and deletes their incoming object; re-runs scans stuck in scanning 5 min; ≤ 100 rows eachnext run in 60 s; skipped while storage is not configuredBuilt
Kafka reconcile and status heartbeatevery 10 s per podrewrites pod status in Redis; reconnects a failed producer or consumernext beatBuilt
settings reloadevery 60 s per pod, plus Redis settings:changedreloads setting values into the pod cachenext intervalBuilt
buffered log flushevery 1 s or 100 rows per log per podinserts buffered kafka_logs and google_logs rows; past 1,000 rows the oldest is droppeddropped rows counted and printedBuilt
fast lane poll / partition heartbeatadaptive 0–5 s / 10 ssends due rows; renews leases; closes taken-over rows—Built
long lane poll / lease heartbeatadaptive 0–5 s / 1 minruns schedule slots; renews leases—Built
plan optimise jobOptimise pressedruns Google optimise outside the request, plan optimising → planned or failedlease; retry from adminDesigned
plan generate jobGenerate pressedper open route one transaction: task, stops, DO claim (ASSIGNED), duty windowlease lapses → retry resumes at first open routeDesigned
end-of-date claim releaseend of the plan date, store local time (mechanism not specified)clears the plan claim on orders still unassigned, so the next day's plan can pick them—Designed (assumption)
task overdue cancelscheduled jobcancels tasks still PENDING or ACCEPTED 24 h after their date (task_start_grace_hours); DOs stay ASSIGNED on their routerun_onceDesigned (needs customer confirm)
task overdue listrunning task 24 h past its date (mechanism not specified)puts the task on an ops overdue list; ops runs close-out for the driver—Designed
workforce day closeevery 15 minends duty records 2 h after their last window: OFF_DUTY auto-closed or ABSENT; bookings CLOSEDre-run safe (each record closed once); engine default for a 15-min cron is run_onceDesigned
on-demand acceptance timeoutper offered task, Redis/Bull timerwhen the driver does not accept, hold the driver (duty HELD) and offer the task to another driverlossy; the timer job retries the hold commandDesigned
notification sendjobs-backed outbox row written with the business changerenders and sends email / SMS5 / 15 / 30 min then FAILEDDesigned
export runjobs row, status kept in export_runsbuilds an export file in batches, stores it as an attachmentjobs engineDesigned
intake replayops presses replay, or the missing store / channel appearsreplays parked intake_message rows per order, oldest firstper row: back to PARKED or DEAD with replay_count + 1Designed
DEAD job clean-upTTL index on closed_atremoves DEAD rows after 30 days—Designed (assumed decision)

4. Logs and retention ​

Each log declares its default retention in code; the value lives in the central setting log.<key> (1–365 days) and can be changed in admin without a deploy. Every row stores expire_at = write time + retention, with a TTL index on expire_at, so a change applies to new rows only. Source: src/log/.

logcollectionone row perwrite modedefault retentionstatus
activity logactivity_logsbusiness action on a record (do-create, do-update, RE_PLAN, …): actor, channel, reason, changed fields, unique event_keyin the business transaction; a duplicate event_key aborts it (this is how a replayed Kafka message becomes a no-op)60 days, one value, no per-entity overrideBuilt
Kafka logkafka_logsmessage sent (out) or handled (in): topic, key, message_id, partition, offset, masked bodies, status sent / consumed / failedbuffered (1 s / 100 rows)60 daysBuilt
Google loggoogle_logsGoogle call: operation, url (masked), status, outcome OK / ERROR / TIMEOUT, duration, masked headers and bodiesbuffered30 daysBuilt
plan historyplan_historyplan save or failed optimise: action, revision, diff, durationin the plan transactionno TTLBuilt
Kafka consume claimkafka_consumedconsumed message idper message7 days (fixed TTL on consumed_at)Built
Kafka dead letterskafka_consume_dlqfailed inbound message with raw payload and errorper messageno TTLBuilt
DEAD jobsjobs (status DEAD)job that used up its tries, with full payload—no TTL; 30 days designedBuilt (TTL Designed)
finished jobsapp log line onlyjob runstdoutlog platform's retentionBuilt
app logstdout (winston)everything else, with trace_id—outside TMSBuilt
S3 callsapp log only———Built (no DB log by choice)
exception log———parked until ops confirm New Relic on PRODDesigned (on hold)
GPS points—driver position—90 daysDesigned (needs customer confirm)
delivery_order_revisioncollection declaredone placement of a DO on a task——Built schema, no writer

Masking: Authorization, cookies, password, secret, token, api_key, key, pin, otp, client_secret in headers, bodies at any depth and URL query; bodies are size-capped with a truncated flag and the original size.


5. Where code and design disagree ​

#areacode (Built)designeffect
1DO emit pointsevery create, and every update that changes status, for routed channelsonly where v1 emits, measured from PROD kafka_logs per triggermay send more or fewer messages than v1 until the emit table is applied
2Inbound replaydead-letter row, no replayintake_message park, ordered replay, WAITING_ON_ORDERan order message failing on a missing store stays failed until Kafka redelivers it
3Plan optimisesynchronous Google call in the HTTP request, 120 sbackground long-lane jobthe request blocks for the whole optimise
4Schedule retryretry_count 3 = 3 runs in total"retries up to 3 times" = 4 runsone fewer retry
5DEAD jobskept foreverTTL 30 days after closed_at (assumed)unbounded growth
6Attachment sweepBull repeat every 60 s, outside the jobs engine"a sweep" (engine not named); every other periodic job is designed on the schedules engineruns on every pod's Bull worker, not visible in /v1/schedules
7Plan historyown plan_history collection with no TTL, plus an activity rowplanning keeps plan_history; platform moves plan rows into activity_logstwo stores; plan_history never expires
8Declared topicsload_update, task_update, driver_update registered as send targetsv2 has no load entity; task and driver messages come with execution and workforcedead targets until those modules exist
9Malware scanstub, always cleanreal scan before a file is readyevery file is marked clean