Clone performance — the tree walk re-fetched shared sub-trees once per parent
Date: 2026-10-02 Branch: claude/charming-babbage-5hzcxq (from dev @ 0c9be4b, v0.16.3) Vault probed: y8pwtjlw (the DC collaboration vault, 172 commits at the time of the run) Reported with: sgit-ai v0.14.27, stuck at ▸ Walking trees: obj-cas-imm-4c9e63979601
1. What the clone was doing
An instrumented run of the real clone steps (derive keys → index → branch meta → walk commits → walk trees) against the live vault, with the BFS tree walk mirrored exactly and every batch request timed:
| phase | time | requests | notes |
|---|---|---|---|
| walk commits | 32 s | ~100 | linear chain: one commit per round trip |
| walk trees | 377 s | 205 | 10,098 tree ids requested for 2,236 unique trees |
| (blobs, not run) | ~15 s | ~52 | 2,563 small blobs, fetched 8 chunks in parallel |
The per-level picture of the tree walk is the whole story:
| level | queue | unique | duplication | requests | download |
|---|---|---|---|---|---|
| 1 | 170 | 170 | 1.0× | 4 | 7 s |
| 2 | 2263 | 269 | 8.4× | 46 | 86 s |
| 3 | 1938 | 508 | 3.8× | 39 | 73 s |
| 4 | 3561 | 811 | 4.4× | 72 | 132 s |
| 5 | 2135 | 460 | 4.6× | 43 | 75 s |
| 6 | 31 | 18 | 1.7× | 1 | 1 s |
Two defects compound:
Vault__Graph_Walk.walk_treesqueued a shared sub-tree once per parent.next_qwas only guarded bysub not in visited, andvisitedis updated at processing time, so within a level every root tree that pointed at the same unchanged folder enqueued its own copy. A 170-commit history whose root trees share most of their folders made the next level ~8× larger than it needed to be, and all of those duplicates went toon_batch_missingand so to the server. Nothing was written twice (the per-treevisitedguard still held), but 4.5× the bytes and requests went over the wire.Vault__API.batch_readfetched its 50-id chunks one after another. Each Lambda round trip costs ~1.7 s for 50 objects, so a 3,561-id level became 72 serial requests (132 s) during which the only progress line is the last decrypted tree id — which is why it looked hung onobj-cas-imm-4c9e63979601. The blob download step already fanned out 8 chunks in parallel; the tree walk did not.
The commit walk is a third, smaller cost: a linear chain needs one round trip per commit because the parent id is inside the ciphertext. ~0.19 s per request × 172.
2. The fix (this branch)
Vault__Graph_Walk.walk_treeskeeps aseenset (queued ∪ visited) so every tree id is enqueued — and requested — exactly once.on_batch_missingstill gets one call per BFS level. Pull's tree walk uses the same class and benefits identically.Vault__API.batch_readfans its chunks out over a bounded pool (BATCH_READ_WORKERS = 8, matching the blob fan-out); each chunk keeps its own 502 → per-file → presigned-S3 fallback, and the first chunk failure still propagates as before. Single-chunk calls stay inline.Vault__API__Staticalready did this;Vault__API__In_Memoryis unaffected.Step__Clone__Walk_Trees/__Head_OnlyemitWalking trees: fetching N tree(s)before each level's download so a large level is visible instead of looking stuck.
Measured on the same vault after the change, full sgit clone:
| phase | before (probe) | after |
|---|---|---|
| commits | 32 s | 32 s |
| trees | 377 s | 24 s |
| blobs | ~15 s | 14 s |
| checkout | – | 0.4 s |
| wall | ~7–8 min | 73 s |
sgit check fsck on the result reports 17 missing objects — all blobs, all also absent on the server (not_found on a direct read), referenced by trees in commits 7b72ec534ca6, 3cd27e9b3222, ff4cc8df75e8, 81c7fd7c6cb4, 68797954d2ef (several are *.conflict files and baseline.json). That is a push from some client that never uploaded those blobs; it is pre-existing and unrelated to the walker (the old walker's probe store lacks them too). Worth noting: the blob step prints 2586 blobs and does not say that 17 came back empty — see §4.
Tests: 3 new walker tests (shared sub-tree requested once across 100 roots, duplicate roots, one call per level) and 4 new batch_read tests (chunking + all returned, single chunk inline, chunk failure propagates, 502 fallback confined to the failing chunk). pytest tests/unit/ -n auto → 3841 passed.
3. Would the newer releases have helped?
No. PyPI's latest is 0.16.0 (published 2026-08-20 from the last dev → main merge); dev is at v0.16.3 but nothing after 0.16.0 has reached PyPI because publishing runs on main only (ci-pipeline__main.yml sets should_publish_pypi: true; the dev pipeline had publishing removed on 2026-08-14). Between 0.14.27 and today the walk code changed once — the verify-before-write hook on 2026-08-20 — which does not touch the algorithm. Vault__Graph_Walk itself was last edited 2026-05-11. So 0.16.0 clones this vault exactly as slowly as 0.14.27; the fix needs a release that includes this branch.
The one dev-only change that is relevant to the follow-up question is sgit vault move rewriting object ids (2026-08-20/21), which is also not on PyPI.
4. Follow-ups (not done here)
- Commit walk round trips. 172 serial requests at ~0.19 s.
curltiming shows ~0.11 s of each ~0.43 s request is the TLS handshake (a reused connection answers in 0.16 s).urllib.request.urlopenopens a fresh connection per call; a per-thread persistenthttp.client.HTTPSConnectioninVault__API._requestwould roughly halve the commit walk and shave the tree/blob phases too. Needs care with proxies (HTTPS_PROXYis honoured by urllib today) and the retry path. - Silent missing blobs on clone.
_download_blobs_by_iddropsNonepayloads without a word; the clone reports success and onlyfsckreveals the gap. A one-linewarningwith the count (and the first few file names, which the tree entries give) would have told the user about the 17 absent blobs at clone time. sgit check fsckhas the same per-commit re-walk: it reported44256 treesfor a vault with 2,264 unique trees because it restarts the tree walk from each commit with a freshvisited_trees. Local disk only, so it is seconds not minutes, but it scales with commits × tree size.- Full-clone shortcut: a non-sparse clone downloads every reachable object anyway, so
list_files('bare/data/')+ one parallel bulk fetch + a local walk would collapse all three phases into one fan-out. It changes semantics (unreachable objects would be fetched too), so it is a design decision, not a patch.
5. Rekey while keeping history — where things stand
The brief's todo: "a rekey loses the entire commit history". That is true of sgit vault rekey (the wipe → init → commit flow: it deletes .sg_vault/ and starts a fresh history from the working tree). It is not true of sgit vault move on dev:
Step__Move__Build_Temp_Vault._rewrite_object_graphclassifies every object by reachability from the refs, then re-encrypts bottom-up — blobs, then trees with theirblob_id/tree_idremapped and names re-encrypted deterministically, then commits withtree_idandparentsremapped — storing each at its recomputed content address. Unreachable objects are carried too. Refs and the branch index are re-pointed.- Verified locally on a throwaway vault (
init+ 3 commits): after the rewrite the new store under the new read key resolves the clone ref to a 4-commit chain (commit 3 → commit 2 → commit 1 → init) with the right file names at each step, 11 objects in, 11 out, and zero shared ids between old and new store. Only the local build step was run (no access token in this session, so push/verify/delete-source were not exercised against the server).
So "rekey keeping history" already exists as sgit vault move --new-key <key>; the hashes change (as expected) but the chain, messages and trees all survive. What it does not do, and what the user may actually want:
- Keep the same vault id.
movederives a new vault id from the new key, so it is a fork-and-delete, not an in-place rotation. Sharing links and.sg_vault/localconfigs on every clone go stale. An in-place variant would need the server to accept a full object-set replacement under the same id (write new objects, swap refs/index, delete old), and the read-key → ref-id derivation (derive_ref_file_id(read_key, vault_id)) means the named ref id changes with the key even if the vault id does not. - Ship it. It is on
devonly; PyPI 0.16.0 still has the old in-place re-encrypt that keeps ids (and therefore produces vaults that the new strict verify refuses). sgit vault rekeyshould either delegate tomoveor say plainly that it discards history, since today the two commands overlap in name and differ exactly on the point the brief cares about.
A full-chain check of move against the server (push → verify → clone the new key → history log shows all commits) is the next thing to run, with a token.
6. Follow-up (same day): keep-alive connections — done, with a measurement caveat
Dinis asked for the connection-reuse follow-up without a new dependency. Implemented as sgit_ai/network/api/Vault__HTTP_Pool.py (stdlib http.client) with Vault__API._send on top; _request / _request_bytes keep their signatures and retry loop.
Rules, as agreed, each with a test against a real local HTTP/1.1 keep-alive server (tests/unit/network/api/test_Vault__HTTP_Pool.py, 21 tests):
| rule | test |
|---|---|
sequential calls share one socket; headers still sent per request; no Connection: close | test_sequential_requests_share_one_connection, test_headers_still_sent_per_request |
server Connection: close honoured | test_server_connection_close_is_honoured |
| stale keep-alive → resend once, reads only (GET + batch read) | test_stale_keep_alive_is_retried_for_idempotent_reads, test_batch_read_is_treated_as_idempotent |
stale keep-alive on a write → URLError, never replayed, pool recovers | test_stale_keep_alive_never_resends_a_write |
| failure on a fresh connection is not retried; half-read body poisons nothing | test_fresh_connection_failure_is_not_retried |
| same error text as before, token masked, connection reusable after a 4xx | test_http_error_shape_and_body_preserved |
| 502/503 retry loop unchanged, on the same connection | test_transient_502_retries_then_succeeds_on_same_connection, …_exhausted… |
| redirects not followed (token never replayed to another host) | test_redirects_are_not_followed |
| 8 threads × 5 calls → bounded pool, no errors | test_parallel_callers_share_a_bounded_pool |
key = scheme/host/port/verify/proxy; HTTPS_PROXY → CONNECT tunnel with Basic auth; NO_PROXY; plain-http proxy → absolute URI | Test_Vault__HTTP_Pool__Keys |
SGIT_HTTP_NO_KEEPALIVE=1 → one connection per request (A/B switch) | test_env_switch_disables_keep_alive |
The pool is shared with a lock rather than thread-local on purpose: batch_read and the blob download create a new ThreadPoolExecutor per call, so thread-local connections would be discarded after every BFS level.
Other details: User-Agent: sgit-ai (the layer-import test forbids network importing _version, so no version in it); the presigned-S3 fallback and the one-off urlopen calls in doctor/static transport are untouched; Vault__API.close() drops the pool.
pytest tests/unit/ -n auto → 3861 passed (one failure on the way: the layer-import rule, fixed by dropping the version from the User-Agent).
Measurement — read this before quoting numbers. Every TLS connection from this cloud session, including "direct" ones with NO_PROXY=*, is terminated by the session's egress gateway (openssl s_client shows issuer Egress Gateway SDS Issuing CA, not Amazon). So the numbers below describe that path, not a laptop talking to CloudFront:
| path | fresh connection per request (old) | reused (new) |
|---|---|---|
| via CONNECT proxy, 40 serial reads, mean | 388 / 340 ms | 292 / 283 ms |
| "direct" (still gateway-terminated), p50 | 217 / 185 ms | 327 / 329 ms |
The reused-connection path through the gateway is bimodal: ~145 ms or ~330 ms per request, and curl shows exactly the same pattern on HTTP/1.1 and HTTP/2 keep-alive, so it is the gateway's upstream handling, not Python and not SG/Send (I ruled out client delayed-ACK with TCP_QUICKACK and the two-segment request by sending headers+body in one record — no change). Through the explicit proxy, where a fresh connection costs more, keep-alive wins ~25 %. The full clone from here: commits 27.4s trees 36.3s blobs 15.4s for 194 commits / 2,735 blobs (the vault grew by 22 commits during the session), wall 86 s.
On a laptop with no interception the expected saving is the handshake itself, which Dinis can measure in one line (second number is the reused connection):
curl -sS -o /dev/null -w 'first=%{time_total}\n' https://dev.send.sgraph.ai/api/vault/read/y8pwtjlw/bare/refs/x \
--next -o /dev/null -w 'reused=%{time_total}\n' https://dev.send.sgraph.ai/api/vault/read/y8pwtjlw/bare/refs/x
and for the real thing, the same clone twice: sgit clone … and SGIT_HTTP_NO_KEEPALIVE=1 sgit clone …, comparing the ⏱ commits … trees … blobs … line.
Full-clone A/B from this session, same vault state (194 commits / 2,735 blobs), alternating:
| run | keep-alive | commits (serial, 1 req each) | trees | blobs | wall |
|---|---|---|---|---|---|
| 1 | on | 27.3 s | 34.5 s | 14.9 s | 79 s |
| 2 | off | 50.3 s | 27.1 s | 15.6 s | 96 s |
| 3 | on | 26.8 s | 24.7 s | 13.7 s | 67 s |
| 4 | off | 47.2 s | 26.4 s | 15.2 s | 91 s |
The serial commit walk — the phase that is one round trip per request — is where reuse shows: 27 s vs 47–50 s. The parallel phases are within noise of each other (the gateway's bimodal reused-connection latency cancels most of the handshake saving there).
7. Final review of the release PR (SGit-AI/SGit-AI__CLI#6) — findings and fixes
Ran the code-review pass at high effort over the three commits unique to this session (the rest of dev was reviewed by the architecture sessions in August). Ten findings; what was done with each:
| # | finding | verdict | action |
|---|---|---|---|
| 1 | an idle connection the server has already closed could be handed to a write (push: ref read → long local encryption → batch POST), failing with URLError where urlopen's fresh socket succeeded | real, the important one | pool now checks liveness before reuse (select readable == FIN, or TLS bytes pending → drop) and never reuses a connection idle > 30 s (a NAT that forgot the mapping sends no FIN). Push gets a fresh socket after a long local phase, exactly as before |
| 2 | the stale-read resend re-acquired from the same idle bucket, so two stale sockets failed an idempotent read | real | the resend uses acquire(fresh=True), bypassing the bucket |
| 3 | lazy pool creation raced when the first call was a parallel batch_read (8 threads → up to 8 pools, 7 orphaned) | real | double-checked lock around creation; test drives 8 chunks as the first call and asserts one pool holds every socket |
| 4 | plain http:// base URL through an authenticated proxy never sent Proxy-Authorization | real, edge | proxy_headers(key) adds it per request on that path; the CONNECT path carries it on the tunnel as before; dead double-construction removed |
| 5 | a 3xx is now a hard failure with no explanation | real (UX) | _api_error adds a hint naming the Location and saying to set --base-url to the final https URL; wording avoids the 404/405/501 markers the auto transport looks for |
| 6 | visited included trees whose load failed (pre-existing) | real, pre-existing | with seen preventing re-enqueue the guard is unnecessary; visited is now "actually loaded", so n_trees is honest |
| 7 | dead code in open_via_proxy | cleanup | folded into #4 |
| 8 | three ad-hoc ThreadPoolExecutor fan-outs (batch_read, static transport, clone blobs) | follow-up | not in this PR — a shared bounded fan-out helper is a refactor, not a fix |
| 9 | the four batch_read tests used unittest.mock.patch (CLAUDE.md: no mocks) | real | rewritten against the local keep-alive server, which now has a real batch endpoint (base64 of the file id; scripted 502 for one chunk) |
| 10 | object-typed pool fields | convention | timeout_seconds / max_idle_seconds are Safe_UInt; the socket bookkeeping (idle, warm, lock) stays object, the same pattern as Clone__Workspace's managers |
Also added while here: every pooled socket has a 120 s per-operation timeout (urlopen had none; a kept-alive socket is the one place a silent peer can hang a command forever).
One pre-existing behaviour the 502 test made visible, left alone: a 502 from the batch endpoint (Lambda response-size limit) is first retried whole three times with 2 + 4 + 8 s of sleeping before batch_read falls back to per-file reads. For a size-caused 502 the retries can never succeed; skipping them would save 14 s on that path.
Suite after the fixes: pytest tests/unit/ -n auto → 3871 passed.
This page as markdown · its source, admin/content/articles/clone-refetched-shared-subtrees.md