Skip to content

8d2cc4b3 - Run long commands as jobs, starting with the account merge - #4496

Open
TaprootFreak wants to merge 8 commits into
developfrom
feat/job-system
Open

8d2cc4b3 - Run long commands as jobs, starting with the account merge#4496
TaprootFreak wants to merge 8 commits into
developfrom
feat/job-system

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Why

Two endpoints, measured in production over 24 h against the trace-derived span metrics:

Endpoint Calls / 24 h p95
GET /v1/auth/mail/confirm (account merge) 65 16.4 s
POST /v1/auth/mail 153 16.4 s
POST /v1/auth 2 139 (951 over 1 s) 15.4 s

Holding a request open for sixteen seconds is not a tuning problem, it is the wrong call shape. Commands whose work no longer fits inside a request now persist a job, answer with a ticket, and a dispatcher executes it.

Deliberately not in scope: slow read endpoints (GET /v1/fiat, /v1/dashboard/financial/log, /v1/statistic/status, /v1/asset — together roughly 6 700 calls over 1 s per day). A ticket on a data query is a regression; those are caching and query problems. This mechanism is for commands with side effects.

What

Metrics first. The API had no metrics of its own — it exported only traces, and Prometheus does not scrape it, so every API metric was derived from spans. Spans cannot carry state: queue depth, or how many jobs are currently over their allowed time, are not derivable from them. MetricService is a thin facade over the OpenTelemetry metrics API; tracing.ts gains a periodic exporter on the existing OTLP path. The same instrumentation gives the 149 existing @DfxCron methods duration, outcome, skip-count and a heartbeat — none of which they had before. A set kill switch was previously indistinguishable from healthy operation.

The job core (src/subdomains/supporting/job/): entity, migration, service, dispatcher, metric snapshot, and GET /v1/job/:uid. One Process kill switch per group, as the house rule requires.

The merge endpoint waits up to 900 ms and only then hands out a ticket.

Design decisions worth reviewing

Claiming, not a timing threshold. A conditional status update claims a row; only the runner whose update reports affected: 1 owns it. Two runners can never both process one job — including across instances, which the process-local lock never covered. This is why there is no age threshold anywhere in the dispatcher: the correctness does not rest on guessing how long a send takes.

Idempotency on a unique index, not in code. Two concurrent enqueues of the same business key cannot both create a job; the loser catches the unique violation (Postgres 23505 specifically, everything else is rethrown) and loads the winner's row. A retried confirmation link is therefore harmless.

A job carries the limit it was accepted under. maxAttempts is persisted and read from the row, not from live configuration — otherwise a settings change would re-budget work already in flight.

Three terminal states, not two. Failed is exhausted attempts; DeadLetter is a task no retry can ever complete. Separating them keeps an unworkable job from burning attempts while still making it visible, instead of being skipped silently.

The leading metric for stuck work is a gauge. A completion counter is incremented when a job finishes — so a job that never finishes never increments it and cannot be reported at all. dfx_job_over_sla is computed from database state and reports a stuck job while it is stuck. It is fed from an in-memory snapshot refreshed by a cron, because gauge callbacks run on the export cadence and a query inside them would tie metric export to database response time.

The configured limit is exported as its own series (dfx_job_sla_seconds), so dashboards and alerts follow a settings change without a deploy and without a second copy of the threshold. The jobGroups setting is validated against a DTO on write, so a typo in a group name is rejected rather than silently falling back to the default.

The heartbeat attests a successful sweep, not merely that the method was entered — it is not updated when the sweep itself fails, so a permanently failing sweep stays detectable.

No access token is persisted. The merge job returns only kycHash and the master account id; the token depends on request state (address, IP, 2FA marker) and is minted in the HTTP context on retrieval. Job groups carry an exposeResult flag, false for the merge, so the generic status endpoint shows progress but never result data.

