Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
4fc24d5
Let an instance serve HTTP without registering cron jobs
TaprootFreak Aug 3, 2026
f5e4aec
Export event loop saturation as OTLP metrics
TaprootFreak Aug 3, 2026
639ac6c
Apply Prettier formatting to the runtime metrics test
TaprootFreak Aug 3, 2026
daeeb71
Reject an empty CRON_JOBS_ENABLED instead of treating it as unset
TaprootFreak Aug 3, 2026
8c47313
Keep per-instance housekeeping running when cron is disabled
TaprootFreak Aug 3, 2026
7aeced4
Mark the remaining process-local jobs as per-instance
TaprootFreak Aug 3, 2026
461d2f8
Stop the Spark token optimization on HTTP-only instances
TaprootFreak Aug 3, 2026
0d13612
Restore CRLF line endings and mark the pool monitors per-instance
TaprootFreak Aug 3, 2026
e7fd090
Document the global vs. per-instance rule where it is read
TaprootFreak Aug 3, 2026
e328908
Give a process a role instead of a cron on/off switch
TaprootFreak Aug 3, 2026
e80ac41
Require a scope on every cron job
TaprootFreak Aug 3, 2026
ac041b0
Make three jobs individually switchable
TaprootFreak Aug 3, 2026
d281a02
Keep periodic work from being registered outside the scheduler
TaprootFreak Aug 3, 2026
a9a3e23
Serve the monitoring state from where it is persisted
TaprootFreak Aug 3, 2026
50c9daa
Keep live payment confirmation in the process serving the connections
TaprootFreak Aug 3, 2026
ae5e7ae
Fill the dashboard balance store from a job in the API process
TaprootFreak Aug 3, 2026
5ec7a88
Report the worker as its own service to the collector
TaprootFreak Aug 3, 2026
bdcd817
Document the scope rule where it is read
TaprootFreak Aug 3, 2026
f960c2c
Bring the cron job inventory up to date
TaprootFreak Aug 3, 2026
abc7b7f
Cover the role binding of the Spark maintenance timer
TaprootFreak Aug 3, 2026
9bd75de
Drop what the removed write-through left behind
TaprootFreak Aug 3, 2026
d586cfa
Serialise the monitoring write instead of only merging it
TaprootFreak Aug 3, 2026
bed4fa7
Follow the repository conventions the review measured
TaprootFreak Aug 3, 2026
3367ccc
Close the two remaining ways the monitoring write can lose a value
TaprootFreak Aug 3, 2026
96b007a
Absorb what develop added, and tighten the two new mechanisms
TaprootFreak Aug 3, 2026
466a988
Make the contribution guide compile against its own rule
TaprootFreak Aug 3, 2026
33d24fc
Say what the boot count actually counts, and widen the guard by two d…
TaprootFreak Aug 3, 2026
a067193
Qualify the enum in the last documentation example
TaprootFreak Aug 3, 2026
448fcd8
Close the two gaps a CONTRIBUTING audit turned up
TaprootFreak Aug 3, 2026
5c1262f
Fix what a PR-level review found outside the diff
TaprootFreak Aug 3, 2026
77fef21
feat(cron): report the active role continuously so the alert can read it
TaprootFreak Aug 3, 2026
db55485
test(cron): den Heartbeat-Test an die echte Registrierung binden
TaprootFreak Aug 3, 2026
a731a1b
fix(test): den Scanner-Mock auf Methoden einschraenken
TaprootFreak Aug 3, 2026
ddedf49
feat(cron): einen Lease in der Datenbank gegen doppelt laufende Jobs
TaprootFreak Aug 3, 2026
48b1a0f
Bound how long a dead process can block a job
TaprootFreak Aug 3, 2026
3602fc9
Stop a broken lease table from reading as a healthy process
TaprootFreak Aug 3, 2026
e1e38d5
Name the lease primary key the way TypeORM names one
TaprootFreak Aug 3, 2026
8e45d4d
Give the lease table an entity, and its timestamps a time zone
TaprootFreak Aug 3, 2026
6927066
Put the Spark wallet maintenance behind the same lock as every other job
TaprootFreak Aug 3, 2026
d7b02bd
Apply the job's own conditions to the statistic it fills at boot
TaprootFreak Aug 3, 2026
dc5422d
Say which way the api scope and the lease were resolved, and report t…
TaprootFreak Aug 3, 2026
e2c0c47
Do not make the monitoring endpoints depend on a row with id 1
TaprootFreak Aug 3, 2026
35a8a1d
Pin the heartbeat wording the lease alert reads
TaprootFreak Aug 3, 2026
cfe1d87
Wire the lease shutdown to the signal instead of to Nest's global hooks
TaprootFreak Aug 3, 2026
2959b31
Keep the lease comments to what this repository can vouch for
TaprootFreak Aug 3, 2026
8c8496a
Deliver payment updates from the stored state instead of from the wri…
TaprootFreak Aug 3, 2026
ef95901
Run the payment jobs that write in one process and the delivery in ev…
TaprootFreak Aug 3, 2026
bead4ed
Say what the api scope is for now that the delivery no longer sits in it
TaprootFreak Aug 3, 2026
12b901a
Let every lease operation clear the health flag, and stop overlapping…
TaprootFreak Aug 3, 2026
a39c71b
Put the lease state in every heartbeat instead of only in the failing…
TaprootFreak Aug 3, 2026
49d6f05
Drop the lease exception from the cron registration guard
TaprootFreak Aug 3, 2026
80080e4
Give every waiter and every device entry an owner that removes it
TaprootFreak Aug 3, 2026
d5f9c59
Say that the lease bounds a double run instead of excluding one
TaprootFreak Aug 3, 2026
63c8fbf
Say that the lease bounds the waiting, not the overlap
TaprootFreak Aug 3, 2026
58daaff
Decide leaving Pending in the update, not in a status read
TaprootFreak Aug 3, 2026
d6cfefe
Let the process flag switch off the dashboard start-up fill too
TaprootFreak Aug 3, 2026
7b3aeac
Correct the sweep order and four comment claims
TaprootFreak Aug 3, 2026
97b774d
Apply Prettier formatting
TaprootFreak Aug 3, 2026
eac6159
Say what the renewal margin actually covers
TaprootFreak Aug 3, 2026
591fa4a
Carry the effects of leaving Pending through the transition
TaprootFreak Aug 3, 2026
342d19f
Pin sdk-metrics to the version the exporters carry
TaprootFreak Aug 3, 2026
4674c14
Restore the line endings of two payment-link services
TaprootFreak Aug 3, 2026
1e7f8d2
Let the latest-balance read fill its own store
TaprootFreak Aug 3, 2026
70ddb57
Drop a repository-wide count from a test comment
TaprootFreak Aug 3, 2026
534a665
Say only what the completion path guarantees
TaprootFreak Aug 3, 2026
2fa9d5c
Bound the double run by what actually bounds it
TaprootFreak Aug 3, 2026
7d84532
Pin the shared load the empty store depends on
TaprootFreak Aug 3, 2026
0a86524
Read a span before now, not a mark that outruns uncommitted rows
TaprootFreak Aug 3, 2026
fd86d07
Assert the transitions run their effects on the manager they were given
TaprootFreak Aug 3, 2026
deeea81
Select the delivery on a column no later write can move
TaprootFreak Aug 3, 2026
bfe6258
Say what the lease is claimed for, not how long it is held
TaprootFreak Aug 3, 2026
e258492
Close the two half-states the transaction boundary still left open
TaprootFreak Aug 3, 2026
233ba87
Format the added test the way the repo formats everything else
TaprootFreak Aug 3, 2026
1b82229
Take the kill switch off the bridge, and bound what the record covers
TaprootFreak Aug 3, 2026
05f5719
Record a device command once it is out, not once it is handed over
TaprootFreak Aug 3, 2026
ed38677
Ask the socket whether it is open, because send will not say
TaprootFreak Aug 3, 2026
908ead7
Do not let an unreachable lease table be worse than no lease at all
TaprootFreak Aug 3, 2026
2fb60aa
Give the lease-less run the same guarantees as every other, and say so
TaprootFreak Aug 3, 2026
f4474d7
Keep the fee warm-up out of the worker, and make the on-demand load safe
TaprootFreak Aug 3, 2026
3578f65
State what holds instead of what an earlier comment said
TaprootFreak Aug 3, 2026
3199bbb
Make the lease owner name a RUN, and keep a switched-off job away fro…
TaprootFreak Aug 3, 2026
456359a
Forget deliveries for a peer the ping sweep drops, and stop overstati…
TaprootFreak Aug 3, 2026
16ac436
Load the statistic on demand, drop the revocation kill switch, close …
TaprootFreak Aug 3, 2026
d8bc7a2
Prove the lease over every interleaving instead of the ones somebody …
TaprootFreak Aug 3, 2026
cc6b63b
Pass the tenth constructor argument the new base added
TaprootFreak Aug 3, 2026
e40748e
Say what holds, name the fourth timer, and count the flags after the …
TaprootFreak Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,11 @@ S3_ADMIN_SECRET_KEY=
# OpenTelemetry trace export (OTLP/HTTP). Point this at an OTLP collector to
# enable distributed tracing; leave empty to disable. e.g. http://localhost:4318
OTEL_EXPORTER_OTLP_ENDPOINT=
# Metric export interval in milliseconds. Optional: unset leaves the SDK default (60s).
# A shorter interval costs a full collect-and-export of every instrument on the event loop this
# split is meant to keep free. It is only read when OTEL_EXPORTER_OTLP_ENDPOINT is set; with an
# endpoint, an invalid or non-positive value aborts the boot, and without one it is never looked at.
# OTEL_METRIC_EXPORT_INTERVAL=60000

