Repository navigation
Conversation
store.scope spelled every chosen folder as its own (path >= ? AND path < ?) range, all ORed into one expression: at 1,179 folders (ARahim3#166's reporter, working around ARahim3#165) every pending query blew past SQLite's expression-depth cap of 1,000 and the indexer died at each startup. The ranges now live in a temp table and the queries test them with a correlated EXISTS — depth stays constant whatever the folder count, and the table grows with the list for free. setScope(_:) is throwing now; a 1,200-folder test reproduces the old failure shape. Closes ARahim3#166
…ndex The ARahim3#191 review found the scope write is not atomic: scope moved in memory before any table write, and CREATE, DELETE and the INSERT chunks each committed on their own, so a write that failed mid-flight (disk full) left the queries answering as if the chosen folders were empty — pendingCount 0, counts 0, no error — until the next setScope succeeded. The write now runs inside one transaction and scope/scopeSQL move only after it commits: a failed setScope leaves the previous scope standing, in memory and in the table, and every scoped query answers as before. A RAISE(ABORT) trigger test reproduces the disk-full shape; ROLLBACK undoing temp-table writes (measured on macOS's SQLite 3.51 and a 3.49 bundled build; the vendored amalgamation is 3.54) is what keeps the table's contents intact. PRIMARY KEY(lo, hi) WITHOUT ROWID is the probe's lookup: EXPLAIN shows SEARCH s USING PRIMARY KEY where the review measured SCAN s. Measured on an Apple M1 (macOS 26.5.1, Swift 6.4.2, system SQLite 3.51) over a 1.2M-file index with 1,200 folders: pendingCount 4.22 s to 2.05 s, pending(limit: 50) 4.05 s to 1.80 s, counts() 153 s to 70.7 s, setScope 4.0 ms to 2.7 ms. DigUpKit 120/120 passing, LlamaRuntime 7/7 with the model.
The scoped queries asked every range with lo <= path whether the path sat under its hi — up to 1,200 PRIMARY KEY seeks per row. Merging the list into disjoint spans in setScope lets one seek answer the same question: the range with the greatest lo at or below a path is the only one that can cover it, so the queries compare that range's hi. Merging is bytewise, SQLite's BINARY order for TEXT; nested ranges fold, and a folder listed twice folds with them, so it never trips the PRIMARY KEY. Measured on an Apple M1 (macOS 26.5.1, Swift 6.4.2, system SQLite 3.51) over a 1.2M-file index with 1,200 folders: pendingCount 878 ms to 27.7 ms, pending(limit: 50) 831 ms to 30.2 ms, unfinishedCount 78 ms to 3.9 ms, counts() 36.0 s to 0.70 s. The EXISTS form it replaces cost 2.05 s and 70.7 s on the same index with a zero-match scope; main's OR-chain crashed there outright. A research harness says the merged predecessor answers exactly as the EXISTS form everywhere: the 1.2M benchmark agrees on all four queries, and a 48- comparison edge matrix (nested, adjacent-with-gaps, sibling /ab vs /a, dual /tmp spellings, precomposed and decomposed names, unplugged drives, empty, nil, tie fetches) is exact — the new mergedScopeKeepsNestedAdjacentAndDupli- cateFoldersExact and countsSeeOnlyTheScopedFilesSegments tests lock the shapes in the suite. DigUpKit 122/122 passing, LlamaRuntime 7/7 with the model.
…d the covering index The bucket column, counter tables, triggers, fill, and content check all run inside the one transaction migrate() opened, with the version stamp last: a crash at any point leaves v5 exactly intact, and a re-run after such a crash is safe because every step is guarded. The state-first covering index and the prepared-statement path in Database exist for the counter reads that follow in the next commit — the cache's only user is the migration's per-row bucket backfill, where re-parsing per row costs more than the update itself.
…egation kept as the oracle A folder scope drives from the scopeb temp table so the cost follows folders, not rows: each range seeks the covering counters_state index, Θ(folders) measured against live aggregation at every scale (0.4 ms vs 11.1 s for the rows-driven join at 2.985M rows × 100 ranges). fullCounts, fullPendingCount, and fullUnfinishedCount keep the aggregation form as the tests' oracle; bucketRanges spells the counters' bucket-key ranges exactly like pathRanges spells the path ranges, same caveats included.
…ts in 25 tests Every write path is followed by a row-for-row comparison against the aggregation oracle; migration tests include a SIGKILL of the real CLI child mid-migration (rolls back to a clean v5, the next open completes to v6), the legacy malformed-partial shape, and two connections on one WAL index. The two invariant pins record the suite's assumptions as behavior: no production statement reparents a segment row in place, and the WHEN guards fire once per file update — the forbidden state-plus-bucket shape is shown to drift the counters and rebuildCounters is shown to heal it.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Incremental Counters for Counts, Pending, and Unfinished (Schema v6) — #194
Overview
This PR implements #193: incremental SQLite counters for
counts(),pendingCount(), andunfinishedCount().Previously, these operations repeatedly aggregated data from the
filesandsegmentstables. As the index grew, these queries became increasingly expensive, particularly for large media libraries.This implementation maintains derived counter tables as files and segments are inserted, updated, or deleted. Count queries can then read the relevant counter rows instead of repeatedly scanning and aggregating the underlying index.
The implementation introduces:
files.bucketcolumn.counterstable tracking file counts by directory, kind, and processing state.segcounterstable tracking segment counts by directory and vector status.counts(),pendingCount(), andunfinishedCount().All performance figures in this PR are measured results, collected on an Apple M1 running macOS 26.5.1 with system SQLite 3.51.0. Comparisons use the PR #191 implementation as the baseline, running on the same machine with the same data.
Stacking and Branch Information
This PR is stacked on [PR #191] (#191), which provides the folder-scoping machinery used by the new counter queries.
Consequently, this branch also contains PR #191's three commits:
b17d59a174c4098da015bOnly the final three commits introduce the changes for this PR. Once PR #191 is merged, the remaining diff will contain only the incremental counter implementation.
Implementation
1. Schema v6 and Incremental Counter Maintenance
The migration introduces two derived counter tables.
counters(bucket, kind, state)Tracks file counts grouped by:
segcounters(bucket, hasvec)Tracks segment counts grouped by:
The migration also adds
files.bucket, a derived directory value computed from each file's path. This allows counter queries to operate on directory ranges without repeatedly deriving directory information from file paths.Eight SQLite triggers maintain these counters automatically when the underlying files and segments change.
The implementation also adds a state-first covering index:
This index allows SQLite to locate counter rows by processing state and directory range while retrieving the required counter values directly from the index where possible.
2. Transactional Migration
The v5-to-v6 migration runs inside a single SQLite transaction.
The migration follows this sequence:
files.bucketcolumn.The schema version is stamped last, after the counter backfill and consistency check have succeeded.
This ensures that an interrupted or failed migration rolls back to the original v5 database rather than exposing partially populated counters as a valid v6 database.
Migration steps are guarded with
IF NOT EXISTSand column-presence checks usingpragma_table_info, allowing safe migration retries.3. Counter-Based Reads and Folder Scoping
The implementations of
counts(),pendingCount(), andunfinishedCount()now read from the counter tables instead of repeatedly aggregating thefilesandsegmentstables.When a folder scope is active, the query uses the temporary
scopebtable introduced by PR #191.Each scope range drives an indexed lookup against
counters_state:This allows SQLite to seek directly into the relevant counter ranges.
The scoped counter query therefore scales with the number of relevant directory ranges and matching counter rows, rather than requiring aggregation across millions of file and segment records.
The original aggregation implementations remain available as reference methods:
fullCounts()fullPendingCount()fullUnfinishedCount()These serve as correctness oracles in the test suite, allowing the optimized results to be compared against the original calculations.
Commits
The implementation is divided into three commits:
c8f05275718d152e2d50cEach commit compiles independently. The first retains the original aggregation-based reads, the second introduces the optimized reads and reference implementations, and the third adds the counter-specific test coverage.
Migration Performance and Safety
All migration measurements below were collected using the production migration code.
Additional verification:
SUM(n) = 1,208,000, matching the total files and live segments.Interrupted Migration Test
Migration recovery was tested by killing an actual DigUp CLI child process with
SIGKILLduring migration of a disposable v5 database.The test verified that:
The regression test is:
interruptedMigrationRollsBackToV5OnSIGKILLRead and Write Performance
All results below compare the incremental counter implementation against the PR #191 baseline on the same Apple M1 and equivalent datasets.
Read Performance
nilscope)setScopeThe largest improvements occur when queries operate on a relatively narrow folder scope. These queries can read a small number of relevant counter rows instead of aggregating the entire index.
Unscoped
counts()also improves, although its advantage becomes smaller as the number of directory buckets grows.Write Performance
Maintaining derived counters introduces additional work during database writes. The following figures show the measured percentage increase in operation time compared with the PR #191 baseline.
resetAllThe largest costs are associated with operations that update many counter rows, particularly
resetAlland requeue-heavy workloads.These are intentional tradeoffs: the implementation performs additional work when the index changes to avoid repeatedly performing expensive aggregations during normal reads.
Counter Consistency Under Churn
The benchmark harness verified that counter results matched the aggregation-based oracle at every tested scale (
equal=yes).A separate 30,000-operation mixed-write workload also maintained row-for-row consistency.
Some counter rows become zero-valued as files and segments are deleted or move between states. These rows are correctly ignored by counter reads.
Approximately 23–53% of counter rows became zero-valued in the tested churn scenarios. The public
pruneZeroCounterRows()operation is available to remove these unused rows during housekeeping.At 5M files, peak RSS was identical between the two implementations: 1,101 MB.
Tests
The full test suite passed on a clean rebuild:
154/154 tests passing across 18 suites.
The test run included 25 new
CounterTestsand the existing DigUpKit and LlamaRuntime suites.Coverage includes:
SIGKILLrecovery.rebuildCounters().Important Invariant Tests
Two additional tests explicitly pin important implementation invariants.
Segment ownership and kind immutability
Production code does not reparent an existing segment or change its kind in place. The counter implementation relies on this invariant because segment ownership and kind changes do not have dedicated counter-maintenance triggers.
The test
segmentOwnershipAndKindAreNeverUpdatedverifies that no production update statement violates this assumption.Trigger firing boundaries
The counter triggers rely on specific update shapes to ensure that each file update adjusts counters exactly once.
The test
guardsFireOnceAndTheForbiddenShapeIsHealedByRebuildverifies that:stateandbucketchanges can cause counter drift.rebuildCounters()restores consistency after such a violation.These invariants are documented in the production code. Future changes to file or segment update paths must preserve them or update the counter-maintenance logic accordingly.
SQLite 3.54 Compatibility
The vendored SQLite 3.54.0 amalgamation was tested against the system SQLite 3.51.0 implementation.
The compatibility harness used the production package compilation flags, including:
It exercised:
markStopped.resetAll.The SQL was captured from the production
IndexStoreimplementation atOP_Init, with bound values expanded, and replayed against both SQLite versions.Results:
Compatibility Test Limitations
This was a SQL-level compatibility test, not a complete application-level test of both SQLite versions.
The following were not exercised against SQLite 3.54:
Databasewrapper behavior.Additional platform limitations:
The results establish SQL-level compatibility, not full cross-platform validation.
Known Limitations and Disclosed Tradeoffs
resetAlland requeue-heavy workloads are the most expensive operations because of the additional counter maintenance. The measured overhead is documented above.counts()with no folder scope scales with the number of counter rows. At 500,000 folders, it was approximately 1.5× the aggregation cost on state-uniform data. It was never slower than the baseline in the measured tests.pruneZeroCounterRows()is available for housekeeping.rebuildCounters()provides a recovery mechanism if counter consistency is lost.pathRangesimplementation in PR Keep the scoped queries alive past a thousand folders #191. A folder name beginning with U+10FFFF can escape the scope in both the existing path query and the new counter query. This behavior is identical in both implementations and is not introduced by this PR. Such names are representable on ext4 and NTFS, but not APFS.Summary
This PR replaces repeated aggregation of the
filesandsegmentstables with incrementally maintained SQLite counters.Combined with the folder-scoping improvements from PR #191, this allows DigUp to calculate indexing and processing counts much more efficiently for large media libraries.
The implementation preserves the original aggregation queries as correctness oracles, verifies counter consistency during migration, and provides explicit rebuild and pruning operations for maintenance.
The measured results show substantial read-performance improvements, particularly for scoped queries, with additional write overhead that is documented and measured.
The implementation passed a clean 154-test suite, migration interruption testing, counter consistency verification, and SQL-level compatibility testing against SQLite 3.54.