Backward compatibility: below 900 ms clients see exactly today's response including the token; 400 (Invalid link) and 409 (Merge is already completed) still come synchronously and without creating a job. executeMerge itself is untouched — it is only wrapped.

Verification

  • Migration written by hand (the generator is not runnable in this environment) with the naming formula validated first against the existing generated migrations: 86/86 primary keys, 24/24 unique constraints, 198/198 foreign keys and 20/20 non-partial indexes reproduced exactly. The generated SQL was then applied and rolled back against a real PostgreSQL 16, both clean.
  • format:check, lint, type-check and the affected Jest suites run on a dedicated build host.

After the first review round

Eleven findings, two of them blocking. Both are fixed.

The ticket path was a dead end. Past 900 ms the client received a ticket and could never collect its result: the generic status endpoint withholds it for this group by design (exposeResult: false), and calling the endpoint again ran into Merge request is already completed. The user saw an error for a merge that had in fact succeeded, and got neither kycHash nor an access token — so they could not reach their account. The endpoint now looks the job up by its idempotency key before anything else and returns the result with a freshly minted token. The conflict remains only for merges completed before this mechanism existed, where no job row was ever written. The long path — the one this feature exists for — is now the one that is tested.

Job rows destroyed their own history. claimedAt, claimedBy and error were overwritten on every attempt, so after three tries the first two failures were unrecoverable. That is what the CRITICAL „auditable mutations" rule forbids. job_attempt now holds one immutable row per attempt with owner, timing, outcome and the full error. It is written after a won claim, not before — the claim is a race, and writing first would leave every loser a row for an attempt it never made — and closed before the snapshot update, as the rule requires.

Also fixed from that round: recoverStale uses the same conditional update as claimNext, so it can no longer overwrite a job that finished between the read and the write, and it respects the persisted attempt budget instead of running an exhausted job once more. Persistence failures on completion are re-thrown rather than counted as a business failure — a metric claiming an outcome the database does not know makes the numbers useless. The public DTO no longer passes internal error text outward: dead-letter messages are statements about the job and stay, failed ones are replaced by a fixed text carrying the ticket number, and every other state reports no error at all (a job in Retry holds the raw message of its last attempt).

Plus the smaller ones: enum values in PascalCase per the naming rule — note that these travel outward as a metric label, so the alerting side was adjusted in the same breath — a dead logger removed, one German comment translated, and the lockfile brought in sync with the two dependencies package.json declares.

On the wait naming rule: this endpoint enqueues the job, kicks the dispatcher and only then waits for the result, so it falls under the rule's explicit exemption for endpoints awaiting work they started themselves. It therefore keeps its path and stays visible in latency monitoring — which is also where the improvement from 16.4 s should become measurable. The reviewer confirmed that reading independently against the comparable paths in the repo.

@github-actions

Copy link
Copy Markdown

❌ ESLint: 2 errors, 1 warnings

Measured in production, GET /v1/auth/mail/confirm takes 16.4 s at the 95th
percentile and POST /v1/auth/mail 16.4 s — holding a request open for that is the
wrong shape. Commands whose work no longer fits inside a request now persist a
job, answer with a ticket, and a dispatcher executes it.

Three pieces:

The API gains its own OpenTelemetry metrics. Until now it exported only traces
and Prometheus does not scrape it, so every API metric was derived from spans.
That cannot carry state — queue depth or a job that is currently over its allowed
time are not derivable from spans. The same instrumentation gives the 149
existing cron methods duration, outcome and a heartbeat, which they never had.

The job core claims rows with a conditional status update, so two runners can
never both process one job — including across instances, which the process-local
lock never covered. That is what removes the need for any timing heuristic.
Idempotency sits on a unique index rather than in code: two concurrent enqueues
of the same business key cannot both create a job, so a retried confirmation link
is harmless. A job carries the attempt limit it was accepted under, so a later
configuration change cannot re-budget work already in flight.

