Temporal Worker (pay-crons)
Temporal Worker (pay-crons)
packages/temporal-workflows
The pay-crons worker is the payment platform’s scheduled-job runtime. It is a
long-running container that polls a single Temporal task queue (pay-crons),
executes eight workflows on a cron cadence, and performs all side-effecting work
(Hyperswitch REST calls, Prisma writes, Datadog events) inside activities.
It exists because of the Hyperswitch pivot: once
Hyperswitch became the source of truth for transfers, settlements, disputes, and
authorizations, the only jobs left for MVS to own are catch-up and
MVS-specific monitoring that Hyperswitch does not natively model. The package
(@mvs/pay-temporal-workflows) replaced the prior Finix-direct @mvs/payfac-jobs
sync surface (8 payfac*Workflow definitions) with the current set.
This worker dials out to Temporal and accepts no inbound RPCs. The only
exposed port is 9090 (Prometheus metrics), and nothing is published there yet —
see Metrics & observability. Liveness/readiness are
pgrep exec probes, not HTTP.
Package layout
The source lives under packages/temporal-workflows/src/. The directory layout is
load-bearing: tsup emits one entry per package.json exports subpath so the
dist/ tree mirrors src/, because @temporalio/worker re-bundles
dist/workflows/ at runtime through its own (webpack-based) sandbox bundler and
needs it as a real on-disk directory.
The package.json description and several in-repo comments still say “8
deterministic workflows” and reference payfac. There are eight workflows,
but only six are defined in workflows/mvs-pay.ts; the DLQ drainer and
success-rate reconcile live in their own files. The payfac mentions are stale
naming from the pre-pivot package — PayCronResult/PayfacCronResult and
RETRY_HYPERSWITCH_API/RETRY_PAYFAC_SYNC are deprecated aliases kept for
migration. Treat the deprecated names as dead on sight.
Architecture at a glance
Two processes share the same image:
- The worker (
dist/worker/start.js) — long-running, polls the queue, runs workflow + activity code. It never creates schedules. - The register-schedules CLI (
dist/cli/register-schedules.js) — a one-shot job that creates/updates the Temporal Schedules which enqueue the workflows. Without it the worker would poll an empty queue forever.
Worker entrypoint
src/worker/start.ts is intentionally thin. startPayCronsWorker():
- Injects secrets via
@mvs/secretsloadAppSecretsForRuntime("mvs-pay", env)— but only whenisInfisicalConfigured()is true. In Kubernetes theExternalSecretalready projects the env, so this is usually a no-op there; it matters for local/dev boot. The scope is deliberately"mvs-pay"(shared withapps/mvs-payandapps/mvs-pay-extensions) rather than introducing a new downstream identifier into the@mvs/secretsapp union. - Builds a
NativeConnectiontoTEMPORAL_ADDRESS(defaultlocalhost:7233). mTLS is enabled iff bothTEMPORAL_TLS_CERT_PEMandTEMPORAL_TLS_KEY_PEMare present. - Creates the
WorkeronPAY_CRONS_TASK_QUEUE(defaultpay-crons) inTEMPORAL_NAMESPACE(defaultdefault), withworkflowsPathresolved todist/workflows/and the union of all three activity modules registered.
main() wires SIGINT/SIGTERM to worker.shutdown() (graceful drain), then
await worker.run(). The Deployment sets terminationGracePeriodSeconds: 60 to
let in-flight activities finish.
The header comment notes a planned @mvs/temporal cross-repo helper
(createWorker / getTemporalClient) that would wrap NativeConnection +
Worker.create with the standard MVS logger/metrics. It does not exist yet —
the worker uses the upstream @temporalio/worker API directly so it has no hard
boot-time dependency on the cross-repo peer. Adopting it is deferred.
Environment variables
Resolved via @mvs/secrets at boot when Infisical is configured; otherwise read
straight from the process env. See also the Environment Variables
reference.
The eight workflows
Workflow code is deterministic — it only logs and forwards to an activity via
proxyActivities. All I/O happens in activities. Each workflow returns a
PayCronResult:
processed/errors are a deploy-grade heartbeat; details is the operator-facing
bag visible in the Temporal Web UI. Activities are proxied at one of two timeout +
retry tiers (all use startToCloseTimeout: "10m"):
1. Hyperswitch event replay (every 30 min)
runHyperswitchEventReplay lists Hyperswitch events created in the last
lookbackHours (default 2h, sized for the 30-min cadence; limit default
500) via hyperswitch.events.list, skips delivered events, and calls
hyperswitch.events.retry(event_id) for the rest. This is the catch-up safety net
for the webhook delivery path: if Hyperswitch’s
own outbox failed to deliver to mvs-pay-extensions, replay re-fires it.
details: { retriedCount, skippedCount }. “Delivered” is read from
event.is_delivered or a status === "delivered" fallback.
2. Instant-payout settlement reconcile (every 15 min)
runInstantPayoutSettlement reads InstantPayoutRequest rows in REQUESTED
status (default limit 100, oldest first), fetches each via
hyperswitch.payouts.get(payoutId), and transitions the row:
succeeded/success/completed→COMPLETED(+completedAt)failed/cancelled/expired/reversed→FAILED(+failedAt,failureCode,failureMessage)- anything else (still processing) → left in place, counted as
processed.
Rows with no hyperswitchPayoutId are skipped (nothing to reconcile). This is the
poll-side complement to the PAYOUT_* webhook handler; see
Payouts & Instant Payouts.
3. Residual estimation (monthly, 1st @ 06:00 UTC)
runResidualEstimation snapshots the last full calendar month per active
MerchantExtension. For each merchant it pages hyperswitch.payments.list
(merchant_id-scoped, pageSize 100, capped at 50 iterations) summing gross
and connector fees, then writes a metadata.residuals[YYYY-MM] entry
(grossAmount, connectorFees, netToMerchant, paymentCount, estimatedAt).
Connector-fee extraction is best-effort and lossy. extractConnectorFee tries
payment.connector_fee_amount, then surcharge_details.surcharge_amount, then
connector_metadata.fee, and falls back to 0 when none are present.
Hyperswitch surfaces connector fees inconsistently across versions, so
netToMerchant should be read as an estimate, not a ledger figure. This feeds
Cost Observability — treat it accordingly.
4. Residual drift checks (daily @ 10:00)
runResidualChecks re-aggregates the trailing lookbackHours (default 24h)
per active merchant, computes an observed connector-fee rate in basis points
(connectorFees / gross × 10000), and compares it to a per-merchant baseline at
metadata.feeBaseline.expectedRateBps with a tolerance of
metadata.feeBaseline.toleranceBps (else defaultDriftBps, default 50 bps =
0.5%). When the absolute deviation exceeds tolerance it opens a SYSTEM /
HIGH Notification (href: /merchants).
Merchants with no expectedRateBps baseline configured are silently skipped —
this job only alerts where someone has set an expected rate.
5. Dispute SLA monitor (daily @ 09:00)
runDisputeSlaMonitor finds DisputeExtension rows older than slaDays (default
5) where lastPlatformActionAt IS NULL, and opens a DISPUTE_DEADLINE /
HIGH notification (href: /disputes). It de-dupes to one alert per dispute per
24h.
The schema has no literal evidenceSubmitted column. “No evidence yet” is
encoded as lastPlatformActionAt IS NULL — a deliberate proxy documented in the
activity. If a dispute’s last platform action was something other than evidence
submission, this proxy can misfire. See Disputes &
Mandates.
6. Device health monitor (every 30 min)
runDeviceHealthMonitor scans enabled Device rows (cap 1000) and, for any
whose lastConnectedAt is older than offlineAfterMinutes (default 30, or
never connected), opens a DEVICE_OFFLINE / HIGH notification (href: /devices), de-duped to one per device per 24h. See Devices &
POS.
7. Webhook DLQ drainer (every 5 min)
runWebhookDlqDrainer (Phase 3 / M17.1) drains the inbound-webhook
dead-letter queue. It selects WebhookDeliveryAttempt rows where
status="pending" and lastAttemptAt is null or older than the 1-minute cool-off
(default limit 100, oldest first), then re-runs the dispatch logic and
updates the row.
Key behaviours:
MAX_ATTEMPTS = 10. On the attempt that reaches 10, the row flips tostatus="failed"andemitDlqExhaustedEventfires a Datadog event (alert_type: error, tagsfeature:webhook_dlq,service:mvs-pay-extensions).- The dispatcher is replicated, not imported.
dispatchHyperswitchWebhookis a deliberate copy ofapps/mvs-pay-extensions/src/services/webhook-dispatcher.tsbecause workspaces can’t depend across theapps/↔packages/boundary (see Boundaries). It handlesPAYMENT_INTENT_*,WEBHOOK_DISPUTE_*, andPAYOUT_*events; refund/mandate/unknown types are intentional no-ops that still mark the row processed. It applies the M17.2 ordering check (isAfterOrCloseEnough, 1s clock-skew tolerance) to avoid overwriting newer state with a stale replay. - After draining, it re-counts remaining
pendingrows and emits themvs_pay.webhook.dlq.depthgauge (M18.2) so the SLO monitor can fire on backlog growth.
details: { drained, exhausted, scanned }. The DB writes mirror only what the
inbound handler does — no Hyperswitch API calls in this activity.
The Datadog event is emitted via a dynamic require("dd-trace") guarded by a
try/catch. dd-trace is only present in the production/staging worker image;
when absent (e.g. local/test runs) the exhaustion is logged only, not sent to
Datadog. The static-import avoidance is intentional so the test runner doesn’t try
to load the native module.
8. Success-rate reconcile (hourly)
runSuccessRateReconcile (Phase 4 / M23.5) drives the dynamic-routing health loop
for Routing & Smart Retries. It iterates
MerchantExtension rows with smartRetriesEnabled = true (and isActive), and
for each with a hyperswitchProfileId calls the SDK
client.routing.dynamic.getScoreWindow({ merchantId, profileId }). For every
score-window entry it:
- Emits the
mvs_pay.smart_retry.success_rategauge (taggedconnector:*). - Tracks a per-
(connector[:merchantConnectorId])consecutiveLowCountinMerchantExtension.metadata.scoreWindowHistory. - When the score is below the threshold (
LOW_SCORE_THRESHOLD = 0.5, overridable via the workflowthresholdinput) forSUSTAINED_RUNS_BEFORE_ALERT = 2consecutive runs (= 2h at the hourly cadence), fires awarningDatadog event (feature:dynamic_routing).
The metadata write is done inside a $transaction that re-reads the row and merges
only the scoreWindowHistory key, so it does not clobber a concurrent cron
writing metadata.residuals (the residual estimator) or a dashboard config save.
Three honesty notes for this workflow:
- It uses the
@mvs/mvs-pay-sdkMvsPayClient, not the standalone Hyperswitch client — it calls the extensions service, which fronts the Hyperswitch dynamic-routing surface. It requiresMVS_PAY_EXTENSIONS_INTERNAL_SECRETand throws on boot of the activity if it is unset. - The activity docstring says it emits a gauge
mvs_pay.smart_retry.score_windowper (org, connector). The code actually emitsmvs_pay.smart_retry.success_rateand tags it by connector only (noorganization_idtag). Trust the code; the docstring metric name and the per-org tag claim are stale. - The Datadog alert path shares the same dynamic-
require("dd-trace")caveat as the DLQ drainer — nodd-trace, log-only.
Schedules (register-schedules)
src/schedules/pay-crons.ts is the single source of truth for cadence. It exports
PAY_CRONS_SCHEDULES — a readonly PayCronScheduleSpec[] of { scheduleId, workflowType, cron, description }. The schedule IDs are the operator-facing handle
in the Temporal UI:
src/cli/register-schedules.ts reads that array and, per spec, attempts
client.schedule.create(...); on ScheduleAlreadyRunning it falls back to
handle.update(...) so cadence edits in the source file land idempotently. Each
spec is registered with:
- action:
{ type: "startWorkflow", workflowType, taskQueue } - spec:
{ cronExpressions: [spec.cron] } - policies:
{ overlap: "SKIP", catchupWindow: "1 hour" } - memo:
{ description }(visible in the UI)
overlap: "SKIP" means a run is dropped if the prior run of the same schedule is
still going — important for the longer monthly/daily aggregations.
The CLI logs a one-line created/updated/errored outcome per schedule and
exits non-zero if any registration failed — this is what gates the ArgoCD
PreSync hook.
PayCronScheduleSpec.description strings are the human cadence summary, but they
are documentation only — they are not enforced against the actual activity
defaults. For example the success-rate description says “score-window gauges”
while the emitted metric is success_rate; the DLQ description says “fail rows
after 10 attempts” which does match MAX_ATTEMPTS. Read the activity for ground
truth, not the description.
Deployment
Manifests live in deploy/pay-crons-worker/ (Kustomize base + prod/staging
overlays) and the ArgoCD Application in
deploy/argo/pay-crons-worker-application.yaml. The worker runs in the payments
Kubernetes namespace and is not PCI-scoped. See the broader
Deployment Topology and
GitOps & Kubernetes pages.
Image
A multi-stage Dockerfile (build context = repo root) mirrors the
apps/mvs-pay-extensions image so the two payment images share an identical
base / pnpm / Node / Prisma layout.
Stage 0 — prisma-clients
Pulls @prisma/control-plane-client + @prisma/tenant-client from the SHA-pinned
mvs-cloud/healthos-prisma-clients:${HEALTHOS_PRISMA_CLIENTS_SHA} image. The
worker does not import these, but they are copied to keep the runtime
node_modules graph identical to extensions. The SHA is a required
--build-arg; pin to a SHA, never :latest. See the
Cross-repo Prisma Build runbook.
The tsup build inlines @mvs/* workspace packages (which ship raw TypeScript and
can’t be loaded by plain node) via noExternal, while keeping @temporalio/*
(native + sandbox-injected), @prisma/*, dd-trace, and zod external and
resolved from the copied node_modules. dist/workflows/ is emitted as a real
directory because the Temporal sandbox re-bundles it at runtime.
Deployment object
deploy/pay-crons-worker/base/deployment.yaml:
- 2 replicas, RollingUpdate (
maxSurge: 1,maxUnavailable: 0). HA is for availability, not throughput — Temporal hands each task to exactly one worker. - HPA 2..6 on CPU @ 60% (
hpa.yaml). Workers are CPU-bound during deterministic replay; the ceiling is low because over-scaling adds connection churn without speeding bursty work. - PDB
minAvailable: 1, zone topology spread, and host anti-affinity. - No Service. Outbound-only.
- Probes are
pgrep -f 'worker/start.js'exec probes — the worker has no HTTP health surface. A documented future enhancement: write a heartbeat file on each poll cycle so liveness can detect a hung poll loop. - Resources: requests
200m/384Mi, limits750m/768Mi. securityContext: non-root,readOnlyRootFilesystem: truewith a 64Mi/tmpemptyDir, all caps dropped,RuntimeDefaultseccomp.- Datadog APM via
admission.datadoghq.com/enabled: "true"and downward-APIDD_ENV/DD_VERSIONfrom pod labels (the overlayreplacementsblock copies the pinned image tag into the version label soDD_VERSIONis the SHA).
The container image is the placeholder
REPLACE_ME_REGISTRY/mvs-cloud/pay-crons-worker:__IMAGE_TAG__. The overlay
images:/newTag is REPLACE_ME_IMAGE_SHA. The Buildkite release pipeline
rewrites these (registry + immutable git-SHA tag) via PR before a prod apply —
they are open placeholders, not deployable as-is. The deploy comment also
notes that whether the worker reuses the combined mvs-pay-extensions image vs. its
own image was a decision deferred to M8 (Buildkite pipeline); the manifests
currently name a separate image.
ArgoCD + PreSync schedule-register Job
The ArgoCD Application (project: mvs-pay, source
deploy/pay-crons-worker/overlays/prod, automated sync with prune + selfHeal,
ServerSideApply, PruneLast) drives the rollout.
The critical piece is register-schedules-job.yaml, an Argo PreSync hook:
The Job runs node dist/cli/register-schedules.js to completion before the
Deployment rolls, on every sync, so schedules exist before (or in lockstep with)
the new worker. It uses the same image, ServiceAccount, and secret bundle as
the worker — only the command differs. backoffLimit: 3,
activeDeadlineSeconds: 300, ttlSecondsAfterFinished: 600. If registration
fails the hook fails and Argo halts the sync, leaving the prior worker running.
Secrets & network
externalsecret.yaml projects a pay-crons-worker-secrets Opaque Secret from
Infisical at /apps/mvs-pay/pay-crons-worker/* (1h refresh,
deletionPolicy: Retain), envFrom-mounted into both the worker and the Job. It
carries the Temporal address/namespace/TLS pair, the Hyperswitch base URL + admin
key, MVS_PAY_DATABASE_URL, the Infisical bootstrap quartet, and DD_API_KEY.
See Secrets Management.
The NetworkPolicy allows ingress only from the monitoring namespace on
9090 (Prometheus) and egress to the in-cluster Hyperswitch router (:8080),
Temporal/:7233, Postgres/:5432, Datadog+Infisical/:443, and ElastiCache
Redis/:6379.
Boundaries
The worker is a separate runtime from the apps/mvs-pay-extensions Hono service
and keeps its dependency graph deliberately narrow.
@mvs/mvs-pay— workspace dep providinggetMvsPayDb()+ Prisma model types. Activities call it directly; workflows never import it (workflows are deterministic, DB calls go through activities). The worker’s only Prisma dependency is the generated@prisma/mvs-pay-client.- No
apps/mvs-pay-extensionsimport. The Hyperswitch HTTP client (src/lib/hyperswitch-client.ts) and the webhook dispatcher (inactivities/webhook-dlq-drainer.ts) are deliberate standalone copies of the extensions versions — workspaces can’t depend across theapps/↔packages/boundary (see ADR-006 · Extensions as a separate app). These copies must be kept roughly in sync by hand for the methods the worker calls (events.list/retry,payments.list/get,payouts.get) and for the webhook event-type handling. There is no automated drift check. @mvs/mvs-pay-sdk— used only by the success-rate activity to reach the extensions service’s dynamic-routing route.@mvs/temporal— the planned cross-repo helper is not adopted; the worker constructs Temporal connections directly.
Metrics & observability
The :9090 Prometheus endpoint scrapes nothing yet. The deploy manifest’s
prometheus.io/scrape: "true" annotation and the exposed port are in place, but
@temporalio/worker is not configured with its metrics exporter — the deploy
comment states the scrape “is harmless until that wiring lands.” Per-workflow
result counters (PayCronResult) are visible in the Temporal Web UI, and
Datadog receives the dogstatsd metrics/events above (when dd-trace is present in
the image). There is no first-class Prometheus surface for this worker today.
For day-to-day operations (replaying a stuck schedule, triaging DLQ exhaustion, on-call escalation), see the On-call runbook and the Smart Retries runbook.
See also
Why the workflow set shrank to catch-up + monitoring jobs.
The Hono app whose webhook + routing surfaces this worker complements.
ArgoCD sync model, PreSync hooks, and overlays.
The dynamic-routing feature the success-rate reconcile monitors.