KEEP-006: cap a NNNNN.keeper log at 2 GiB past that the log is unmappable, and the cap is unenforceable without also capping the fetch

Now
OPEN
Sev
HIGH

PackLog says "a new log starts only past a size threshold" but never bounds that threshold, so a single landed pack can make one log arbitrarily large. GET-044 T4 proved it: a linux.git clone streamed 6.45 GB and atomically landed a durable *6.0 GB* 0000000001.keeper bytes on disk that no reader can open. RULING 2026-07-27 (gritzko): *cap at 2 GB, not at the engine's ceiling. The engine numbers, all measured this session on sparse files: JSC allocates a typed array up to 4 GiB inclusive, but only 2^32-1 of it is addressable (a[2^32-1] reads undefined, JS's canonical not-an-index sentinel), and the JAB-007 Buf | 0 cursor wraps NEGATIVE at 2^31 exactly, so io.mmap yields an empty or garbled view from there up. 2^31-1 is the only bound that survives the whole path, and ingest.js already refuses at exactly that (MMAP_CAP). jab/io.cpp JABC_MAP_MAX now enforces it in the mapper (this session). The sting in the tail: PackLog §Delta-dependency pins OFS_DELTA bases inside one pack*, so a received pack cannot be split across logs meaning a 2 GB log cap is only achievable if no single fetched pack exceeds 2 GB. Scope: keeper log rotation + the fetch that feeds it. See PackLog, Keeper; method Issues.

Input

Context

Goals

No keeper log ever exceeds 2 GB (2^31-1 bytes), and the writer refuses to create one instead of landing unreadable bytes.

Constraints

WIP

Design decisions

RULING 2026-07-27 (gritzko): log splitting is fine on the beagle side the indexes already support multiple files. That makes the incoming pack, not the log, the binding constraint: a received pack cannot be split at an arbitrary byte because PackLog §Delta-dependency pins OFS_DELTA bases inside one pack. Every crossing delta must be re-anchored by hash. So the question became how much does REF_DELTA inflate the pack, and it was measured rather than argued.

*Measurements 2026-07-27* git verify-pack -v over two real packs, base-reference bytes only (the zlib payload is byte-identical either way, so added = 20 - ofslen(distance) per converted edge; ofslen thresholds 128/16512/2113664/270549120 match git's offset encoding exactly). Verified every delta in the git.git pack is currently type 6 OFS_DELTA (315878/315878, type nibble read straight from the pack), so the baseline is real and nothing was already sha-coded.

git.git 300.9 MB pack 417,182 objs 315,878 deltas (75.7%) 2.76 B/delta option 1 all OFS 870,855 B baseline option 3 all REF +5,446,705 B +1.73% of pack option 2 crossings REF @2GiB cap 0 B 0.00% (fits one log)

@256MB +382,209 B (7.6% of deltas) +0.12%

linux.git 6.33 GB pack 11,690,992 objs 9,492,857 deltas (81.2%) 2.96 B/delta option 1 all OFS 28,067,830 B baseline option 3 all REF +161,789,310 B +2.56% of pack option 2 crossings REF @2GiB cap +24,089,888 B +0.38% (3 segs, 16.91% of deltas, 15.00 B/edge)

@1GiB +27,389,232 B +0.43% (6 segs, 19.22%)

@512MB +29,961,837 B +0.47% (12 segs, 20.99%) @256MB +32,195,316 B +0.51% (24 segs, 22.49%)

Three findings that decide it:

Bytes were never the real argument against all-REF. The other two costs are:

DECISION: *option 2* OFS within a pack/segment, REF only across boundaries. This is PackLog §Delta-dependency as already written, not a new design. The crossing set falls out of the scan pass ingest already runs, so the implementation cost is bounded.

Big incoming packs: stream-ingest, never stored

RULING 2026-07-27 (gritzko): *the received pack is never read back and never even stored.* It is consumed once, forward, straight from the wire into the log. This supersedes the whole line of thought that preceded it (windowed io._mmap, an io._pread primitive, git-style dependency-ordered resolution) none of it is needed, and none should be built.

Why it works: OFS_DELTA bases are backward-only (PackLog §Intra-pack object order, "forward-reference free"), so by the time a delta arrives on the wire its base has ALREADY been appended to our log. Every read-back therefore targets our own log, which is ≤2 GiB by the cap above and so maps with the plain whole-file io.mmap that exists today. The 6.33 GB pack is never seeked, never mapped, never written to disk. Resolving a chain is a walk inside a mapped log, exactly as it is today no new access primitive, no traversal reorder.

The one new structure is a *pack-offset log-offset map*, live only for the duration of one ingest. It is needed because rewriting a base ref changes a record's size (OFS distance re-encoded, or OFS→REF at +15 B), so log offsets drift away from pack offsets as you go. Two shapes, both measured against linux.git (11,690,992 objects, 3,566,506 of them 30.5% actually cited as a base; you cannot exploit that, since a base is named by an object that arrives later):

Start with the full map; compress to the drift table only if it bites.

The ingest loop is then: inflate resolve against the log sha1 index append with the base ref rewritten (OFS if the base landed in this log, REF if in an earlier one its sha is known, we just indexed it) rotate at KEEP_LOG_MAX. Constant RSS, disk cost = the logs alone (no 6.33 GB tmp spike on top).

Consequences to honour:

Still first in line regardless: curlRun (be/shared/wire.js:250) buffers the entire https response in the JS heap, so it dies on any pack over the typed-array cap before ingest gets a chance to run. Streaming ingest cannot start until the bytes arrive AS a stream.

Where stream-ingest lives: libdog, as "repack a git pack"

RULING 2026-07-27 (gritzko): the ingest loop is C, in libdog. *libdog sees the job as "repacking a git pack" and knows nothing about curl, HTTP, or pkt-line framing* opening the stream, stripping headers, and consuming the negotiation preamble is JS-level routing that hands libdog a bare pack byte stream (an fd) and a destination shard, and gets back counts and the new refs.

The loop, per PackLog object order (forward-only, bases already behind us):

  1. parse the next pack record from the stream;
  2. append pack_off -> our_off to the ingest-local map (our off is a wh64 carrying the log/file number, so the entry is wh128b);
  3. resolve the delta using that map plus the already-written pack log;
  4. hash it and repack it into the pack log;
  5. add the hashlet -> our_off entry to the HIT index (needed to resolve REF entries);
  6. keep a running sha1 over the stream for the trailing checksum;
  7. on reaching KEEP_LOG_MAX, start a new pack log.

Why C and not JS: it is per-object over ~11.7M objects for a linux-scale clone, and every step is already native dog/git/PACK.c header/ofs decode, PACKResolveOfs, PIDXScan (which already emits key=hashlet60|type4, val=offset wh128 entries straight into a caller-owned region), and abc's HIT/LSM for the index. In JS it would be 11.7M+ JSC binding crossings per clone, every one of them wrapping a buffer view and so exposed to the JAB-007 | 0 cursor hazard. JS keeps what it already owns: URL classify, capability/want-have negotiation, refs, shard resolution, rotate policy.

One correction to step 6, recorded because the instinct to drop it is reasonable and wrong: the running sha1 is *not* redundant with per-record hashing. Per-record hashes cover inflated object content only, not the framing bytes between records and a stream cut that happens to land on a record boundary would otherwise parse as a clean, complete pack. The trailer is what distinguishes "peer finished" from "connection died tidily". It costs one pass over bytes already in hand.

Prototype: dogrepack, verified on the 6.33 GB linux pack

Built 2026-07-27 in work/KEEP-006 as git/REPACK.cli.c (+ one add_executable line) a libdog CLI running the exact loop above, so the design is measured rather than argued. It consumes an fd, never stores or maps the source, and resolves every base out of our own logs via the pack-offset map.

corpus objects result beagle 7,652 7,652/7,652 shas == git, 6 logs @4 MB cap, 234 REF git.git 417,182 417,182/417,182 shas == git, 3 logs @128 MB cap, 57,609 REF linux.git 11,690,992 11,690,992/11,690,992 shas == git, 3 logs @2 GiB cap,

2,198,136 raw / 7,886,869 OFS / 1,605,989 REF, 12m22s

Two independent confirmations fell out of it:

Three constraints the production ingest inherits, each found by hitting it:

Also settled by construction: the loop never needed io._pread, windowed io._mmap, or dependency-ordered resolution. Every one of the 9.49M deltas resolved through the map plus our own logs, including across closed-log boundaries. Record shape used: mean 541 bytes, max 1.6 MB compressed (git.git max 0.4 MB) so a 1 GB buffer carries ~600x the worst record, and u8bReMap doubling is a safety net rather than a live path.

Buffer plumbing needs NO new primitive: abc/Bx.h already has it. PAST | DATA | IDLE maps one-to-one onto consumed / unparsed / read-target, with u8bShift (Bx.h:415) as drain-shift, u8bUsed as the parser cursor, u8bFed after a read, u8bMap for the MAP_NORESERVE allocation and u8bReMap for the grow case.

Fetch-side rules (bounded fetch), still open

Both depend on REF_DELTA resolution, which is a RESTORE not a build dog/git/PACK.c:252 returns PACKREF as a deliberate backstop ("Foreign REF packs go through UNPK"), and dog/git/PIDX.c:4 records that this leaner scanner DROPPED "the REF_DELTA waiter" the keeper had.

TODOs

Blockers and bummers

The https clone path has its own unrelated wall that will mask any fix here: curlRun (be/shared/wire.js:250) buffers the whole response in the JS heap and dies on new Uint8Array(total) for a >4 GiB body (RangeError: length too large) GET-044's streaming drainToFile was only wired to the spawn/ssh transport. Needs its own ticket; until it lands, https can't reach the log-writing code at all.

Outcome

Filed 2026-07-27 from the linux.git clone benchmark; the read half of the rotation now works. UNCOMMITTED in the main tree per the no-post order.