FIXER_BASE_URL=
FIXER_API_KEY=
Expand Down Expand Up @@ -343,3 +348,9 @@ REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05
REQUEST_KNOWN_IPS=

CRON_JOB_DELAY=

# Which jobs this process registers: 'all', 'api' or 'worker'. Mandatory — a missing, empty or
# unknown value fails the boot on purpose, because every possible default lets a misconfiguration
# run silently: one process would do the background work twice, or not at all.
# 'all' is the single-process mode and the right value unless a separate worker process exists.
CRON_ROLE=all
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,8 @@ MAIL_PASS=dummy-password-for-local-dev
# compared while REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY/_ADDRESS are unset, so the
# value only has to satisfy the boot check.
REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05

# Which half of the application this process runs: 'all' registers every cron job, which is
# what a local single-process setup wants. There is no default — config.ts rejects a missing or
# unknown value and aborts the boot, so this line is required for `npm run setup` to work.
CRON_ROLE=all
97 changes: 86 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,12 +517,14 @@ Use `@DfxCron` (custom wrapper with built-in locking, process control, and error

```typescript
// GOOD: @DfxCron handles everything
@DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAYMENT, timeout: 1800 })
@DfxCron(CronExpression.EVERY_MINUTE, { scope: CronScope.WORKER, process: Process.PAYMENT, timeout: 1800 })
async processPayments(): Promise<void> {
// no @Lock, no DisabledProcess check needed
}

// ONLY with bare @Cron: manual @Lock + DisabledProcess required
// What the wrapper takes off your hands — NOT an alternative you may pick.
// A bare @Cron carries no scope, so it registers in every process; a guard test
// rejects it (see "Register periodic work through @DfxCron" below).
@Cron(CronExpression.EVERY_MINUTE)
@Lock(1800)
async processPayments(): Promise<void> {
Expand All @@ -531,14 +533,87 @@ async processPayments(): Promise<void> {
}
```

Declare a `process` flag unless the job maintains the disabled set itself. Without one the job
runs unconditionally and cannot be switched off without a deploy.

[docs/cron-jobs.md](docs/cron-jobs.md) lists every scheduled job with its interval and flag.
Declare a `process` flag unless the job maintains the disabled set itself, or unless something
outside the process infers health from the job still running — a watchdog that can be switched off
looks, once it is off, exactly like the failure it watches for. Without a flag the job runs
unconditionally and cannot be switched off without a deploy.

A job scoped `worker` or `api` additionally takes a **lease in the database** before it starts
(`CronLeaseService`). The in-process lock cannot see a second process at all — a missed
recreate, a second worker from `--scale`, two processes on `all` after a rollback — and the lease
is what such a second process has to get past before it may start the job.

It does **not** make a double run impossible, and it does **not** bound how long one lasts;
nothing in this repository should claim either. If the holder stops renewing while it is still
working — an unreachable database, a blocked event loop — the claim lapses and a second process
can start the job, while the first runs on to its own end, which for the longest-running jobs is
hours. What the lease does bound is the waiting: a claim left behind by a process that was killed
blocks the job for the lease expiry rather than until someone intervenes. What a `worker` job
actually rests on is the deployment running one worker and the job tolerating a repeat; the lease
is defence in depth over those two, not a substitute for either. `CronLeaseService` states the
limit under "What it does not do" — keep any wording here consistent with it. Jobs scoped `both`
are exempt by design: they must run everywhere, which is why running them twice has to be harmless
by construction.

[docs/cron-jobs.md](docs/cron-jobs.md) lists every scheduled job with its interval, flag and scope.
**Adding, removing or re-scheduling a job must be reflected there in the same PR.**

Prefer longer intervals (15min) over aggressive polling (1min). Only use short intervals when truly needed.

#### Which process a job belongs to

The API can run as more than one process from the same image — one serving HTTP, one running the
background work — and `CRON_ROLE` decides which of them a process is (`api`, `worker`, or `all`
for a single-process setup). **Every cron job must declare which process it belongs to**, and the
compiler enforces it: `scope` is a mandatory parameter of `@DfxCron`.

```typescript
// Worker: writes to the database, moves money, or calls an external system in a way that
// changes state. The normal case.
@DfxCron(CronExpression.EVERY_MINUTE, { scope: CronScope.WORKER, process: Process.PAYMENT })
async processPayments(): Promise<void> {}

// Both: the effect is confined to the process it runs in — refreshing an in-memory copy of
// global state, expiring a local cache, measuring this process. It runs everywhere, because
// requests on the API process read what it maintains.
@DfxCron(CronExpression.EVERY_30_SECONDS, { scope: CronScope.BOTH })
async resyncDeniedJwtAccounts(): Promise<void> {}

// Api: maintains state read only from a request path. Not for delivering to the connections
// this process holds open — that job is leased too, so it would run in one process while the
// connections are spread over all of them. Deliver from stored state under `Both` instead.
@DfxCron(CronExpression.EVERY_HOUR, { scope: CronScope.API, process: Process.UPDATE_STATISTIC })
async doUpdate(): Promise<void> {}
```

Ask: *does a request handler read state this job writes?* If both a request path and a job read
it, the answer is `Both`; if only a request path does, `Api`; otherwise `Worker`. Getting it
wrong fails silently — the state simply freezes at boot wherever the job does not run. The JWT
denylists are the cautionary example: frozen, they fail open and a blocked account keeps its live
tokens.

A job scoped `Both` runs in every process without a shared lock, so running it twice must be
harmless by construction. If it writes to the database, sends mail, or calls a paid external API,
it is `Worker` — and if a request path needs its result, that result belongs in the database, not
in process memory.

#### A cache read by a request path loads itself

**A cache read in a request path must load on demand** — through `AsyncCache`, `CachedRepository`
or a lazy load of its own. A cron job may refresh it, but must not be the only thing filling it.

This is the rule that makes a wrong scope harmless: a cache that loads itself is correct in every
process, whichever scope its refresh job carries. `AsyncCache` and `CachedRepository` already work
this way; the jobs scoped `Both` are precisely those that do not.

#### Register periodic work through @DfxCron

`scope` only reaches jobs going through `@DfxCron`. A native `@Cron` or a bare `setInterval` is
invisible to it and therefore runs in every process — for anything writing to the database, that
means twice, without a shared lock. A test enforces this, and it carries no exceptions: everything
it matches on is gone from the repository. What it does not match on — a repeating `setTimeout` —
its own comment names one by one, with the reason each is left alone.

### Await Discipline

```typescript
Expand Down Expand Up @@ -818,13 +893,13 @@ const isValid = await this.validateIban(iban).catch(() => false);

```typescript
// BAD: @DfxCron already handles errors
@DfxCron(CronExpression.EVERY_HOUR)
@DfxCron(CronExpression.EVERY_HOUR, { scope: CronScope.WORKER })
async process(): Promise<void> {
try { ... } catch (e) { this.logger.error(e); } // redundant
}

// GOOD
@DfxCron(CronExpression.EVERY_HOUR)
@DfxCron(CronExpression.EVERY_HOUR, { scope: CronScope.WORKER })
async process(): Promise<void> {
// just do the work
}
Expand Down Expand Up @@ -983,8 +1058,8 @@ Endpoints that block by design:

| Path | Blocks until | `wait` segment |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `GET /v1/lnurlp/wait/:id` | the payment resolves: completed, canceled, expired — or, for `MULTIPLE`-mode links, when a quote reaches the configured completion threshold (the payment itself may stay `Pending`) | yes |
| `GET /v1/paymentLink/payment/wait` | the same, for the authenticated payment-link flow | yes |
| `GET /v1/lnurlp/wait/:id` | the payment resolves: completed, canceled, expired — or, for `MULTIPLE`-mode links, when a quote reaches the configured completion threshold (the payment itself may stay `Pending`); bounded at 60 s, after which it answers with the payment as it stands | yes |
| `GET /v1/paymentLink/payment/wait` | the same, and under the same bound, for the authenticated payment-link flow | yes |
| `GET /v1/lnurlp/:id` | a pending payment appears; bounded by `timeout` (default 10 s, caller-controllable) | no — exempt |
| `GET /v1/lnurlp/tx/:id` | the payer's own broadcast reaches one confirmation (`tx` branch); 15 polls at 1 s. The `hex` branch broadcasts without awaiting confirmation, except on ICP, where it first waits for the payer's allowance (up to 3 attempts, 2 s apart) | no — exempt |
| `GET /v1/node/:node/tx/:txId` | the transaction reaches one confirmation; bounded at 600 s | no — exempt |
Expand Down Expand Up @@ -1228,7 +1303,7 @@ single DTO with two fields (PR #3772, 91 LOC, ~50% reduction).
| Loading all then filtering in JS | SQL WHERE clause |
| `any` type | Proper typed interface/class |
| `string` for enum values | Typed enum |
| `@Interval(60000)` | `@DfxCron(CronExpression.EVERY_MINUTE)` |
| `@Interval(60000)` | `@DfxCron(CronExpression.EVERY_MINUTE, { scope: CronScope.WORKER })` |
| `eager: true` everywhere | Explicit relation loading |
| Providing service in multiple modules | Single module, import from there |
| `JSON.stringify(JSON.parse(...))` | Unnecessary — remove |
Expand Down
Loading