gabriel / musehub public
push-timeout-diagnosis.md markdown
257 lines 9.2 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago

Push Timeout Diagnosis

What We Are Trying To Do

Create a repo on MuseHub and push code to it. That is the entire requirement. A user should be able to run muse push staging dev and have it complete in seconds, the same way git push origin main completes in seconds for GitHub.


The Repo Under Test

gabriel/muse — the muse VCS engine itself.

Metric Value
Commits 827
Objects 6,860
Branches dev, main

What We Know About the Push Path

Client-side batching (no CF Worker configured for staging)

The push CLI uses the local hub path (push.py:447). Objects are batched in CHUNK_OBJECTS = 500 per HTTP POST.

6,860 objects / 500 per batch = 14 batches (13 × 500 + 1 × 360)

Each batch is one call to transport.push_stream_coro()POST {url}/push/stream.

  • Non-final batches: 500 objects, no commits
  • Final batch: 360 objects + 827 commits + 827 snapshots

Client HTTP settings: http2=False, timeout=300s, max_keepalive_connections=0

Server-side per batch

Request body received
    ↓
wire_push_stream() reads H frame, N×O frames, C frame, E frame
    ↓
_flush_batch(): CONCURRENCY=32 async R2 PUTs per 500 objects
    ↓
bulk_upsert_snapshot_entries(): one INSERT...ON CONFLICT
    ↓
ordered_commits loop: build new_commit_rows
    ↓
pg_insert bulk INSERT for commits
    ↓
branch pointer update
    ↓
Response sent

Cloudflare proxy in between

CF sits between the muse CLI and the staging EC2 origin. CF's 524 timeout fires when the origin does not produce a response within ~100 seconds. It is triggered by silence — no response bytes, not slow bytes.


Everything We Have Tried

Attempt 1 — Fresh push after delete+recreate (failed: CF 524)

What: Deleted gabriel/muse on staging, recreated it, ran muse push staging dev. Result: CF 524 after ~120 seconds. Diagnosis: The batch containing 500 large objects (or 6860 in one shot — unclear at the time) exceeded CF's origin timeout.

Attempt 2 — Repair script for ghost objects (worked, but wrong problem)

What: Ran a repair script that did POST /gabriel/muse/repair-object for each of 1004 missing objects. Result: Objects repaired. But no commits were ever pushed — the repo was still empty. Diagnosis: Treating the symptom, not the disease. The push itself was failing before any objects landed.

Attempt 3 — Stream _WireResponse response frames (froze)

What: Changed _WireResponse.__call__ to:

  • Send http.response.start immediately (before processing)
  • Forward each P frame as http.response.body chunk with more_body=True
  • Send final R frame, then empty more_body=False terminator

Also changed wire_push_stream to emit yield _prog(...) every 100 commits inside the ordered_commits loop.

Result: Push started, appeared to work longer than 120s, then froze after "several minutes". Diagnosis (hypothesis): Unknown. Several candidates:

Candidate Evidence For Evidence Against
HTTP/1.1 doesn't support bidirectional streaming Using http2=False explicitly ASGI spec allows interleaved send/receive
CF buffers the full response before forwarding Would explain silent client Not documented CF behavior
R2 PUTs are slow (>100s for 500 objects) Each PUT is an HTTPS round trip CONCURRENCY=32 should parallelize
Too many P frames create overhead Forwarding every frame Only ~5 P frames per batch
Something else entirely

Status: NOT DEBUGGED. We do not actually know why it froze.


What We Do NOT Know (Unknowns to Resolve)

  1. How long does one batch of 500 objects actually take? (R2 PUT time end-to-end)
  2. Is the 524 from batch 1, or from the final batch with commits?
  3. Does http.response.start actually reach CF before the R2 PUTs complete?
  4. Is "froze" a CF reset, a client timeout (300s), or a server exception?
  5. What is the actual terminal output when the push freezes? (we never captured it)

The Architecture We Need to Match

GitHub pushes gabriel/muse in ~5–15 seconds because:

  1. git pack-objects delta-compresses all objects into one pack file (~20–50 MB)
  2. Client sends that ONE file as one HTTP POST body
  3. Server unpacks server-side (fast, in-process) while streaming sideband messages back
  4. One round trip, streamed response, no CF timeout

Our current push:

  1. Client sends objects as individual msgpack frames (no delta compression)
  2. Server stores each to R2 individually (N × HTTPS round trips)
  3. Server then inserts commits to Postgres
  4. Server then sends response

