Commit2a57744dRecorded24 Oct 2026Repositoryfolio

Census: a failed metadata query or a projects/ symlink keeps the full census

Message

sqlite-query returns '() when prepare fails and a partial result when a step fails, so a failed notes_meta or link_sources query looked like "no rows" and let the fast path arm on a census that never saw them. The census now checks sqlite-errmsg after each metadata query and raises, which records a retryable failure.

The projects/ walk reads dotfiles, so a hidden symlink there is followed; only notes/ keeps the exemption for links under a dot component.

The task-notes parity tool now runs with the cache settle window off, so a wrong cache hit on a changed file can show.

Changed
 CHANGELOG.md                      |  2 +-
 src/folio/index.sgl               | 27 ++++++++++++++++++++-------
 test/test-idle-census.sgl         | 27 +++++++++++++++++++++++++++
 tools/check-task-notes-parity.sgl | 13 +++++++++++++
 4 files changed, 61 insertions(+), 8 deletions(-)
Diff
CHANGELOG.mdmodified
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
8
## [0.3.2] - Unreleased
9
−10
- Skip the per-file census on idle ticks. Each 15 s tick compares one native metadata listing of `notes/` and `projects/` (size, mtime-ns and ctime-ns of every entry) and SQLite's `data_version` with the last complete census, and runs the full census only when something differs, another connection committed, or a retryable failure is pending. A listing is trusted only once its newest mtime and ctime are 2 s old, so writes inside one coarse timestamp granule cannot hide. A visible symlink, or a listing line that does not parse as 7 fields, disables the skip. On a 4,467-note corpus an idle tick drops from 216-611 ms of CPU to about 24 ms.
+10
- Skip the per-file census on idle ticks. Each 15 s tick compares one native metadata listing of `notes/` and `projects/` (size, mtime-ns and ctime-ns of every entry) and SQLite's `data_version` with the last complete census, and runs the full census only when something differs, another connection committed, or a retryable failure is pending. A listing is trusted only once its newest mtime and ctime are 2 s old, so writes inside one coarse timestamp granule cannot hide. A symlink the walk would follow (any in `projects/`, any not under a dot component in `notes/`), a metadata query that fails, or a listing line that does not parse as 7 fields disables the skip. On a 4,467-note corpus an idle tick drops from 216-611 ms of CPU to about 24 ms.
11
- Yield every 32 files during a full census, so a large outside change no longer holds a shared HTTP server's only thread while it is reconciled.
12
- Reuse parsed project files, the parsed inbox and buried-id scans while each file's `stat` signature (dev, inode, size, mtime-ns, ctime-ns) is unchanged. A file is cached only once its mtime and ctime are 2 s old; a recently written file is parsed on every call. `tasks`, `projects` and `status` no longer re-parse every project file on each call.
13
- Answer `task-notes` from cached note frontmatter, keyed the same way, instead of parsing every note on each call (6.5 s on the corpus above). The answer is still computed from the files at call time.
src/folio/index.sglmodified
@@ -401,10 +401,21 @@
401
(unless (eq? value 'deferred) (forget-failure! idx key)))
402
#t))
403
+404
;; `sqlite-query` returns '() when prepare fails and the rows read so far
+405
;; when a step fails; only the connection's errmsg tells either from a real
+406
;; result. A failed query must raise here, so safe-index-item! records a
+407
;; retryable failure and the fast path cannot arm on a census that never
+408
;; saw the rows.
409
(define (safe-index-query idx label sql)
410
(let ((rows '()))
411
(safe-index-item! idx label
−407
(lambda () (set! rows (sqlite-query (folio-index-db idx) sql))))
+412
(lambda ()
+413
(let* ((db (folio-index-db idx))
+414
(result (sqlite-query db sql))
+415
(message (sqlite-errmsg db)))
+416
(unless (equal? message "not an error")
+417
(error (format "folio index: ~a failed: ~a" label message)))
+418
(set! rows result))))
419
rows))
420
421
(define (failed-content-unchanged? idx path signature)
@@ -556,8 +567,8 @@
567
;; defers until the file's signature changes, which changes the listing.
568
(define (armable? idx state captured-seconds)
569
(let ((cutoff-ns (* (- captured-seconds (census-settle-seconds)) 1000000000)))
−559
(and (listing-settled? (car state) cutoff-ns)
−560
(listing-settled? (cadr state) cutoff-ns)
+570
(and (listing-settled? (car state) cutoff-ns #t)
+571
(listing-settled? (cadr state) cutoff-ns #f)
572
(let ((failures (vector-ref (folio-index-failed-fingerprints idx) 0)))
573
(let loop ((keys (dict-keys failures)))
574
(or (null? keys)
@@ -567,9 +578,11 @@
578
;; Every line must parse as exactly 7 fields, so a name holding a newline
579
;; or a tab cannot hide an entry; no line may be a symlink the store walk
580
;; would follow (the walk follows them, the listing does not show their
−570
;; target's changes; the walk skips every dot component); and every
−571
;; mtime and ctime must be older than cutoff-ns.
−572
(define (listing-settled? listing cutoff-ns)
+581
;; target's changes); and every mtime and ctime must be older than
+582
;; cutoff-ns. hidden-exempt? is true only for notes/, whose walk skips
+583
;; every dot component; the projects/ walk reads dotfiles too, so any
+584
;; symlink there counts.
+585
(define (listing-settled? listing cutoff-ns hidden-exempt?)
586
(let loop ((lines (string-split listing "\n")))
587
(cond
588
((null? lines) #t)
@@ -578,7 +591,7 @@
591
(let ((fields (string-split (car lines) "\t")))
592
(and (= (length fields) 7)
593
(not (and (equal? (cadr fields) "l")
−581
(not (any-hidden-component? (car fields)))))
+594
(not (and hidden-exempt? (any-hidden-component? (car fields))))))
595
(let ((mtime (string->number (list-ref fields 4)))
596
(ctime (string->number (list-ref fields 5))))
597
(and mtime ctime (< mtime cutoff-ns) (< ctime cutoff-ns)))
test/test-idle-census.sglmodified
@@ -220,6 +220,33 @@
220
(assert-equal '(("source" . note)) (index-backlinks idx "target"))
221
(assert-equal 'unchanged (index-census-tick! idx))))))
222
+223
;; `sqlite-query` answers '() when the statement cannot run; the census must
+224
;; not take that for "no rows" and arm on it.
+225
(test "a read query that fails keeps every tick on the full census"
+226
(with-census-store
+227
(lambda (root store idx source)
+228
(index-census-tick! idx)
+229
(assert-equal 'unchanged (index-census-tick! idx))
+230
(let ((other (sqlite-open (path-join root ".folio-index.db"))))
+231
(assert-true (sqlite-exec other "ALTER TABLE notes_meta RENAME TO notes_meta_away"))
+232
(assert-equal 'reconciled (index-census-tick! idx))
+233
(assert-equal 'reconciled (index-census-tick! idx))
+234
(assert-true (sqlite-exec other "ALTER TABLE notes_meta_away RENAME TO notes_meta"))
+235
(sqlite-close other))
+236
(assert-equal 'reconciled (index-census-tick! idx))
+237
(assert-equal 'unchanged (index-census-tick! idx)))))
+238
+239
;; The projects/ walk reads dotfiles, so a hidden link there is followed and
+240
;; its target's edits are invisible to the listing.
+241
(test "any symlink in projects/, hidden or not, disables the fast path"
+242
(with-census-store
+243
(lambda (root store idx source)
+244
(let ((outside (path-join root "plan.md")))
+245
(write-file-string outside "# Plan\n")
+246
(sh "ln" "-s" outside (path-join (store-project-dir store) ".plan.md"))
+247
(assert-equal 'reconciled (index-census-tick! idx))
+248
(assert-equal 'reconciled (index-census-tick! idx))))))
+249
250
(test "an unsettled listing is not trusted until its newest time is old enough"
251
(with-settle 1
252
(lambda ()
tools/check-task-notes-parity.sglmodified
@@ -6,6 +6,7 @@
6
;;; change, an outside frontmatter edit, an outside note move, a note-rename
7
;;; through the tool, a task-move through the tool, and a deletion.
8
(import (sigil fs)
+9
(only (sigil async) sleep)
10
(sigil list)
11
(sigil path)
12
(sigil string)
@@ -18,8 +19,15 @@
19
(folio task)
20
(folio query)
21
(folio index)
+22
(folio filecache)
23
(folio tools))
24
+25
;; With the settle window on, a file changed by a round is never a cache hit
+26
;; in that round, so a wrong hit could not show. Run with it off, and wait one
+27
;; timestamp granule before each mutation so the signatures can differ.
+28
(file-cache-settle-seconds 0)
+29
(define (granule) (sleep 0.05))
+30
31
(define root (getenv "FOLIO_CORPUS"))
32
(unless root (error "FOLIO_CORPUS must name a disposable corpus copy"))
33
(when (string-contains? root "/Ops/folio") (error "refusing to run against a live store"))
@@ -105,6 +113,7 @@
113
;; Outside edit: add a task id to a different, unlinked note's frontmatter.
114
(define plain
115
(car (filter (lambda (n) (null? (note-tasks n))) (all-notes store))))
+116
(granule)
117
(write-file-string (folio-note-file-path plain)
118
(string-append "---\ntasks:\n - " linking-task "\n---\n\n" (folio-note-body plain)))
119
(round! "outside-edit")
@@ -112,16 +121,19 @@
121
;; Outside move of the linking note into a new directory.
122
(define moved-path (path-join (store-notes-dir store) "parity-moved" "moved-note.md"))
123
(ensure-directory (path-join (store-notes-dir store) "parity-moved"))
+124
(granule)
125
(rename-file (folio-note-file-path linking) moved-path)
126
(round! "outside-move")
127
128
;; Rename through the tool.
+129
(granule)
130
(let ((reply (tool "folio/note-rename" (dict name: "parity-moved/moved-note"
131
new-name: "parity-moved/renamed-note"))))
132
(unless (string-contains? reply "Renamed") (error "note-rename failed" reply)))
133
(round! "tool-rename")
134
135
;; Move a linked task to another project through the tool (when it is a project task).
+136
(granule)
137
(let* ((task (find (lambda (t) (equal? (folio-task-id t) linking-task)) (all-tasks store)))
138
(projects (store-project-files store))
139
(target (and task
@@ -135,6 +147,7 @@
147
(round! "tool-task-move")
148
149
;; Deletion.
+150
(granule)
151
(delete-file (path-join (store-notes-dir store) "parity-moved" "renamed-note.md"))
152
(round! "delete")
153