Skip to content

fix(api): scope offlineDetector DB contexts to DB statements only (#3233) - #3235

Merged
ToddHebebrand merged 3 commits into
mainfrom
fix/3233-offline-detector-db-context
Aug 8, 2026
Merged

fix(api): scope offlineDetector DB contexts to DB statements only (#3233)#3235
ToddHebebrand merged 3 commits into
mainfrom
fix/3233-offline-detector-db-context

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Fixes #3233.

What

createOfflineWorker wrapped the entire job body in withSystemDbAccessContext, so every sweep's Redis fan-out (queue.addBulk) and the uninstall reaper's per-row writes ran while a pooled Postgres connection sat idle-in-transaction for the whole chunked loop.

Same shape already fixed for alertWorker evaluate-all (#3216) and snmpWorker poll-scheduler (#3215). This is the third and worst member of the family:

  • detect-offline runs every 30s (twice as often as alertWorker's sweep)
  • the wrap spans the whole multi-chunk while loop, so one connection is held for the entire run and the hold grows with fleet size

How it was found

Surfaced by #3221's caller-attribution diagnostic during v0.104.0 release QA — 13 held-context warnings in ~15 minutes, 5-10s each, all naming offlineDetector.ts:26:45. The hold itself is pre-existing (the blanket wrap dates to 2b62f023d, 2026-07-30); #3221 just made it attributable for the first time. Nothing in the v0.104.0 round introduced it.

The round 5000/10000ms clustering is ioredis reconnect backoff (services/redis.ts:121) charged against the held connection.

Against a bounded pool (DB_POOL_MAX default 30, one region ~25) this eats directly into headroom and amplifies outages. For scale reference, #3216's writeup measured its equivalent hold at ~4.9s every 60s and 15,469 warnings since June 16.

Changes

  • Worker handler no longer blanket-wraps. The three sweep jobs self-manage their contexts. The two per-device jobs (mark-offline, reevaluate-offline) keep a whole-job context — they read+write a single row and do no fan-out, mirroring alertWorker's evaluate-device/auto-resolve.
  • processDetectOffline / processReevaluateOfflineSweep — page read moves into its own short system context that CLOSES before that page's queue.addBulk.
  • processReapUninstallIntent — page read gets its own context; each candidate's two writes then share one short context of their own, so the decommission and the replacement-linkage clear still commit together while no connection is held across the whole chunk.

Pagination now spans transactions. Intentional and safe: the keyset cursor (devices.id ascending) is stable, eligibility timestamps are computed once per run so the window doesn't drift between pages, and a device whose status flips mid-sweep is picked up next cycle.

Also documents that runOutsideDbContext is not a substitute here — it only exits the AsyncLocalStorage, it does not release the pooled connection held by the enclosing baseDb.transaction().

Tests

Adds offlineDetector.dbcontext.test.ts (6 tests), following the snmpWorker.dbcontext.test.ts pattern: it tracks real context enter/exit depth and asserts WHICH depth each read, enqueue and write ran at. An identity fn => fn() mock cannot catch this class of bug.

Verified to fail against the unfixed workeraddBulk(2)@depth1 instead of the expected @depth0.

  • 931 job tests pass (106 files)
  • tsc --noEmit clean

Live verification

Rebuilt the API image on a local stack and confirmed:

  • zero held-connection warnings from the sweep paths
  • offline detection still works end-to-end: Detected 1 stale devices → fan-out → device row flips onlineoffline

The narrowing's failure mode would be a worker that silently stops writing, so the end-to-end write was checked explicitly rather than inferred from the absence of errors.

Known remaining hold (separate follow-up, documented in #3233)

Two 10s warnings still appear from the per-device path this PR deliberately leaves wrapped: createAlert's Redis cooldown/flapping calls (alertCooldown.ts:61,93,153,387) run unescaped inside that per-device transaction. That is shared by every createAlert caller including alertWorker's still-wrapped paths, so it can't be fixed by touching offlineDetector.ts alone.

🤖 Generated with Claude Code

)

createOfflineWorker wrapped the entire job body in withSystemDbAccessContext,
so every sweep's Redis fan-out (queue.addBulk) and the uninstall reaper's
per-row writes ran while a pooled Postgres connection sat idle-in-transaction
for the whole chunked loop.

Observed on a local stack during v0.104.0 release QA: 13 held-context warnings
in ~15 minutes at 5-10s each, all from offlineDetector — surfaced for the first
time by the #3221 caller-attribution diagnostic. detect-offline runs every 30s
(twice as often as the alertWorker evaluate-all sweep fixed in #3216) and holds
ONE connection across the entire multi-chunk loop, so the hold grows with fleet
size. Against a bounded pool (DB_POOL_MAX default 30, one region ~25) that eats
directly into headroom and amplifies outages.

The 5000/10000ms clustering is ioredis reconnect backoff (services/redis.ts:121)
charged against the held connection.

Same fix shape as alertWorker evaluate-all (#3216) and snmpWorker poll-scheduler
(#3215):

- Worker handler no longer blanket-wraps. The three sweep jobs self-manage;
  the two per-device jobs (mark-offline, reevaluate-offline) keep a whole-job
  context since they read+write a single row and do no fan-out.
- processDetectOffline / processReevaluateOfflineSweep: page read moves into its
  own short system context that CLOSES before that page's queue.addBulk.
- processReapUninstallIntent: page read gets its own context; each candidate's
  two writes then share one short context of their own, so the decommission and
  the replacement-linkage clear still commit together but no connection is held
  across the whole chunk.

Pagination now spans transactions. That is intentional and safe: the keyset
cursor (devices.id ascending) is stable, the eligibility timestamps are computed
once per run so the window does not drift between pages, and a device whose
status flips mid-sweep is picked up on the next cycle.

Adds offlineDetector.dbcontext.test.ts, which tracks real context enter/exit
depth and asserts WHICH depth each read, enqueue and write ran at — an identity
fn => fn() mock cannot catch this class of bug. Verified to fail against the
unfixed worker (addBulk lands at depth1 instead of depth0).

Also documents that runOutsideDbContext is NOT a substitute here: it only exits
the AsyncLocalStorage, it does not release the pooled connection held by the
enclosing baseDb.transaction().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0039d51
Status: ✅  Deploy successful!
Preview URL: https://5d0765e6.breeze-9te.pages.dev
Branch Preview URL: https://fix-3233-offline-detector-db.breeze-9te.pages.dev

View logs

The hoisted queue mock declared `addBulk: vi.fn(async () => [])`, so the
per-test `mockImplementation((jobs) => ...)` that inspects the batch size
failed TS2345 in CI. Local vitest does not typecheck, and the earlier
`tsc --noEmit` ran before this test file existed, so it slipped through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ToddHebebrand added a commit that referenced this pull request Aug 8, 2026
…oS CVEs (#3241)

Unblocks the PR queue. Trivy's vulnerability DB refreshed on 2026-08-08
and three new HIGH findings began failing **`Trivy Filesystem Scan` on
every PR resolved after the refresh** — currently #3235 and #3238.

**This is not caused by any of those PRs.** #3232 was scanned *before*
the refresh and passed, which makes the failure look selective; it
isn't, and it will redden `main` on the next push regardless of what
gets merged. Exact repeat of the CVE-2026-59870 (js-yaml) episode in the
previous release round.

## The three findings, and why they're handled differently

| CVE | Package | Fix available? | Ships to users? | Action |
|---|---|---|---|---|
| CVE-2026-67213 | `nanoid` 3.3.16 | **Yes** — 3.3.17 / 5.1.6 | **Yes**
— runtime dep of apps/mobile | pnpm override |
| CVE-2025-71329 | `image-size` 1.2.1 | **No** | No — build-time only |
`.trivyignore` |
| CVE-2025-71330 | `image-size` 1.2.1 | **No** | No — build-time only |
`.trivyignore` |

### nanoid — fixed properly

Reached via `@react-navigation/native@7.3.8`, which is a **runtime**
dependency of `apps/mobile`, so this one genuinely ships to devices.
Override is upper-bounded `>=3.3.17 <4.0.0` so it stays a 3.3.16 →
3.3.17 patch bump rather than resolving to 6.x and dragging
react-navigation through a major — the same bounding rationale used for
the js-yaml override.

The tree's other copies (5.1.16, 6.0.0) are already past the advisory's
fixed versions and are untouched. **Lockfile diff is 2 lines**,
`nanoid@3.3.16` removed:

```
$ grep -oE "nanoid@[0-9.]+" pnpm-lock.yaml | sort -u
nanoid@3.3.17
nanoid@5.1.16
nanoid@6.0.0
```

### image-size — ignored, with justification

There is **no fixed version upstream**. Trivy reports both as `affected`
with an empty "Fixed Version" column, so no override or upgrade can
close them today.

Sole dependent is `metro@0.84.4` — the React Native bundler. Confirmed
nothing else in the tree pulls it. metro runs on developer machines and
in CI, never in a shipped image and never on a server or customer
device. Both CVEs are DoS via a crafted image (a malformed ICNS for
-71330) parsed by `image-size`; reaching that path means feeding a
hostile image to our own bundler during our own build, i.e. the attacker
already controls the source tree.

The `.trivyignore` entry records the dependent, the non-exploitability
argument, and explicit removal criteria — matching the justification
standard the file already sets for its existing `node-ip` and pnpm
blocks. **All 9 pre-existing entries are preserved**; this appends 2.

## Release note

Worth a line in the v0.104.0 notes alongside the js-yaml entry: nanoid
is a real (if low-severity) fix that reaches the mobile app; image-size
is a documented, non-shipping accept.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Todd Hebebrand <todd@lanternops.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ToddHebebrand
ToddHebebrand merged commit e51120c into main Aug 8, 2026
55 checks passed
@ToddHebebrand
ToddHebebrand deleted the fix/3233-offline-detector-db-context branch August 8, 2026 03:49
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.

[API] offlineDetector holds a pooled connection for 5-10s every 30s (#1105 family, next after #3216)

1 participant