The structural difference: N R2 PUTs vs 1 pack upload. This is the performance gap.


The Goldilocks Zone (What "Fixed" Looks Like)

A push of gabriel/muse (827 commits, 6860 objects) must:

  • Complete in < 60 seconds (matching GitHub order of magnitude)
  • Not trigger CF 524 (no silence > 100s at any point)
  • Show progress on the terminal during the wait
  • Not require exotic infrastructure (no CF Worker, no special R2 config)

Atomic Tests (Bottom-Up)

These are the smallest measurable units. Each must pass before scaling up.

Layer 0 — Baseline: can we push at all?

Test Command Expected
T0-a Push 1 object, 0 commits to staging < 2s, ok=True
T0-b Push 0 objects, 1 commit to staging < 2s, ok=True
T0-c Push 1 object, 1 commit to staging < 2s, ok=True

Layer 1 — R2 PUT latency

Test What Expected
T1-a Time a single R2 PUT from EC2 < 200ms
T1-b Time 32 concurrent R2 PUTs from EC2 < 300ms total
T1-c Time 500 R2 PUTs (CONCURRENCY=32) < 5s
T1-d Time 500 R2 PUTs × 14 (full push) < 70s

If T1-d > 100s, the batching approach cannot work with CF. Must change architecture.

Layer 2 — One batch, end-to-end timing

Test Objects Commits Expected total
T2-a 10 0 < 2s
T2-b 100 0 < 5s
T2-c 500 0 < 15s
T2-d 500 827 < 20s

If T2-c > 100s, a single batch times out CF. Must split batches smaller or stream heartbeats.

Layer 3 — Heartbeat delivery

Test What Expected
T3-a Server sends http.response.start before R2 PUTs Verifiable via log timestamp
T3-b CF receives first response byte before 100s silence 524 does not fire
T3-c Client receives P frame before R2 PUTs complete P frame logged before R-frame

Requires http.response.start to actually flush through CF to the client before processing.

Layer 4 — Full push

Test What Expected
T4-a Push all 14 batches sequentially All batches ok=True
T4-b Total wall clock < 60s
T4-c No 524s in any batch CF logs show no 524

The Experiment to Run First

Before writing any more code: measure T1-c and T2-c with exact timestamps.

Create a minimal test script (/tmp/push_timing_test.py) that:

  1. Calls staging /gabriel/muse/push/stream with exactly 500 random objects
  2. Timestamps: connection established, first request byte sent, last request byte sent, first response byte received, last response byte received
  3. Prints each timestamp to stderr

Run it from the local machine (simulating the real CLI path, not from EC2).

If response arrives in < 10s: the streaming heartbeat approach (Attempt 3) should work, and the freeze had a different cause.

If response takes > 60s: the R2 PUT bottleneck is real and we need a pack-based upload.

If CF returns 524: the heartbeat approach must send the first response byte before the R2 PUTs complete.


Next Actions (in order)

  1. [ ] Run the timing test — measure one batch of 500 objects end-to-end
  2. [ ] Based on timing: decide if the streaming approach is viable or if pack upload is needed
  3. [ ] TDD the chosen approach at the atomic level before implementing
  4. [ ] Scale test: confirm timing is linear from 1→6860 objects

What We Now Know About the Push Path (corrected)

The global R2 PUT semaphore is 100 (not 32 as assumed above):

# musehub_wire.py:50
_R2_PUT_SEM = asyncio.Semaphore(100)

Revised R2 timing estimate for 500 objects:

  • 500 PUTs / 100 concurrent = 5 rounds
  • Each R2 PUT from EC2 to R2: ~50–150ms
  • 5 × 150ms = 750ms per batch

Revised total estimate: each batch should complete in < 5 seconds. 14 batches = < 70 seconds total.

This is MUCH faster than the CF timeout. So why did it 524?

Unknown. We need to measure.


Code Locations

Component File Key Lines
Client batching muse/muse/cli/commands/push.py 447–498
Client HTTP POST muse/muse/core/transport.py 1045–1135
CHUNK_OBJECTS constant muse/muse/core/transport.py 202
Server response handler musehub/musehub/api/routes/wire.py 267–341
Server push stream musehub/musehub/services/musehub_wire.py 1110+
R2 flush batch musehub/musehub/services/musehub_wire.py search _flush_batch
CF timeout constant (CF proxy config, not in code) 100s default
Client timeout muse/muse/core/transport.py _TIMEOUT_SECONDS = 300
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago