perf(sync): parse a pull page with one jq, framed by NUL, with no eval (#908, #940) - #942
Merged
Conversation
Each pulled message paid a printf and a jq, and the jq emitted shell assignments for eval -- the reason #930 had to route every field through tostring|@sh. One jq now parses the whole page (--raw-input, so a line carrying two JSON values is still refused, by line number) and emits a record count and then eighteen NUL-separated raw fields per record; the loop reads them with read -d '' into plain variables. No server-chosen byte is parsed as shell anymore, and the stream is verified end to end: count matches records, every record completes its frames, EOF where the count says. The framing's precondition is #940's fix: jq refuses any record whose field contains U+0000, naming the record and the field. Before this, a body holding U+0000 was reported imported and stored as different bytes (hex 785C3079 for x<NUL>y -- jq -r's raw NUL plus the shell stripping it, both version-dependent); bash cannot hold a NUL in a variable at all, so every path through the shell either mangles or refuses, and now it refuses out loud. A failed record still refuses the whole page before anything is written, as it always did. Contract cases pin the refusal and the framing; the suite is green under bash 5 and /bin/bash 3.2. Measured (join, 400 messages, two runs each): bootstrap.apply 23.6/23.7 -> 15.5/16.9 ms/msg. Apply's per-message process count is now zero (#908: #925 the quoting, #939 the grep, this the jq). Closes #940.
…ited surplus Two review findings on the parent commit. The final EOF probe accepted bytes past the declared count when they arrived without a trailing NUL: read -d '' returns non-zero at EOF but still fills its variable, so the status alone is not the check -- the variable must also be empty. And the failure sites that predate the stream (cursor, type, wire, seq, status, projection, sqlite exec) still cleaned up only the SQL file; called directly in a long-lived shell, as the bats suite does, they leaked the descriptor and the jq stderr file. Every failure after the stream opens now goes through one cleanup -- fd closed, both temp files removed, globals cleared, trap dropped -- and the suite is green under bash 5 and /bin/bash 3.2 (43/43 each). Verified live that a refused page leaves no fd and no temp file behind and that repeated failures do not accumulate files.
fujibee
force-pushed
the
perf/apply-one-jq-per-page
branch
from
August 22, 2026 00:53
2fc7a8e to
19b0e82
Compare
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.
Part of #908, item (3) — and it closes #940, because the fix and the framing are the same decision. Stacked on #939 (base
mainso CI runs; until #939 lands the diff shows its commit too).One jq per page, and no
evalat allstorage_sync_apply_pullpaid aprintf | jqper message, and the jq's output was a list of shell assignments fed toeval. That second half is why #930 had to route all eighteen fields throughtostring | @sh: quoting discipline was the only thing between a server-chosen string and the shell.Now a single
jq -j -R -sparses the whole page and emits, after a leading record count, each record's eighteen fields as raw values separated by NUL bytes; the loop reads them withIFS= read -r -d ''into plain variables. There is noeval— nothing server-chosen is parsed as shell anymore, so the class #930 defended against is gone rather than guarded.--raw-inputkeeps the old "one JSON value per line" refusal: each line isfromjson'd on its own, and a line carrying two values fails with the line named, exactly as the per-line jq refused it.Blast radius is unchanged. Today a single bad record already refuses the whole page before anything is written (
jq_ok=0→ the SQL file is discarded → 13); the page-level jq refuses the same way, and names what the old sentinel could not: which record, and which field. The stream is also verified end-to-end — the leading count must match the records read, every record must complete its eighteen frames, and one more read past the count must find EOF.Why a NUL can never break the framing: U+0000 is refused (#940)
NUL-delimited framing is only sound if a value cannot contain a NUL — and it cannot, because jq refuses any record whose field contains U+0000, naming the record and the field. That is #940's fix as much as the framing's precondition. Measured on
mainbefore this change: a body of"xy"was accepted, reported imported, and stored as four different bytes (hex 785C3079— the NUL re-spelled on its way throughjq -r's raw output and the shell's own NUL-stripping, both of which vary by version). A value the store cannot hold verbatim is now refused, not silently rewritten. Contract cases pin both: the refusal (record and field named, nothing committed) and the framing checks (two values on one line, mid-frame truncation, bytes past the declared count).bashcannot hold a NUL in a variable at all (command substitution andreadboth drop or stop at it), which is also why the alternative — carrying such a value through — does not exist: any scheme that lands in shell variables loses the byte. Refusing is the honest version of what every framing here would do anyway.Before and after, same machine, same method
tests/perf/join-harness.sh --messages 400,bootstrap.apply, two runs each:8cb6cad)About −7 ms per message — the
printfand thejq— for ~2 more minutes off a 17,300-message pull; the whole apply path has gone 280 → ~16 ms/msg across #925, #939 and this. The suite (tests/test_remote_sync.bats, 43 cases) is green under bash 5 and under/bin/bash3.2 — the day #925 landed, a bash-3.2 divergence is what CI caught, so both are checked before pushing, and the primitives this leans on (read -r -d '', process substitution, a dynamic variable name toread) were probed on 3.2.57 directly.Scope
scripts/drivers/storage/sqlite-sync.sh,storage_sync_apply_pullonly; the per-ack jq inreconcileis perf(sync): one jq per acknowledgement, not six forks #927's shape and untouched; the jsonl driver has its own apply.printf|grep, this theprintf|jq. What remains per message is bash and SQLite.