The merge endpoint waits up to 900 ms for the result and only then hands out a
ticket. Below that, clients see exactly today's response, including the access
token; 400 and 409 still come synchronously and without creating a job. The token
is minted in the HTTP context, never stored: it depends on request state and would
be a stored credential in the job table.

The leading metric for stuck work is a gauge, not a counter. A completion counter
is incremented when a job finishes, so a job that never finishes never increments
it and cannot be reported. The configured limit is exported as its own series so
dashboards and alerts follow a settings change without a deploy.
The new rule reserves the `wait` segment for routes that only wait for someone
else to act. `GET /v1/auth/mail/confirm` enqueues the account-merge job, kicks
the dispatcher and waits up to 900 ms for its result — the duration measures work
the API itself started, so per the rule it must NOT carry the segment and stays
visible in latency monitoring. Recording it next to the other examples so the
decision is where a reader looks for it.
The require() inside jest.isolateModules violated no-require-imports; the same
assertion now runs through isolateModulesAsync with a dynamic import, so the
positive proof stays intact — one metric reader, 15000 ms, and no second one on
a repeated call.

The controller test asserted the absence of a `result` property via
hasOwnProperty. With target es2023 every declared class field materialises as an
own property on `new JobDto()`, and Object.assign — the repo's mapper
convention — does not remove it. The property exists but carries no value, and
JSON.stringify drops it, so the client never sees it. The assertion now checks
the serialised response in both directions, which is what actually matters.

The auth controller spec pulled TfaService in as a DI token and thereby the
whole KYC entity chain, which is circular at import time: StepLog extends KycLog
ran while KycLog was still undefined and took the entire suite down. Mocking that
one module keeps the chain out of this spec; all three cases are unchanged.
The auth spec mocked src/.../tfa.service but exported only TfaService from it.
The module also exports the TfaLevel enum, so every consumer of it got undefined
and validation decorators failed with 'Cannot convert undefined or null to
object' — the whole suite could not load. Both existing mocks of that module in
this repo list TfaLevel; this one now matches them.

Prettier over the five files the pipeline flagged.
The suite could not compile: AuthController's routes carry IpCountryGuard and
RateLimitGuard, and Nest instantiates both when building the module. IpCountryGuard
injects IpLogService, RateLimitGuard needs the throttler options — neither was
present, so every test in the file failed on module creation rather than on
anything it asserts.

All four cases pass now: immediate result with token, pending job answered with
the ticket, and a conflict from the pre-check that does not enqueue.
package.json declares the OTLP metrics exporter and sdk-metrics as direct
dependencies, but the lockfile's root dependency block still listed only the
trace exporter — both packages were present transitively via sdk-node, so
installs worked while the manifest and the lockfile disagreed.
Two blocking defects from the review.

The ticket path went nowhere. Past 900 ms the client got a ticket and could
never collect its result: the generic status endpoint withholds it for this
group by design, and calling the endpoint again hit 'Merge request is already
completed'. The user saw an error for a merge that had in fact succeeded, and
got neither kycHash nor access token. The endpoint now looks the job up by its
idempotency key first and returns the result with a freshly minted token; the
conflict remains only for merges completed before this mechanism existed, where
no job row was ever written.

Job rows overwrote claimedAt, claimedBy and error on every attempt, so after
three tries the first two failures were unrecoverable — against the CRITICAL
'auditable mutations' rule. job_attempt now keeps one immutable row per attempt
with owner, timing, outcome and the full error. It is written after a won claim,
not before: the claim is a race, and writing first would leave every loser a row
for an attempt it never made. It is closed before the snapshot update, as the
rule requires.

Also from the review: recoverStale now uses the same conditional update as
claimNext, so it cannot overwrite a job that finished in the meantime, and it
respects the attempt budget instead of running an exhausted job once more.
Persistence failures on completion are re-thrown instead of being counted as a
business failure - a metric claiming an outcome the database does not know makes
the numbers useless. And the public DTO no longer leaks internal error text:
dead-letter messages are statements about the job and stay, failed ones are
replaced by a fixed text with the ticket number.
Three blocking findings from the final review.

The first one this PR introduced itself. Closing the ticket dead end meant looking
the job up before the isCompleted check — and that check was not only state logic,
it was the only thing that spent the confirmation link. With it bypassed, anyone
holding the merge code could collect a fresh access token for the merged account
over and over, up to 30 days for IBAN and ident merges, and
createAccessTokenAfterMerge falls back to generateAccountToken without verifying
the caller belongs to that account. The result is now collectable only within 15
minutes of completion; the waiting client polls in seconds and gives up after ten
minutes, so it is fully covered, and afterwards the link is spent as it always was.

finish and abort wrote with an unconditional update by id. If recoverStale had
meanwhile handed the job to another runner, the original one overwrote the new
owner's state — and mergeUserData could run twice for the same pair. Both now
update only while the row still carries the attempt and owner they claimed; a
runner that lost the job logs and returns instead of overruling the winner.
closeAttempt only writes to an attempt row that is still open.

The kill switch only ever ran inside the cron wrapper, so kick() — which the code
itself calls the normal path — was never gated. Operations could not actually stop
merges while requests kept arriving. drain() now checks the group's process first.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Review complete: two rounds, two lenses each (conformance against CONTRIBUTING.md and the repo's conventions, and logic in context). Twenty findings in total.

Round one surfaced eleven, two of them blocking, both fixed: the ticket path was a dead end past 900 ms — the client could never collect its result and hit Merge request is already completed instead — and job rows overwrote claimedAt, claimedBy and error on every attempt, which the CRITICAL auditable-mutations rule forbids. job_attempt now keeps one immutable row per attempt, written after a won claim and closed before the snapshot update.

Round two surfaced nine, three of them blocking. All three are fixed here.

The first was introduced by the round-one fix itself, which is worth stating plainly. Closing the dead end meant looking the job up before the isCompleted check — and that check was not only state logic, it was the only thing that spent the confirmation link. Bypassed, anyone holding the merge code could collect a fresh access token for the merged account repeatedly, up to 30 days for IBAN and ident merges; createAccessTokenAfterMerge falls back to generateAccountToken without verifying the caller belongs to that account. The result is now collectable only within 15 minutes of completion. The waiting client polls in seconds and gives up after ten minutes, so it is fully covered — and afterwards the link is spent, as it always was.

finish and abort wrote with an unconditional update by id. Had recoverStale meanwhile handed the job to another runner, the original one overwrote the new owner's state, and mergeUserData could run twice for the same pair. Both now update only while the row still carries the attempt and owner they claimed; a runner that lost the job logs and returns rather than overruling the winner. closeAttempt only writes to an attempt row that is still open.

The kill switch was only evaluated inside the cron wrapper, so kick() — which the code itself describes as the normal path — ran ungated on every request. Operations could not have stopped merges while requests kept arriving. drain() now checks the group's process first.

The remaining seven are not merge-blocking and are tracked in #4561 — the wait exemption covering only the first call, explicit nullable: false, relative imports, CRLF in one file, the dual role of uid as identifier and authorisation (not exploitable for the only shipped group), missing outcome counting for jobs ended terminally by recoverStale, and a missing age signal on the metric snapshot.

On the bot comment above: it is from 2026-07-30 11:24, several pushes ago. The review job at the current head reports ESLINT_ERRORS: 0; the bot only posts when counters are non-zero and therefore never removed the old comment. Verified in the job log, not from the comment list.

CI is green on the latest push: API PR CI, API Migration Check, PR Review Bot, CodeQL and CodeQL Advanced. Gates were additionally run on a dedicated build host — prettier, lint, type-check and 111 tests across 14 suites. Both migrations were applied and rolled back against a real PostgreSQL 16 before they were committed.

@TaprootFreak
TaprootFreak marked this pull request as ready for review August 1, 2026 01:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant