feat(sqlite): split queue-wait vs service metrics, slow-op log, configurable read workers#817
feat(sqlite): split queue-wait vs service metrics, slow-op log, configurable read workers#817arielmelendez wants to merge 8 commits into
Conversation
…gurable read workers Add observability to disambiguate StandaloneSqlite core-worker stalls under load and a knob to relieve them. - standalone_sqlite_method_queue_wait_seconds (enqueue -> dispatch) and standalone_sqlite_method_service_seconds (dispatch -> reply received) split the existing enqueue->completion summary. A jam dominated by queue wait points at worker-pool serialization; one dominated by service with fast underlying queries points at main-event-loop saturation. - sqlite_queued_ops gauge exposes enqueued-but-not-yet-dispatched depth. - Slow-op warn log (SQLITE_SLOW_QUERY_LOG_THRESHOLD_MS, default 1000ms) reports method, queue/service breakdown, queue depth, and a bounded arg summary to identify which method is responsible during a jam. - CORE_SQLITE_READ_WORKER_COUNT / DATA_SQLITE_READ_WORKER_COUNT make reader pool sizes configurable (defaults 1/2, unchanged). Writers stay single since SQLite permits one writer. Docs (docs/envs.md) and docker-compose passthrough updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #817 +/- ##
===========================================
- Coverage 78.77% 78.75% -0.02%
===========================================
Files 133 133
Lines 50477 50880 +403
Branches 3815 3835 +20
===========================================
+ Hits 39761 40072 +311
- Misses 10666 10757 +91
- Partials 50 51 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR adds configurable SQLite worker counts and gateway socket timeout/logging settings, introduces new SQLite and gateway timing metrics, and instruments standalone SQLite workers and gateway agents with queue and socket latency logging. ChangesSQLite worker and gateway observability
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/database/standalone-sqlite.ts (1)
3391-3415: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
sqliteQueuedOpsgauge can go negative — dec() fires for jobs never inc()'d.
takeWork()unconditionally decrementssqliteQueuedOpsfor every dispatched job. Butstop()(further down) pushes a{ method: 'terminate' }job directly ontoworkQueues[pool][role]without ever callingmetrics.sqliteQueuedOps.inc()(onlyqueueWork()does that, at thequeueWorkpush site). When that terminate job is dispatched, the gauge is decremented without a matching increment, so it can dip negative during shutdown — exactly when accurate metrics are most useful for diagnosing a stuck drain.🔧 Proposed fix: only decrement for tracked (enqueued-via-queueWork) jobs
job = self.workQueues[pool][role].shift(); // Split timing: record how long this op waited in the queue before // dispatch, and stamp the dispatch time so the reply handler can // measure service time. const dispatchedAt = performance.now(); const method = job.message.method; - const queueWaitSeconds = - job.enqueuedAt !== undefined - ? (dispatchedAt - job.enqueuedAt) / 1000 - : 0; + const wasTracked = job.enqueuedAt !== undefined; + const queueWaitSeconds = wasTracked + ? (dispatchedAt - job.enqueuedAt) / 1000 + : 0; job.dispatchedAt = dispatchedAt; job.queueWaitSeconds = queueWaitSeconds; - metrics.sqliteQueuedOps.dec({ worker: pool, role }); - metrics.sqliteMethodQueueWaitSeconds.observe( - { worker: pool, role, method }, - queueWaitSeconds, - ); + if (wasTracked) { + metrics.sqliteQueuedOps.dec({ worker: pool, role }); + metrics.sqliteMethodQueueWaitSeconds.observe( + { worker: pool, role, method }, + queueWaitSeconds, + ); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/database/standalone-sqlite.ts` around lines 3391 - 3415, The `takeWork()` dispatch path is decrementing `metrics.sqliteQueuedOps` for every job, but some jobs such as the `terminate` job inserted by `stop()` are never counted by `queueWork()` and therefore should not be decremented. Update `takeWork()` in `standalone-sqlite.ts` to only call `sqliteQueuedOps.dec()` for jobs that were actually tracked as queued, using the job metadata or a similar guard, while keeping the existing queue-wait observation and dispatch logic for the tracked jobs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/database/standalone-sqlite.ts`:
- Around line 3391-3415: The `takeWork()` dispatch path is decrementing
`metrics.sqliteQueuedOps` for every job, but some jobs such as the `terminate`
job inserted by `stop()` are never counted by `queueWork()` and therefore should
not be decremented. Update `takeWork()` in `standalone-sqlite.ts` to only call
`sqliteQueuedOps.dec()` for jobs that were actually tracked as queued, using the
job metadata or a similar guard, while keeping the existing queue-wait
observation and dispatch logic for the tracked jobs.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 0425b299-581d-4583-964b-33e805546588
📒 Files selected for processing (5)
docker-compose.yamldocs/envs.mdsrc/config.tssrc/database/standalone-sqlite.tssrc/metrics.ts
The 'terminate' job pushed directly by stop() bypasses queueWork(), so it is never counted into sqliteQueuedOps and carries no enqueuedAt. takeWork() previously decremented the gauge for every dispatched job, so on shutdown the gauge could dip negative. Gate the split-timing instrumentation on enqueuedAt so only queueWork()-tracked jobs adjust the gauge and emit queue-wait/service metrics (also avoids a spurious 'terminate' latency series). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@coderabbitai Good catch — addressed in ad19989. You're right: Fixed by gating the entire split-timing block on |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
cd /repo 2>/dev/null || true
git fetch origin ad199893 --depth=2 2>&1 | tail -5
git show ad199893 -- src/database/standalone-sqlite.ts | head -150Length of output: 3424
Nice catch and clean fix — thanks for addressing it. |
…fore the peer The outbound trusted-gateway agent used a 60s idle-socket timeout, equal to the peer's server keep-alive timeout (HTTP_KEEP_ALIVE_TIMEOUT_MS, default 60s). When both sides idle-time-out at the same value, the client can reuse an idle socket at the exact moment the server sends its idle-close FIN. The request then stalls until the socket teardown resolves — observed as ~8-10s inter-gateway peer-fetch stalls (packet capture: request queued in the client agent, GET finally sent on a fresh SYN the instant the peer FINs the old socket). When the stall exceeds TRUSTED_GATEWAYS_REQUEST_TIMEOUT_MS (10s) the request is canceled before it is ever sent, so the peer logs nothing and the fetch "fails" with the data plainly available. Make the client's idle timeout configurable (GATEWAY_AGENT_IDLE_SOCKET_TIMEOUT_MS, default 50000) and strictly below the server default, so the client always retires an idle socket before the peer closes it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-wire stalls
Request/response timing can't see stalls that happen before a request reaches
the wire (e.g. the keep-alive reuse race, where a request waits ~8-10s for a
socket while the agent tears down one the peer is closing). Wrap each per-gateway
agent's addRequest and time addRequest -> 'socket' (socket acquisition) and, for
new sockets, socket -> 'connect'.
- gateway_socket_acquisition_seconds{gateway_url,reused} histogram
- gateway_socket_connect_seconds{gateway_url} histogram
- warn log "Slow gateway socket acquisition" above
GATEWAY_SLOW_SOCKET_ACQUISITION_LOG_THRESHOLD_MS (default 1000)
A high acquisition time (especially reused=true) is the direct fingerprint of the
keep-alive stall addressed by GATEWAY_AGENT_IDLE_SOCKET_TIMEOUT_MS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/data/gateways-data-source.ts`:
- Around line 175-183: The gateway socket connect histogram in
`gateways-data-source.ts` is measuring only the plain `connect` event, so
HTTPS/TLS sockets miss the handshake time. Update the `isNewSocket` timing logic
to use `secureConnect` for `TLSSocket` instances in the same block where
`metrics.gatewaySocketConnectSeconds.observe` is called, or otherwise adjust the
metric/help text to clearly indicate it only measures TCP connect time. Keep the
fix localized around the socket event hookup in `gateways-data-source.ts`.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 5891df17-8dc1-4cb1-bd07-5556a71f83e1
📒 Files selected for processing (5)
docker-compose.yamldocs/envs.mdsrc/config.tssrc/data/gateways-data-source.tssrc/metrics.ts
✅ Files skipped from review due to trivial changes (1)
- docs/envs.md
🚧 Files skipped from review as they are similar to previous changes (1)
- src/config.ts
…ps gateways The gateway_socket_connect_seconds timer listened for 'connect', which on a TLSSocket fires after TCP connect but before the TLS handshake — so for https gateways (notably arweave.net) it undercounted connect time by the whole handshake. Listen for 'secureConnect' on https:// gateways and 'connect' otherwise, so the metric reflects TCP + TLS as its help text states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… vs untrusted The keep-alive agents were hardcoded at maxSockets 16 / maxFreeSockets 4 for every gateway. Instrumentation (gateway_socket_acquisition_seconds) showed that on a busy but otherwise-healthy node, inter-gateway requests queue for seconds waiting for a socket slot — the 16-socket cap, not upstream latency, is the binding constraint. Make the caps configurable and set them per agent by trust so internal peers and CDN-fronted upstreams can be tuned independently: - GATEWAY_MAX_SOCKETS_PER_HOST (default 16) — trusted/internal peer gateways - GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST (default = above) — untrusted gateways (e.g. arweave.net); set lower to avoid a CDN throttling/502 under concurrency - GATEWAY_MAX_FREE_SOCKETS_PER_HOST (default 4) — idle keep-alive sockets kept warm; raise alongside max sockets to reuse rather than reopen connections Defaults preserve current behavior (16/16/4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… number
Each socket-cap setting now accepts either a bare positive integer (applied to
every host, as before) or a JSON object mapping an exact gateway URL to a
per-host value with an optional `default` key — e.g.
{"http://10.84.0.82:4000":128,"default":64}. This lets a busy sibling peer run a
higher cap than other trusted gateways while arweave.net stays throttled,
without one global value for all.
- add PerHostNumber type + parsePerHostNumber (validates positive ints) and the
exported resolvePerHostNumber(cfg, host, fallback) helper
- GATEWAY_MAX_SOCKETS_PER_HOST / GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST /
GATEWAY_MAX_FREE_SOCKETS_PER_HOST all parse number-or-object
- getAgent resolves per host (exact -> default -> built-in fallback)
- unit tests for resolvePerHostNumber; docs updated
Bare-integer configs (the current deployment) are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/config.ts (1)
359-371: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDead default value in
GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOSTbranch.The
env.varOrUndefined('GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST') !== undefinedcheck already guaranteesrawis defined by the timeparsePerHostNumberis called, so thedefaultValueparameter (16) passed there is unreachable insideparsePerHostNumber. It's harmless, but the duplicated16magic number could drift fromGATEWAY_MAX_SOCKETS_PER_HOST's actual default if that changes later.♻️ Simplify by letting parsePerHostNumber return undefined when unset
-export const GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST: PerHostNumber = - env.varOrUndefined('GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST') !== undefined - ? parsePerHostNumber('GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST', 16) - : GATEWAY_MAX_SOCKETS_PER_HOST; +export const GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST: PerHostNumber = + parsePerHostNumberOrUndefined('GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST') ?? + GATEWAY_MAX_SOCKETS_PER_HOST;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/config.ts` around lines 359 - 371, The `GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST` initialization in `src/config.ts` duplicates the `16` default even though the env presence check already guarantees a value, so the fallback is dead and can drift from `GATEWAY_MAX_SOCKETS_PER_HOST`. Simplify the branch by having `parsePerHostNumber` handle the unset case directly or by reusing `GATEWAY_MAX_SOCKETS_PER_HOST` as the fallback, and keep the behavior centered around the `GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST` and `parsePerHostNumber` symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/config.ts`:
- Around line 284-351: The new exported symbols in config.ts are documented with
plain line comments instead of TSDoc. Convert the comments for PerHostNumber,
parsePerHostNumber, and resolvePerHostNumber to proper TSDoc blocks so they
satisfy the codebase guideline for touched exported APIs, while keeping the
existing descriptions accurate and concise.
---
Nitpick comments:
In `@src/config.ts`:
- Around line 359-371: The `GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST`
initialization in `src/config.ts` duplicates the `16` default even though the
env presence check already guarantees a value, so the fallback is dead and can
drift from `GATEWAY_MAX_SOCKETS_PER_HOST`. Simplify the branch by having
`parsePerHostNumber` handle the unset case directly or by reusing
`GATEWAY_MAX_SOCKETS_PER_HOST` as the fallback, and keep the behavior centered
around the `GATEWAY_UNTRUSTED_MAX_SOCKETS_PER_HOST` and `parsePerHostNumber`
symbols.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: cc89b5d6-4f9f-4ed9-830b-6a491611e354
📒 Files selected for processing (5)
docker-compose.yamldocs/envs.mdsrc/config.test.tssrc/config.tssrc/data/gateways-data-source.ts
✅ Files skipped from review due to trivial changes (1)
- docs/envs.md
🚧 Files skipped from review as they are similar to previous changes (1)
- src/data/gateways-data-source.ts
…HostNumber Convert the plain // comments on the new socket-cap helpers to TSDoc blocks per the repo guideline for touched exported symbols. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Motivation
On the canary gateways, the core
StandaloneSqliteworker intermittently backsup under concurrent retrieval load — the
SQLite Core Worker Latency by Methodpanel shows every core-worker method spiking to tens of seconds at once. That
panel is derived from
standalone_sqlite_method_duration_seconds, which timesenqueue → completion, so it conflates three very different things: time spent
waiting in the queue, worker execution, and main-event-loop reply scheduling.
During a jam every method inflates to the queue depth, so the panel can't tell
you which query is expensive or where the time actually goes.
Investigation found the individual queries are all indexed and fast (single-digit
ms in isolation), which points at saturation — a single core read worker
(
core.read = 1) serializing request volume, plus main-event-loop latency on theworker message round-trip — rather than a slow query. This PR adds the
instrumentation needed to confirm that and the knob to relieve it.
Changes
Split timing metrics (
metrics.ts,standalone-sqlite.ts)standalone_sqlite_method_queue_wait_seconds— enqueue → dispatchstandalone_sqlite_method_service_seconds— dispatch → reply receivedsqlite_queued_opsgauge — enqueued-but-not-yet-dispatched depth per worker/roleReading them: high queue_wait → worker-pool serialization (raise the read
pool); high service with fast underlying queries → main-event-loop
saturation (more read workers won't help). The existing
standalone_sqlite_method_duration_secondssummary is retained for dashboardcontinuity.
Slow-op log
queue_wait + service ≥ SQLITE_SLOW_QUERY_LOG_THRESHOLD_MS(default 1000),a
warnreportsmethod, the queue/service breakdown, current queue depth,and a bounded arg summary (
summarizeSqliteArgs— scalars pass through, longstrings/blobs become
<bytes:N>/<string:N>, containers collapse; no buffersor row payloads reach the logs).
Configurable reader pools
CORE_SQLITE_READ_WORKER_COUNT(default 1) andDATA_SQLITE_READ_WORKER_COUNT(default 2) make the reader pool sizes tunable; WAL supports concurrent
readers. Writers stay single — SQLite permits only one writer, and writes
are not the bottleneck.
Defaults preserve current behavior exactly.
docs/envs.mdand thedocker-compose passthrough are updated.
Testing
tsc --noEmit: cleaneslint src test+prettier: cleansrc/database/standalone-sqlite.test.ts: 68/68 pass (queue mechanics intact)Follow-ups (not in this PR)
whose caller already timed out).
serviceproves ambiguous vs main-loopscheduling in practice.
🤖 Generated with Claude Code