Reverse transport: run files-pull in the push direction (remote drives, local dials out)#313
Reverse transport: run files-pull in the push direction (remote drives, local dials out)#313adamziel wants to merge 15 commits into
Conversation
Design only, no code. Describes running the ordinary pull commands in the push direction over an outbound-only local connection: the remote runs the importer (owns cursors/index/apply/final writes) and the local runs a stateless source worker that delivers the previous export command's result and takes the next on each outbound request. One transport seam makes every regular command ride it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pull pipeline performance —
|
| Stage | PR | trunk | Δ | Status | Details |
|---|---|---|---|---|---|
playground-sqlite-db-pull |
9.30 s | 9.12 s | ⚪ +179 ms (+2.0%) | ✓ | condition=db-pull in PHP.wasm runtime=php.wasm 8.3 wp_mysql_parser=enabled mode=lexer native_lexer=verified native_token_stream=WP_MySQL_Native_Token_Stream native_token_count=18 native_parser=selected trunk: condition=db-pull in PHP.wasm runtime=php.wasm 8.3 wp_mysql_parser=enabled mode=lexer native_lexer=verified native_token_stream=WP_MySQL_Native_Token_Stream native_token_count=18 native_parser=selected |
playground-sqlite-db-apply |
3.60 s | 3.52 s | ⚪ +81 ms (+2.3%) | ✓ | condition=db-apply to SQLite in PHP.wasm runtime=php.wasm 8.3 wp_mysql_parser=enabled mode=parser native_lexer=verified native_token_stream=WP_MySQL_Native_Token_Stream native_token_count=18 native_parser=verified native_ast=WP_MySQL_Native_Parser_Node sqlite_driver_parser=verified trunk: condition=db-apply to SQLite in PHP.wasm runtime=php.wasm 8.3 wp_mysql_parser=enabled mode=parser native_lexer=verified native_token_stream=WP_MySQL_Native_Token_Stream native_token_count=18 native_parser=verified native_ast=WP_MySQL_Native_Parser_Node sqlite_driver_parser=verified |
| Total | 12.90 s | 12.64 s | ⚪ +260 ms (+2.1%) |
Numbers carry runner noise; treat single-run deltas as directional, not authoritative.
📈 Trunk performance history — commit-by-commit timeline.
Implements the single-endpoint reverse pull: the remote runs the importer and owns all state; the local source is outbound-only. One `relay_exchange` call both delivers the previous export command's result and takes the next. The endpoint *is* the importer, driven one step per call — a `TransportYield` unwinds the importer cleanly after it issues its next request, reusing the existing resume-from-cursor discipline. No second remote process, no held socket; the local worker's outbound request is the trigger. Four small classes under packages/reprint-importer/src/lib/relay/: - RelayTransport: returns the one delivered result or throws TransportYield - TransportYield: carries the pending command back to the exchange handler - RelayExchange: runs the importer inline; catches the yield, returns command - RelaySourceWorker: outbound-only loop; executes each command locally ReverseTransportDemoTest moves real file bytes source -> destination over the reversed channel (byte-identity assertion) and covers crash-resume. Its stand-in file source and mirror driver play the parts export.php and files-pull take in production; wiring the real ones through this seam is the follow-up. Updates markdown/REVERSE-TRANSPORT.md to make the single-endpoint model primary and records remote-liveness as solved (the outbound request is the trigger). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ull)
Adds a transport seam to ImportClient so an ordinary files-pull runs entirely
over the reverse channel — real importer, real export.php — with the source
outbound-only. Direct mode is untouched: fetch_json/fetch_streaming take a relay
branch only when set_relay_transport() is called, and import.php's PHPCS count is
unchanged (504/74).
How it hangs together:
- A request's URL is its relay command identity. build_url drops the
non-deterministic cache-buster in relay mode so a re-issued request after a
yield fingerprints identically to its delivered result.
- relay_fetch_streaming feeds the worker-carried body to the SAME
MultipartStreamParser + chunk handler the curl path uses; the boundary is
read back from the body (no capturable Content-Type header in-process).
- TransportYield now extends Error, not Exception, so it slips past the
importer's `catch (Exception)` command wrapper without persisting an error
status. No explicit "persist cursor at the yield boundary" code is needed:
the importer already checkpoints at exactly the exit-2 request boundary.
- RelayExportSource turns a command back into export.php's request array and
gunzips the streamed response. RelayImportDriver re-enters the real importer
per exchange (fresh client, like an exit-2 resume) until it yields or
completes.
tests/Relay/ReverseTransportPullTest.php runs the real files-pull against the
real export.php over the reverse channel and mirrors a source tree to the
fs-root byte-for-byte in two exchanges (file_index then file_fetch). The source
export runs in a subprocess (the exporter tears down output buffering to stream,
so it can only be captured from a real output stream); production would loopback
into the source's export.php instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RelayExchange works in arrays; a real relay_exchange HTTP hop is JSON. Every value is already JSON-safe except the delivered result's body — raw gunzipped multipart bytes — so RelayExchangeEndpoint carries it base64-encoded as result.body_b64 and decodes it back for RelayExchange. handle_json() is the remote body handler; static encode_request()/decode_response() are the source worker's side, so the contract lives in one place. ReverseTransportWireTest runs the same real files-pull as the pull test, but the worker↔endpoint boundary now crosses JSON strings via RelayExchangeEndpoint — proving the wire format end to end, including the binary-body base64 the in-process path hid. The source tree still lands on the fs-root byte-for-byte. Remaining (documented in REVERSE-TRANSPORT.md): the HMAC route + relay-source CLI that carry these JSON strings over curl, the loopback export client, and db-pull (same seam, needs a live MySQL to verify). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ngerprinting The mechanism was over-built. Collapsing it, with the same real files-pull still green end to end over a JSON wire: - Drop request/response fingerprinting (command_id/md5) and the build_url cache-buster special-case. The exchange is strictly sequential and the importer resumes deterministically, so "the delivered result answers the first request on re-entry" holds without matching — a single consumed flag is enough. - Merge the three remote classes (RelayImportDriver + RelayExchange + RelayExchangeEndpoint) into RelayExchange::handle_json: decode JSON, re-enter the real importer until it yields or completes, encode JSON. - Merge the two source classes (RelaySourceWorker + RelayExportSource) into RelaySource: the outbound loop plus command->request translation and gunzip. - Keep RelayTransport: it holds the one delivered result and a consumed flag that must survive the fresh-client re-entry loop (moving it onto the recreated client would let the second pass re-consume the result at an advanced cursor). This is the one abstraction that earns its keep; documented as such. - Collapse the demo + array-level pull + wire tests into one real files-pull over the JSON boundary (ReverseTransportTest). Relay lib 474 -> 262 lines (8 -> 5 files incl. load), relay tests 644 -> 174 lines (3 -> 1 test). import.php back to 504/74 PHPCS, direct mode untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mechanism is small enough now that the class docblocks carry the design: the sequential-exchange rule and the why-RelayTransport-exists rationale live on RelayTransport, the endpoint contract on RelayExchange, and the security posture on RelaySource. A separate design document would just drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answers "put the network handling in the transport and call to that": the
four relay_* marshalling methods (~125 lines) leave ImportClient and become
RelayTransport's own fetch_json()/fetch_streaming(), mirroring the client's
two fetch shapes. The client's relay branches shrink to delegation:
if ($this->relay_transport !== null) {
return $this->relay_transport->fetch_json($url);
}
The curl path stays inline rather than moving behind the same interface: it
is welded to client state (HMAC signing, tuner error hooks, spinner/heartbeat
progress, curl-state diagnostics, audit log), so extracting it would relocate
~450 battle-tested lines and expose a pile of private internals to make an
interface with exactly two implementations. The two-line guard is the honest
cost; extract the curl side if a third transport ever appears.
net: import.php -130 lines of relay logic; RelayTransport is self-contained
(deliver-or-yield, command building, boundary recovery, multipart parse).
Same e2e test green; import.php PHPCS unchanged (504/74).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The class is the importer-side piece of the reverse transport, and the name should say which mode the guard switches on. Property, setter, and file name follow (reverse_transport / set_reverse_transport / class-reverse-transport). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RelaySource treated any non-JSON exchange response the same as "done" — over
real HTTP, a 502 page or timeout garbage would end the worker mid-transfer
looking successful. Now the wire has three statuses (done | command | error)
and anything else throws:
- RelayExchange::handle_json never throws; a non-yield importer failure comes
back as { status: "error", message } so the worker can tell a remote failure
from transport garbage.
- RelaySource throws on the error status (carrying the remote's message) and
on malformed responses. It never re-sends a result: the remote's persisted
cursor is the recovery mechanism — a rerun starts with no result and the
remote re-asks. Re-sending could hand a stale result to a newer request
(results carry no ids to match), so the docblock also warns the future HTTP
glue off blind retries.
Tests: a crash-resume case (worker dies before delivering the fetch result; a
fresh worker resumes from the remote's state and finishes byte-for-byte) plus
the two failure modes, all against the real importer/exporter where relevant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RelayTransport had become ReverseTransport while its siblings kept the relay name — two words for one mechanism. Now the family is uniform: RelayExchange -> ReverseTransportEndpoint (the remote side) RelaySource -> ReverseTransportWorker (the outbound-only local side) lib/relay/ -> lib/reverse-transport/ tests/Relay/ -> tests/ReverseTransport/ (suite "Reverse Transport Tests") Docblocks, error messages, temp-file prefixes, and the test namespace follow. No behavior change; suite green, import.php PHPCS unchanged (504/74). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
handle_json said how the payload is encoded, not what the method does. The renames make each name carry its object: ReverseTransportEndpoint::handle_json -> handle_exchange ReverseTransport::deliver -> serve_delivered_result ReverseTransport::build_command -> build_export_command ReverseTransport::extract_boundary -> extract_multipart_boundary ReverseTransportWorker::execute -> execute_export_command ReverseTransportWorker::build_request -> build_export_request ReverseTransportWorker::$exchange -> $send_exchange_request ReverseTransportWorker::$run_export -> $run_export_request fetch_json/fetch_streaming keep their names on purpose — they are the transport counterparts of ImportClient::fetch_json/fetch_streaming and the guard there delegates 1:1; the docblocks now state that parity. Worker::run() gains @param/@throws docs; the test's $exchange variable (which held the endpoint) becomes $endpoint, and its put() helper becomes writeFile(). No behavior change; suite green, PHPCS sniff set unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… whole The wire embedded the result body as base64 inside the exchange JSON, which forced whole-body buffering several times over on both sides (raw + base64 + JSON string), and fetch_streaming() fed the parser one giant string. Fine for commands; wrong for file_fetch results, which can be many megabytes — the direct curl path streams all of this. The result now travels as the raw request body and stays a stream end to end: - ReverseTransportWorker ships the export output exactly as the engine produced it (typically gzip), spooled to a temp file by the export runner — never a PHP string. The gunzip moved off the worker entirely. - ReverseTransportEndpoint takes (resource|null $result_stream, int $result_http_code) — the HTTP body and a header, once the glue exists — instead of a JSON string. - ReverseTransport::fetch_streaming() reads the stream in 64 KB chunks, inflating incrementally when gzipped (the way curl decodes Content-Encoding: gzip), and feeds the multipart parser per chunk. The only buffered prefix is up to the first newline, to recover the boundary. fetch_json() still reads whole — JSON endpoint responses are small. The command (response) direction stays small JSON. The one JSON-embedded payload that can reach megabytes is the inlined file_fetch batch list, bounded by the batch size and equal to what the direct path holds when signing. New regression test: transferring a 24 MB file (gzip-window-defeating content, so the wire really carries ~24 MB) must grow peak memory by less than 8 MB; byte-identity checked by hash so the assertion itself stays out of memory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orker" "Worker" reads as a managed process from a pool; this is just the source side of the exchange — the site that has the data, dialing out and answering export commands. "Source" is also the codebase's existing word for that role, so the pair is now ReverseTransportEndpoint (remote) / ReverseTransportSource (local). Docblocks, error prefixes, and the tests follow; the test's $source string property becomes $sourceRoot to free the name for the object. No behavior change; suite green, PHPCS sniff set unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings from the reverse-transport pass, cleanup tier:
- Don't create the AdaptiveTuner when the reverse transport is set. The tuner
adapts request size and paces the dialing client with duty-cycle sleeps
between its own requests; over the reverse transport the remote is not the
dialer — its requests are answered inside the source's exchange calls, where
the pacing usleep (finalize_tuned_request) would only hold the exchange
open. Untuned means get_tuned_params sends no knobs and the exporter's
server-side defaults rule, exactly like --no-adaptive.
- Delete the guard's response_stats zeroing: StreamingContext initializes the
array, every completion handler overwrites it wholesale, and the tuner never
read ttfb/total_time — dead code with a false comment (and moot now).
- Build the wire command lazily: serve_delivered_result takes a builder
callable invoked only on the yield path, so the consume pass no longer
re-reads and base64-encodes the whole file_fetch batch list just to throw
it away.
- Fix the test teardown leak: rmrf used glob('*'), which skips dotfiles — the
state dir is full of .import-* files, so rmdir failed silently and every run
leaked temp dirs. Replaced with the suite's scandir-based recursiveDelete
idiom; verified a full suite run now leaves zero reverse-transport-* dirs.
Suite green (5 tests), import.php PHPCS unchanged (504/74).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The review's correctness findings, fixed by parity with the curl path — same messages, same shapes — plus loud refusals for the two paths whose checkpointing can't yet uphold the reverse transport's resume invariant. Transport validation (was: silent success / silent truncation): - A 200 body that never yields a multipart boundary now throws the curl path's "Invalid response: missing multipart boundary" (with a body snippet); the guard enforces "missing completion chunk from server" after parsing, exactly as the curl tail does. A deterministically-failing source now aborts on the next exchange instead of re-running its export up to max_exchanges times. Message parity also revives the sql_chunk crash recovery, which classifies retryable failures on those exact strings. - fread()/inflate_add() false returns throw instead of being cast to ""; corrupt gzip fails like curl's CURLE_BAD_CONTENT_ENCODING rather than truncating. The gzip sniff reads exactly two bytes so a short first read on a socket-backed stream cannot misclassify the encoding. Error diagnosis (was: generic message, server body discarded, wrong types): - Non-200 results throw ReverseTransportHttpError carrying the (capped, gunzipped) error body. The ImportClient guards convert it with the same diagnosis the curl path uses: fetch_json returns curl's exact non-200 shape (json=null, error_code — a 500 error body can no longer pass the preflight "payload !== null" gate), and fetch_streaming calls the diagnose-and-throw block now extracted from the curl tail into throw_diagnosed_stream_error() (shared, so the "HTTP error 4xx" shapes callers classify on are identical, and the server stack trace survives). Checkpoint-invariant refusals (was: silent livelock): - ReverseTransportEndpoint accepts only commands that persist a checkpoint before every export request (files-pull); preflight and the composite pull re-run multi-request sequences from scratch on re-entry and would pair results with the wrong request. - run() refuses follow_symlinks over the reverse transport: symlink discovery issues follow-up file_index requests with no sub-stage checkpoint. Refused loudly rather than silently skipping symlinks. - handle_exchange caps re-entry passes so a no-progress loop fails the exchange instead of spinning inside one HTTP request. Seven new tests cover each failure mode: garbage-200, truncation, corrupt gzip, non-200-as-preflight-data, rejected commands, symlink refusal, and the pass cap. import.php PHPCS unchanged (504/74). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Closing after the design pivot in #315: push stays local-driven — the remote is a passive authenticated API, no polling loop, no remote-owned cursors. This reverse-transport experiment answered the question and lost the argument; the outbound-only constraint it solved is handled by ordinary outbound requests in the new flow. I almost went through with this one, but I couldn't make myself like the remote site requesting paths from a local site. It's a privacy and security boundary I don't think Reprint should cross. We'll do a regular push, not inverted pull. The local site will choose what to communicate and the remote site will validate it and accept it. |
What it does
Lets the ordinary pull pipeline move a site from a machine that cannot accept inbound connections (laptop, NAT, dev box) to a public remote. The remote runs the regular importer and owns all state — cursors, index, diff, file writes; the local site only ever makes outbound requests. An ordinary
files-pullcompletes end to end over this reversed channel in the test suite, mirroring a source tree byte-for-byte.In industry terms: the importer becomes a task-queue server and the source polls it over a reverse connection (the same model as GitHub Actions runners or GitOps agents), with each result piggybacked on the source's next request.
Rationale
Push previously meant building a second transfer subsystem (staging store, upload client, receiver endpoints) that reimplements what pull already does well. But the only thing actually wrong with pull for this direction is who dials whom. Inverting the transport keeps the entire tested pull pipeline — resume, delta diff, batching — and adds no new state machine:
Implementation
Three classes under
packages/reprint-importer/src/lib/reverse-transport/plus a two-line guard per choke point inImportClient:ReverseTransport— the importer-side transport. Holds the one source-delivered result as a stream and serves the importer's two fetch shapes from it:fetch_json()reads it whole (JSON endpoint responses are small);fetch_streaming()reads it in 64 KB chunks, inflating incrementally when gzipped (the way curl decodesContent-Encoding: gzip), and feeds the sameMultipartStreamParserchunk handler the curl path uses — a multi-megabytefile_fetchresult is never buffered whole. The request after the answered one throwsTransportYield(extendsErrorso it slips past the importer'scatch (Exception)wrapper), unwinding the importer back to the endpoint with the next command. No request/response matching: the protocol is strictly sequential and the importer resumes deterministically from its persisted cursor, so a singleconsumedflag is all the bookkeeping.ReverseTransportEndpoint::handle_exchange— the remote's single endpoint. One exchange banks the delivered result, advances the importer by one request, and returns the source's next instruction:The body is deliberately not JSON-embedded: base64-in-JSON would force both sides to buffer it whole. Commands are small; results are not. The endpoint re-enters the real importer (fresh
ImportClientper pass, like an exit-code-2 resume; the sameReverseTransportacross passes so the result is consumed exactly once) until it yields or completes, and never throws — importer failures come back asstatus: "error"so the source can tell a remote failure from transport garbage.ReverseTransportSource— the outbound-only loop on the source site: deliver the previous result, receive the next command, run it against the source's ownexport.php(a hostile remote can only ask for what the source already exposes), repeat. The export output ships exactly as the engine produced it (typically gzip), spooled to a temp file — never a PHP string. Any exchange failure throws; the source never re-sends a result. Recovery is the remote re-asking from its persisted cursor: a rerun starts with no result and the remote re-issues exactly the request it is missing. Re-sending could hand a stale result to a newer request (results carry no ids), so the exchange callable must not retry either.ImportClient— gainsset_reverse_transport()and a delegation guard at the two curl choke points:The curl path stays inline and untouched: it is welded to client state (HMAC signing, tuner hooks, progress heartbeats, curl diagnostics), so one interface over both paths would relocate ~450 battle-tested lines for zero behavior change. The adaptive tuner is skipped entirely in reverse mode (as with
--no-adaptive): it paces a dialing client with duty-cycle sleeps, and on the remote those sleeps would run inside the exchange handler.import.phpPHPCS unchanged (504/74). No cursor-persistence changes were needed: the importer already checkpoints at exactly the per-request boundary exit-code-2 resume uses, so a yield lands on an existing checkpoint.Failure contract (mirrors the curl path, message-for-message): a 200 body with no multipart boundary or no completion chunk throws the curl path's exact
Invalid response: …errors (which also keepssql_chunk's crash-recovery classifier working);fread/inflate_addfailures throw instead of truncating; non-200 results carry the server's error body up viaReverseTransportHttpError, and the guards run it through the samediagnose_http_errorblock the curl tail uses (extracted into a sharedthrow_diagnosed_stream_error()), sofetch_jsonreturns curl's exact non-200 shape — an HTTP 500 error body can no longer pass the preflightpayload !== nullgate. Two invariant guards refuse loudly what the checkpointing can't yet support: the endpoint whitelists commands that persist a checkpoint before every request (files-pull), andfollow_symlinksis rejected over the reverse transport (symlink discovery re-scans without a sub-stage checkpoint). A re-entry pass cap turns any residual no-progress loop into an error.Not in this PR: the HTTP glue (an HMAC-authenticated route + CLI carrying the exchange over curl), the source's real loopback into its own
export.php(tests use a subprocess because the exporter tears down output buffering to stream),db-pull(same seam viasql_chunk/db_index; needs a live MySQL to verify), and the sub-stage checkpoints that would letfollow_symlinksand the compositepull/preflightride the reverse transport.Testing instructions
ReverseTransportTestruns the real importer against the real exporter — no mocks of either:files-pullmirrors a 4-file source tree (incl. binary content) byte-for-byte in three exchanges (file_index→file_fetch→ done).malformed exchange responseinstead of finishing clean.status: "error".missing multipart boundaryinstead of re-asking forever.missing completion chunk, not a clean partial.fetch_jsonshape (json: null,error_codeset).follow_symlinksare rejected up front; a no-progress importer loop is capped, not spun.