Skip to content

fix(sqlite): stop getTxByOffset misses from scanning the offset index to the end#818

Merged
arielmelendez merged 2 commits into
developfrom
fix/tx-offset-miss-scan
Jul 8, 2026
Merged

fix(sqlite): stop getTxByOffset misses from scanning the offset index to the end#818
arielmelendez merged 2 commits into
developfrom
fix/tx-offset-miss-scan

Conversation

@arielmelendez

Copy link
Copy Markdown
Contributor

Motivation

The SQLite slow-op log added in #817 captured getTxByOffset calls with
queueWaitMs=0 but serviceMs of 23–110 seconds on a canary gateway, all
during retrieval-load episodes where peer socket pools pinned at their caps.
Re-running the captured offsets against a production-sized core DB showed every
slow call was a miss — an absolute weave offset in a coverage gap (format-1
tx or unpopulated region) — while hits consistently resolved in ~10ms.

Root cause: selectStableTransactionOffsetById keeps the span predicate
((offset - data_size) < @offset) inside the index scan. On a hit the first
index row matches immediately. On a miss no row can ever match, so SQLite walks
stable_transactions_offset_idx from @offset to the end of the table — with a
table-row fetch per entry — before returning empty. Each such miss pins a core
read worker for tens of seconds and holds the requesting peer's socket open,
cascading into outbound socket-pool saturation.

Fix

Weave data regions never overlap, so the first format = 2, data_size > 0
row with offset >= @offset is the only possible spanner — any other tx whose
region ends at or past @offset starts after that row's region ends. The
statement now fetches exactly that row in an inner LIMIT 1 subquery and
applies the span check outside. A miss costs the same single index probe as a
hit.

Partial-index caveat (guarded in code + test): stable_transactions_offset_idx
is a partial index (WHERE format = 2 AND data_size > 0). SQLite only uses it
when the query's WHERE clause implies the index's WHERE clause, so both
predicates must remain inside the inner query verbatim — hoisting either one out
silently degrades to a full table scan. The SQL comment documents this and the
new test asserts the plan.

Validation

Against a production-sized core DB (read-only, exact offsets captured by the
slow-op log):

offset before after
hit (control) ~10ms ~10ms, identical row
miss (logged 44s / measured 41.8s cold) 23–110s ~10ms
miss ×3 more 23–27s logged ~10ms
beyond weave end full remaining scan ~10ms

EXPLAIN QUERY PLAN confirms a single
SEARCH stable_transactions USING INDEX stable_transactions_offset_idx (offset>?).

Tests

New regression test covering: hit, gap miss, format-1-spanned miss, beyond-end
miss, and an EXPLAIN QUERY PLAN assertion (statement loaded via sql-loader,
not duplicated) that the plan probes stable_transactions_offset_idx and never
scans stable_transactions — this specifically catches the partial-index
mismatch, which timing on a small test DB cannot. Full
standalone-sqlite.test.ts suite: 69/69 pass.

🤖 Generated with Claude Code

Ariel Melendez and others added 2 commits July 7, 2026 22:11
… to the end

selectStableTransactionOffsetById kept the span predicate
((offset - data_size) < @offset) inside the index scan. On a HIT the first index
row matches and the query returns in ~10ms, but on a MISS -- an offset in a
coverage gap (format-1 tx, unpopulated region) -- no row can ever match, and
SQLite walked stable_transactions_offset_idx from @offset to the end of the
table with a table-row fetch per entry before returning empty. On a
production-sized DB that is tens of seconds to minutes per miss (observed up to
110s via the slow-op log), each one pinning a core read worker and holding the
requesting peer's socket, which cascades into outbound socket-pool saturation.

Since weave data regions never overlap, the first format-2 data_size > 0 row
with offset >= @offset is the only possible spanner. Restructure the statement
to fetch exactly that row in an inner LIMIT 1 subquery and apply the span check
outside, making a miss cost the same single index probe as a hit.

The partial-index predicates (format = 2 AND data_size > 0) must stay inside the
inner query verbatim: stable_transactions_offset_idx is a partial index and
SQLite only uses it when the query's WHERE clause implies the index's WHERE
clause. Hoisting them out silently degrades to a full table scan -- the SQL
comment and the new plan-assertion test both guard this.

Validated against a production-sized core DB (read-only): hit unchanged (~10ms,
same row); previously-pathological misses (logged 23-110s) now ~10ms; the plan
is a single SEARCH via stable_transactions_offset_idx.

Adds a regression test covering hit, gap miss, format-1-spanned miss,
beyond-end miss, and an EXPLAIN QUERY PLAN assertion that the statement probes
stable_transactions_offset_idx and never scans the table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
URL.pathname is URL-encoded and non-native on some platforms; fileURLToPath
yields a correct filesystem path everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a136e2f6-4a1a-4134-a1ae-12c9e74c4b5e

📥 Commits

Reviewing files that changed from the base of the PR and between 9ee9967 and f53145f.

📒 Files selected for processing (1)
  • src/database/standalone-sqlite.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/database/standalone-sqlite.test.ts

📝 Walkthrough

Walkthrough

The stable transaction offset lookup query now selects a single partial-index-backed candidate before applying the span check, and the standalone SQLite tests add regression coverage for hit and miss behavior plus query-plan validation.

Changes

Offset Query Fix

Layer / File(s) Summary
Restructure offset lookup query
src/database/sql/core/offsets.sql
selectStableTransactionOffsetById now uses an inner query for format = 2 and data_size > 0 candidates with ORDER BY offset ASC LIMIT 1, then applies the span predicate outside that subquery.
Regression test for miss behavior and index usage
src/database/standalone-sqlite.test.ts
Adds SQL-loading imports and a regression test that seeds spanning transactions, checks getTxByOffset hit/miss results, and asserts EXPLAIN QUERY PLAN uses stable_transactions_offset_idx without scanning stable_transactions.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • ar-io/ar-io-node#370: This related PR also changes selectStableTransactionOffsetById behavior and adds getTxByOffset regression coverage.
  • ar-io/ar-io-node#801: This related PR modifies the same offset lookup SQL and its candidate-selection semantics.

Suggested reviewers: dtfiedler, djwhitt

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main SQLite fix: preventing getTxByOffset misses from scanning to the end of the offset index.
Description check ✅ Passed The description clearly matches the PR's SQLite performance fix, rationale, and validation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tx-offset-miss-scan

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/database/standalone-sqlite.test.ts (1)

422-423: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer fileURLToPath over URL(...).pathname for the SQL directory.

new URL('./sql/core', import.meta.url).pathname returns a URL-encoded, non-native path (leading-slash drive paths on Windows, %20 for spaces), which fs.readdirSync in loadSql will mishandle. Using fileURLToPath yields a correct filesystem path across platforms.

Also worth confirming the .sql files resolve at test runtime (i.e., tests execute against src/, not a compiled dist/ where sql/core/*.sql may not be emitted), otherwise loadSql returns an empty map and the assertion at Line 424 fails.

♻️ Proposed change
-      const offsetsSqlDir = new URL('./sql/core', import.meta.url).pathname;
+      const offsetsSqlDir = fileURLToPath(new URL('./sql/core', import.meta.url));

Add the import (adjust to existing node:url import if present):

+import { fileURLToPath } from 'node:url';
🤖 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.test.ts` around lines 422 - 423, The SQL
directory path in the standalone SQLite test is built with URL.pathname, which
can produce an invalid filesystem path and break loadSql on some platforms.
Update the test setup around loadSql/offsetsSqlDir to use fileURLToPath with
import.meta.url so the path is native and decoded, and make sure the test is
resolving against the source sql/core directory at runtime so the
selectStableTransactionOffsetById lookup still finds the .sql file.
🤖 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.

Nitpick comments:
In `@src/database/standalone-sqlite.test.ts`:
- Around line 422-423: The SQL directory path in the standalone SQLite test is
built with URL.pathname, which can produce an invalid filesystem path and break
loadSql on some platforms. Update the test setup around loadSql/offsetsSqlDir to
use fileURLToPath with import.meta.url so the path is native and decoded, and
make sure the test is resolving against the source sql/core directory at runtime
so the selectStableTransactionOffsetById lookup still finds the .sql file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2767e95b-6929-4a41-8bbd-ede8b79f38ae

📥 Commits

Reviewing files that changed from the base of the PR and between 56b176e and 9ee9967.

📒 Files selected for processing (2)
  • src/database/sql/core/offsets.sql
  • src/database/standalone-sqlite.test.ts

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.75%. Comparing base (56b176e) to head (f53145f).

Additional details and impacted files
@@           Coverage Diff            @@
##           develop     #818   +/-   ##
========================================
  Coverage    78.75%   78.75%           
========================================
  Files          133      133           
  Lines        50880    50880           
  Branches      3834     3835    +1     
========================================
  Hits         40072    40072           
  Misses       10757    10757           
  Partials        51       51           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@arielmelendez

Copy link
Copy Markdown
Contributor Author

@coderabbitai Addressed the nitpick in f53145f — switched to fileURLToPath(new URL(...)) for the SQL directory path (with the node:url import). On the second point: the suite passing confirms the .sql files resolve at test runtime (tests execute against src/ via the TS loader, not dist/). Note standalone-sqlite.ts itself uses the same .pathname pattern when loading these statements; left that as-is since changing production code is out of scope here.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
sed -n '1,60p' src/database/standalone-sqlite.test.ts | head -80
rg -n "fileURLToPath|pathname" src/database/standalone-sqlite.test.ts src/database/standalone-sqlite.ts

Length of output: 2388


@arielmelendez Thanks for the update — confirmed in the current branch that standalone-sqlite.test.ts now imports fileURLToPath from node:url and uses it for offsetsSqlDir at Line 423, resolving the Windows path-separator concern.

On the second point, that's a fair call. standalone-sqlite.ts (Line 531) does still use sqlUrl.pathname, so the pattern is at least consistent between production and test code even if not the most portable approach. Since it's pre-existing behavior and out of scope for this fix, leaving it as-is makes sense — happy to file a follow-up issue if you'd like to track it separately, otherwise I'll leave it be.

@arielmelendez

Copy link
Copy Markdown
Contributor Author

Disjointness assumption validated against production data.

The rewrite assumes tx data regions in stable_transactions never overlap (first candidate row = only possible spanner). Ran a full read-only audit on a canary gateway's production core DB: for all 70,127,446 rows in the partial-index domain (format = 2 AND data_size > 0), ordered by end offset, zero rows have (offset - data_size) < LAG(offset) — i.e. 0 overlap violations. Any overlap between any two regions would necessarily surface as a consecutive-pair violation in this ordering, so the check is complete, not sampled.

On this dataset the new statement is therefore provably input-equivalent to the old one (identical hits and misses for every possible offset) — minus the pathological miss-scan cost.

@arielmelendez arielmelendez merged commit 960b9cd into develop Jul 8, 2026
3 of 4 checks passed
@arielmelendez arielmelendez deleted the fix/tx-offset-miss-scan branch July 8, 2026 15:24
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