Skip to content

fix(telemetry): stop re-walking stores too large for dbstat - #4166

Merged
ScriptedAlchemy merged 4 commits into
masterfrom
fix/telemetry-large-store-growth
Oct 17, 2026
Merged

ScriptedAlchemy merged 4 commits into
masterfrom
fix/telemetry-large-store-growth

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

Doctor on the live daemon reports two table-growth unknowns:
storage.table_growth.sessions.db.unknown.deadline_exceeded and
storage.table_growth.user-sessions.db.unknown.deadline_exceeded. These are the
20 GB and 5.5 GB session stores. Per-table growth walks every table page through
dbstat, so the walk cost tracks the store size. On these stores the walk can
never finish inside the 5 s reader deadline. Every full maintenance tick still
spent 5 s per store and measured nothing.

This PR has two commits.

  1. simplify(telemetry) deletes SqliteStoreSizeTelemetryPort from
    tracedecay-rusqlite-runtime. It was a second implementation of the
    store-size telemetry port, and only its own tests called it. The production
    port is GuardedStoreTelemetryPort in tracedecay-maintenance. The covered
    behavior that the production port did not test now has tests on
    GuardedStoreTelemetryPort:
    • store size reports the exact page pragmas;
    • a table created after the baseline is baseline-pending, not grown from zero;
    • a read for another store is denied without bytes;
    • a wait on a busy health reader yields the poller instead of blocking it.
  2. fix(telemetry) stops re-walking stores that are too large for dbstat.
    When a walk runs out of its deadline, the port records the store's page
    count. Later passes first read page_count, which is a cheap pragma. While
    the store is at least that large, the pass skips the walk and reports the new
    reason exceeds_measurement_budget. A smaller store, or a mark older than one
    day, is walked again. A successful walk clears the mark. The mark is part of
    the persisted table-growth state, so it survives a daemon restart.

Doctor now reports
storage.table_growth.<store>.unknown.exceeds_measurement_budget for these
stores. Store-level growth for them is still observed through page_count.

Alternatives measured

The alternatives ran on a copy of the live user-sessions.db (1,356,185 pages,
200 tables). The copy was deleted afterwards.

Walk Time
Current query, first run 94.9 s
Current query, warm 64.2 s
dbstat with aggregate=TRUE 48.5 s
aggregate=TRUE with mmap_size set 55.6 s

No dbstat form fits 5 s. aggregate=TRUE is also not adopted for another
reason. In aggregate mode one xNext walks a whole b-tree before control returns
to the VDBE, so sqlite3_interrupt waits for the current table to finish. After
a SIGINT at 2 s, the aggregate query exited at 3.2 s and the current query
exited at 2.0 s. The current per-page form keeps the reader interruptible.

Two other options were rejected. The tables that matter most are the largest
ones, so walking only some tables does not help. A fixed page threshold does not
work either, because the cost per page varied 20x between a hot ext4 page cache
(1.7 to 4 µs) and this ZFS host (36 µs). The over-budget mark adapts to the host.

Verification

  • An end-to-end run used GuardedStoreTelemetryPort with the production 5 s
    deadline on a 1,438,536-page (5.9 GB) fixture. The scratch harness was not
    committed. The first pass stopped at its deadline (5.006 s) and recorded the
    mark. The second pass took 2.5 ms and the third took 0.25 ms. Both reported
    exceeds_measurement_budget.
  • an_over_budget_store_is_not_walked_until_it_shrinks_or_the_mark_ages_out
    fails without the skip, because the walk runs and reports
    BaselineEstablished.
  • a_telemetry_wait_yields_the_polling_thread fails when the wait runs inline
    instead of through native_runtime::spawn_blocking.
  • bazel test passed for //crates/tracedecay-maintenance:unit_test,
    //crates/tracedecay-rusqlite-runtime:unit_test, //crates/tracedecay-contracts:all,
    //crates/tracedecay-dashboard-api:all, //crates/tracedecay:daemon_suite, and
    //crates/tracedecay:unit_test filtered to daemon::maintenance (19 tests).
  • bazel build --config=clippy passed on the maintenance, rusqlite-runtime and
    contracts crates. rustfmt_check_2024 passed for all three.
  • contracts:generate updated only sdks/typescript/src/operations.ts, and
    contracts:check reports the contracts are up to date.

The reader side is unchanged. reader/worker.rs table_sizes keeps its query
and its interrupt-on-timeout behavior.


Devin Review

GuardedStoreTelemetryPort in tracedecay-maintenance is the production
store-size and table-growth telemetry port. The rusqlite-runtime copy had
no production caller; only its own tests used it.

The behavior those tests covered that the production port did not is now
tested on GuardedStoreTelemetryPort: observed store-size pragmas, a table
created after the baseline reported as baseline-pending, a denied read for
another store, and a wait on a busy health reader yielding the poller.
Per-table growth walks every table page through dbstat, so its cost
tracks the store size. On the live daemon the 20 GB sessions.db and the
5.5 GB user-sessions.db can never finish inside the 5 s reader deadline
(the same walk takes 48-95 s on a copy of user-sessions.db). Every full
maintenance tick spent 5 s per store and Doctor reported
unknown.deadline_exceeded for both.

A walk that runs out of its deadline now records the store page count.
Later passes read page_count first, which is cheap, and skip the walk
while the store is at least that large. They report the new
exceeds_measurement_budget reason. A smaller store, or a mark older than
a day, is walked again.

Measured through GuardedStoreTelemetryPort on a 1.44M-page (5.9 GB)
fixture: the first pass stops at its 5.006 s deadline and marks the
store. The next two passes return in 2.5 ms and 0.25 ms.
@changeset-bot

changeset-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 37472ab

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +59 to +60
page_count >= self.page_count
&& now.0.saturating_sub(self.observed_at.0) < TABLE_SIZE_WALK_REPROBE_MICROS

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.

🟡 Shrunken tables stay unmeasured for a day

After large tables lose rows, still_applies skips their walks while the file's page count stays unchanged. SQLite retains freed pages on its freelist, so a newly affordable dbstat walk remains suppressed for a day.

Learn more

SQLite normally retains pages freed by row deletion in its database file. The new suppression check compares page_count with the count recorded at the timeout. Deleting most rows can therefore shrink the b-trees that dbstat traverses without reducing page_count; the next pass still skips the read for 24 hours. The same store_size_telemetry result already includes freelist_pages, which can distinguish this change.

Example: A 20 GB sessions database times out at five million pages. Retention removes most sessions and releases four million pages to SQLite's freelist without running VACUUM. Its page_count remains five million; the walk is skipped despite only one million occupied pages remaining.

Recommended fix: Record and compare occupied pages (page_count - freelist_pages) from the existing store-size sample, or clear the mark on a material freelist increase. Preserve an expiry for cases where the page count stays stable but workload conditions change.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +80 to +84
Err(TableGrowthUnavailableReasonV1::DeadlineExceeded) => {
Ok(TableSizeWalk::OverBudget(OverBudgetTableSizeWalk {
page_count,
observed_at,
}))

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.

🟡 Transient deadline suppresses later table reads

When table_size_telemetry times out for a transient reason, settle_table_size_walk marks the store over budget. A healthy store then skips table measurements for 24 hours unless its page count drops.

Learn more

The table-size worker returns DeadlineExceeded whenever its reply takes longer than the bound, including time queued behind prior worker activity or temporarily slow storage. That error alone does not distinguish an unaffordable dbstat walk from a transient delay. Once recorded, the new mark suppresses further walks for a full day on an unchanged store.

Example: A modest database briefly stalls on disk IO for five seconds. Its table_size_telemetry call times out; later reads would finish quickly, but maintenance skips them until the mark ages out.

Recommended fix: Treat a single timeout as an uncertain failure and require repeated timeouts before suppressing walks, or obtain a distinct indication that the dbstat query itself exhausted its budget. Keep the transient deadline reason visible until the evidence supports the over-budget classification.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

…-store-growth

# Conflicts:
#	plugin/chatgpt-extension/embedded/server.mjs
@ScriptedAlchemy
ScriptedAlchemy merged commit 073c00a into master Oct 17, 2026
12 of 22 checks passed
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