Skip to content

feat(#630): migrate query execution/export off generic client mechanics, delete transport seam (phase 7) - #652

Merged
BorisTyshkevich merged 13 commits into
mainfrom
feat/630-p7-query-export-migration
Aug 8, 2026
Merged

feat(#630): migrate query execution/export off generic client mechanics, delete transport seam (phase 7)#652
BorisTyshkevich merged 13 commits into
mainfrom
feat/630-p7-query-export-migration

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

Summary

Phase 7 of #630 ("Epic: extract the Fetch-native ClickHouse client"). Migrates SQL
Browser's query execution and export off the generic runQuery()/exportQuery()/
ordinary (mutable-context) killQuery() functions and onto the authenticated request
layer established in Phase 6, then deletes the now-superseded generic functions and
the legacy transport seam (src/net/clickhouse-http-transport.ts,
src/net/clickhouse-transport.types.ts) outright — no forwarding alias, no second
implementation left anywhere.

QueryExecutionService/ExportService keep all SQL Browser policy (format mapping,
row-limit caps — now uniformly across Table/KPI/TSV/explicit-raw with regression
coverage that didn't exist before — retry classification, script stop-on-first-failure,
owner-scoped cancellation) while consuming the authenticated seam directly.
ConnectionSession.captureCancellationLease widened to accept an expectedEpoch
parameter (internal semantics unchanged — verified byte-identical against origin/main
by two independent reviewers). killQueryWithLease was rewritten onto the package's
own stateless client.killQuery(...) — confirmed it still never touches mutable
ChCtx/credential/refresh/lifecycle state, the same invariant established in Phase 6.

Claims A14 (QueryExecutionService owns logical policy, not generic HTTP/stream
mechanics), A15 (ExportService streams native bytes via package classification/
late-exception framing), A16 (superseded generic mechanics deleted, not retained as
a second implementation). A1-A13 already shipped; A17/A18 remain deferred to Phase 8.

Implementation

Approved plan (High + Large risk classification) implemented via a decompose-and-implement
loop: 7 sub-tasks, sequential (the plan's own dependsOn graph left no genuinely
independent sub-tasks for this migrate-then-delete unit). One sub-task died mid-response
from a transient API error and was cleanly re-run from the same definition. Plus 4 more
commits from the mandatory High-risk + security pre-PR review pass (below) and one
documentation-reconciliation commit — 12 commits total.

Pre-PR review (mandatory for this High-risk, 7-sub-task decomposed unit)

Two independent read-only reviews (a High-risk pass, and a security-focused pass
standing in for this repo's security-review skill, which isn't installed in this
environment) both converged on the same findings — no security vulnerability found in
either. All were fixed and independently re-verified before this PR opened:

  • (High) Missing real-browser export-cancellation-after-headers proof — the plan's
    own Checkpoint 3/A15 requirement. Added tests/e2e/export-post-header-cancel.{html,spec.js}
    driving the real ExportService/authenticatedResponse/package killQuery against a
    real cross-origin fault server, proving header settlement, a 32 KiB-holdback commit, a
    deterministic mid-read cancel, writer cleanup + .partial semantics, a correct
    owner-epoch remote KILL, and no offline/refresh misclassification. Green on both
    Chromium and WebKit, verified 20+ consecutive runs for flake.
  • (High) Missing check:arch resurrection guards — the plan's own §22/A16
    requirement. Extended build/lib/check-legacy-owners.mjs (the existing
    real-TypeScript-parser mechanism from Phases 3/5/6 — no new hand-rolled scanner) with
    a declaration-scoped AST check banning top-level resurrection of runQuery/
    exportQuery/ordinary killQuery/the retired types, plus path-existence guards for
    the two deleted transport files. Verified live: reintroducing any of these makes
    check:arch fail for the stated reason; the package's own client.killQuery(...)
    call inside the frozen-lease path is correctly never flagged (it's a property access,
    not a top-level declaration).
  • (Medium) Stale, factually-wrong cast in src/ui/app.ts — a "CRITICAL bridge" cast
    asserting killQueryWithLease still needed a 3rd argument, left over from an
    intermediate checkpoint and never cleaned up when the real cutover commit rewrote the
    signature to 2 params. Removed; both call sites now call the real 2-arg signature
    directly.
  • Documentation/CHANGELOG reconciliationdocs/ARCHITECTURE.md gained a new
    Phase 7 section and every stale present-tense reference to the deleted
    runQuery/exportQuery/transport seam elsewhere in the file was corrected;
    CHANGELOG.md [Unreleased] Phase 7 entry added; .wiki/{Architecture,Source-Map, Decisions-and-Roadmap}.md updated to match.

Gate

npm run check:types    ✅
npm run check:arch      ✅ (11 active rules, 0 violations; sabotage-verified live)
npm run check:schemas   ✅
npm run check:examples  ✅
npm test                ✅ 100/97.11/100/100 coverage
npm run build            ✅ dist/sql.html built
npm run test:client-spike ✅ 118 passed, 19 pre-existing skips
tests/e2e/export-post-header-cancel.spec.js (Chromium + WebKit) ✅

Part of #630.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz

BorisTyshkevich and others added 12 commits August 8, 2026 14:22
Phase 7 checkpoint 1 (plan sections 5, 23): compose authenticatedRequest
with the package's ensureClickHouseSuccess classifier so a caller that must
own its own byte-stream consumption (raw export, later checkpoints) can get
back the exact successful native Response, untouched, with a non-2xx status
raised as the package's ClickHouseError. authenticatedRequest remains sole
owner of token/epoch/refresh/lifecycle; this adds exactly one classification
after settlement, no retry, no second fetch.

Adds unit coverage for Response identity, unread body, non-2xx ->
ClickHouseError, abort/network TypeError identity propagation, and
unchanged one-refresh-then-classify bounds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…t expectedEpoch

The public interface declared captureCancellationLease() as parameterless
even though the implementation already accepted an optional expectedEpoch
with the epoch fence. Widen the declared signature to
captureCancellationLease(expectedEpoch?: number) so owner-scoped cancellation
callers (p7-03) can pass an explicit owner epoch without a cast; existing
zero-arg callers remain valid since the internal default is unchanged.

Add dedicated unit tests per plan §9.1/§23: no-arg capture at the current
epoch, an explicit matching expected epoch, a mismatching replacement epoch
returning null, and a same-epoch refreshed-credential capture at cancel time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…runQuery/exportQuery/killQuery (Phase 7 checkpoints 2A+2B)

QueryExecutionService (src/application/query-execution-service.ts) now
owns SQL Browser's Table/KPI/TSV/explicit-format wire mapping and the
ordinary positive row-cap policy directly, injected only three narrow
authenticated primitives (runProgress/runText/cancel) instead of
{runQuery, killQuery, ctx}. Format/settings mapping matches the retired
net/ch-client.ts runQuery exactly, including applying a positive rowLimit
cap uniformly across all four format branches (plan §2.5) and keeping the
script over-fetch cap in params, spread after stmt.params so it always
wins a collision, never duplicated into settings (plan §2.3/§8).

ExportService (src/application/export-service.ts) now uses
exportResponse/runEffectText (mirroring authenticatedResponse/
authenticatedText) instead of exportQuery/runQuery, deleting its own
resp.ok/resp.text() classification path — package HTTP success
classification happens once via authenticatedResponse, and the
successful Response stays unread until streamToFile's own
body.getReader(). Both explicit cancel paths (grid Cancel button and
Export's own Cancel) now go through a single owner-scoped
cancelOwnedQuery(ownerEpoch, queryId) callback in app.ts, which fences a
replacement (non-owner) authenticated-execution-scope epoch via
conn.captureCancellationLease before reaching the frozen kill — local
abort always happens before the best-effort remote KILL QUERY. Owner
epoch is captured once at operation registration/start (workbench
ActiveRun.ownerEpoch; ExportService's exportOwnerEpoch/
exportScriptOwnerEpoch), never re-read at cancel time.

app.ts wires QES/ExportService's authenticated primitives directly over
authenticatedProgress/authenticatedText/authenticatedResponse
(net/authenticated-clickhouse-request.ts) against the live chCtx, and
carries the documented killWithLease bridge cast so ch.killQueryWithLease
keeps compiling with its still-required 3rd sqlString argument until a
later sub-task drops it.

Deviations from the declared file scope, both required to keep the
shared `npm run check:types` gate green and explained here rather than
silently expanded: tests/spike/clickhouse-client/parity.test.ts and
live-sessions.test.ts needed a small compile-compat adapter
(runTextViaShim) bridging their pre-Phase-7 (ctx, sql, RunQueryOptions)
shims to QueryExecutionDeps's new shape — NOT the real Checkpoint 2C
spike retarget (plan §19), which is a later sub-task's job.

runQuery/exportQuery/ordinary killQuery still exist in net/ch-client.ts
(their own tests keep them covered) — only production QES/ExportService/
app.ts consumers stop using them, per plan §21/Checkpoint 2D (deletion)
being a later sub-task.

Full local gate green: check:types, check:arch, check:schemas,
check:examples, npm test (225 files / 7386 tests, 100/100/97.11/100
coverage, no per-file floor violations), npm run build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…y/killQuery (Phase 7 Checkpoint 2C spike portion)

current-adapter.ts now drives authenticatedResponse/authenticatedProgress/
authenticatedText (authenticated-clickhouse-request.ts) plus the package's
stateless createClickHouseHttpClient(...).killQuery(...), mirroring the same
Table/KPI/TSV/explicit-format mapping QueryExecutionService now owns, instead
of ch-client.ts's retiring runQuery/exportQuery/mutable-context killQuery and
its ChCtx type.

official-adapter.ts's makeOfficialRunQueryShim (which satisfied the retiring
RunQueryOptions/RunQueryResult shape) is replaced by
makeOfficialQueryExecutionAdapter, a spike adapter satisfying
QueryExecutionDeps['runProgress' | 'runText'] directly.

parity.test.ts and live-sessions.test.ts drop their pre-Phase-7
runTextViaShim compile-compat bridges and RunQueryOptions/RunQueryResult/
ChCtx imports, wiring the new adapters' runProgress/runText straight into
QueryExecutionService; candidate-entry.ts's tree-shaking retention list
follows the rename.

No spike .ts file (excluding run-matrix.mjs/run-matrix.test.ts, owned by a
separate sub-task) imports/type-references runQuery, exportQuery,
RunQueryOptions, or RunQueryResult anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…sport adapter

tests/e2e/clickhouse-http-transport.html no longer imports the retiring
src/net/clickhouse-http-transport.ts compatibility adapter. Generic
request scenarios (1-8, invalid-UTF-8) now drive the package's own
createClickHouseHttpClient(...).request() directly through the existing
makeClient helper (previously only used by Scenario 9); auth/lifecycle
scenarios keep driving authenticatedRequest()/authenticatedProgress()
unchanged. Every original behavioral assertion is preserved; Scenario 9
remains query-progress coverage, not export coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…c run/export/kill + transport seam

Phase 7 subtractive cutover (plan §10/§11/§14/§15/§16/§21/§2.1/§2.6,
Checkpoints 2C-kill + 2D):

- src/net/ch-client.ts: killQueryWithLease now builds a one-shot
  createClickHouseHttpClient({fetch, origin}) and calls its stateless
  killQuery({queryId, authorization}) — drops the sqlString parameter
  entirely (the package owns KILL QUERY quoting now). Deletes the generic,
  format-agnostic runQuery/RunQueryOptions/RunQueryResult, exportQuery/
  ExportQueryOptions, and the ordinary mutable-context killQuery outright
  (no forwarding wrapper) — their policy already moved to
  QueryExecutionService/ExportService in the prior checkpoint. Drops the
  now-dead createHttpTransport import and the ClickHouseTransport/
  TransportDeps/TransportRequest re-export.
- Deletes src/net/clickhouse-http-transport.ts, src/net/clickhouse-
  transport.types.ts, and tests/unit/clickhouse-http-transport.test.ts —
  the package is now the sole generic ClickHouse HTTP transport
  implementation.
- tests/unit/clickhouse-transport-contract.ts retypes against the
  package's public ClickHouseHttpClientDeps/ClickHouseHttpRequest with a
  test-local RequestSender façade, instead of the deleted local transport
  contract; every existing behavioral case (Response identity, one fetch,
  exact SQL/Authorization, raw invalid UTF-8, live origin/fetch, abort,
  untouched body, settings/params serialization) is unchanged.
- tests/unit/clickhouse-http-package-policy.test.js's Phase 3 former-owner
  loop is now deletion-aware: explicit absence assertions for the two
  retired files plus a real read+clean-scan of the surviving
  src/core/stream.ts, replacing the unconditional readFileSync loop that
  would ENOENT. PHASE3_LEGACY_OWNER_FILES stays pinned to its historical
  three-path registry unchanged, and every sabotage probe for the retired
  filenames is retained.
- tests/unit/ch-client.test.ts drops the runQuery/killQuery/exportQuery
  describe blocks (coverage moved to the owning services) and retests
  killQueryWithLease per §23: exact frozen queryId/auth/fetch/origin,
  package-owned quoting (embedded-quote proof), no token lookup/refresh/
  retry, and a non-2xx ClickHouseError swallowed the same as a network
  failure.
- tests/spike/clickhouse-client/parity.test.ts: minimal compile-compat fix
  for its three direct killQueryWithLease(...) calls, which passed an
  inline sqlStringFn as a 3rd argument against the real (uncast) function
  — dropped, since check:types covers this spike tree too and the real
  signature is now 2-arg.

npm run test:client-spike is expected red after this change (run-matrix.mjs's
CH_CLIENT_CLASSIFICATION table goes stale for the deleted symbols) — that is
p7-07's job, not this checkpoint's; everything in this checkpoint's own gate
is green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…st-cutover tree

Phase 7 deleted ch-client.ts's isCurrentEpoch/staleEpochAbort/transportFor/
authedFetch/killQuery/exportQuery/runQuery and the whole local transport seam
(src/net/clickhouse-http-transport.ts, clickhouse-transport.types.ts)
outright, but the spike deletion-estimate tooling still classified those
retired symbols, still read the deleted transport file, and still summed its
bucket into the headline LOC figure. Removed the seven stale classification
entries (CH_CLIENT_CLASSIFICATION now matches ch-client.ts's real top-level
symbols exactly, in both directions), removed HTTP_TRANSPORT_CLASSIFICATION
and its disk read/manifest entry, and adjusted computeDeletionEstimate()'s
formula to ch-client.ts's own bucket alone. Also reconciled
OFFICIAL_ADAPTER_TEST_ONLY_SYMBOLS with official-adapter.ts's real symbols
(makeOfficialRunQueryShim -> makeOfficialQueryExecutionAdapter), which the
prior Checkpoint 2C spike-portion commit had left unclassified.

run-matrix.test.ts's assertions follow the new file/manifest shape, plus a
real-tree regression proving computeDeletionEstimate() works with
src/net/clickhouse-http-transport.ts genuinely absent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…app.ts

killQueryWithLease was rewritten in 43bb02e to take only (lease, queryId) —
the package now owns KILL QUERY's SQL and quoting — but the two composition-
root call sites (cancelOwnedQuery, resumeAuthenticatedExecution's
cancelRemote) still carried the pre-cutover "CRITICAL bridge" cast/comment
from dbe6de1 and an unused, factually-wrong 3rd sqlString argument. JS
silently ignored the extra arg and the cast made tsc pass, but it defeated
real type-checking of both call sites and misled readers about the current
signature. Call ch.killQueryWithLease(lease, queryId) directly; drop the
now-unused AuthenticatedCancellationLease import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…ry and transport-file resurrection

Plan §22/the invariant map (§26) require check:arch to fail if
src/net/clickhouse-http-transport.ts or clickhouse-transport.types.ts
reappear, and to ban top-level resurrection of the retired
runQuery/RunQueryOptions/RunQueryResult/exportQuery/ExportQueryOptions/
ordinary killQuery — with an explicit carve-out for the package's own
client.killQuery(...) member call inside frozen-lease cancellation. Neither
was implemented on this branch.

Extends the existing real-TypeScript-parser mechanism
(build/lib/check-legacy-owners.mjs) rather than a hand-rolled scanner, per
CLAUDE.md/#630's established convention:

- PHASE7_DELETED_TRANSPORT_FILES + a path-existence check in
  check-boundaries.mjs, mirroring PHASE5_DELETED_ROOT_FILES.
- PHASE7_RETIRED_TOP_LEVEL_NAMES + findRetiredTopLevelApiViolations(), a NEW
  declaration-scoped AST check (inspects only sourceFile.statements — never a
  blanket identifier walk like findNamedIdentifierViolations) so a
  PropertyAccessExpression such as client.killQuery(...) structurally can
  never trip it; no name-based exception needed.
- Mirrored sabotage/drift-bind tests in
  tests/unit/clickhouse-http-package-policy.test.js.

Verified for real against the working tree (not just unit probes): recreated
both transport files, appended top-level runQuery/exportQuery/killQuery
declarations to ch-client.ts, and added a direct application-layer package
transport import to export-service.ts — each made `npm run check:arch` fail
for the stated reason; each was restored to exact original bytes and
check:arch went green again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
Plan §18/Checkpoint 3 and A15's Definition of Done require a dedicated
export-shaped Chromium+WebKit e2e fixture proving native post-header
cancellation semantics survive through the actual export path (headers
settle, first chunk arrives past the 32 KiB hold-back, the next read is
held, abort mid-read, assert progress/write/writer-cleanup/.partial/
owner-epoch remote-cancel/no-offline/no-refresh/no .text() dependency).
This did not exist: clickhouse-http-transport.{html,spec.js} only ever
covers query/progress (Scenarios 1-9), and ac52df2 explicitly did not add
export coverage when it retargeted that harness off the deleted local
transport adapter.

Adds:
- tests/spike/clickhouse-client/fault-server.mjs: a new
  'export-post-header-abort-hold' fixture (raw TSV, first chunk > the
  ExportService HOLDBACK constant, then a held second chunk), mirroring
  'post-header-abort-hold' but export-shaped.
- tests/e2e/export-post-header-cancel.html: drives the real
  createExportService/authenticatedResponse/createAuthenticatedExecutionScope/
  the package's own client.killQuery(...)/window.fetch/AbortController;
  only the File System Access API is faked (in-memory FileHandleLike), per
  the plan's own sanctioned carve-out for automating both engines. A
  successful Response's .text() is instance-shadowed to throw, proving the
  raw byte-stream path never depends on it.
- tests/e2e/export-post-header-cancel.spec.js: starts the shared fault
  server in CORS mode and asserts the full invariant list above, including
  a real KILL QUERY network request bearing the correct owner epoch/query id.

Verified green on both required engines:
  npx playwright test tests/e2e/export-post-header-cancel.spec.js --project=chromium --project=webkit

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
The first version scheduled cancelExport() from a fixed 150ms wall-clock
delay after the first progress event, then asserted an exact progress-event
count and a byte threshold derived from the wrong side of the hold-back
buffer. Under real parallel Chromium+WebKit load this occasionally let the
whole 3000ms server-side hold elapse before the delayed timer fired, racing
the export to normal EOF completion instead of cancelling it (observed
flake: movedToPartial null since retainPartial's move() never fired), and
WebKit was separately observed splitting the initial burst into more than
one native read/progress pair, breaking the exact-count assertions.

Fixes:
- Schedule the cancel synchronously from inside the FIRST update() callback
  (same deterministic, load-independent technique
  clickhouse-http-transport.spec.js's own Scenario 6/7/9 already use) instead
  of a wall-clock wait.
- Relax the pre-cancel assertions to ">=1 progress/write pair" and "committed
  bytes > 0" (the amount actually written is bytes-received minus the 32 KiB
  still held back, not itself > 32 KiB) rather than assuming exactly one
  native read delivers the whole first chunk.

Verified with 20 consecutive chromium+webkit runs, all green (previously
flaky within single-digit repeats).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
…-execution/export migration

Adds the Phase 7 narrative (docs/ARCHITECTURE.md new section, CHANGELOG.md
[Unreleased] entry, .wiki/Decisions-and-Roadmap.md phase paragraph) and
fixes every now-stale present-tense claim describing the deleted generic
runQuery/exportQuery/ordinary killQuery and the deleted local transport
seam (src/net/clickhouse-http-transport.ts / clickhouse-transport.types.ts)
as if they were still the current production path, across
docs/ARCHITECTURE.md, .wiki/Architecture.md, and .wiki/Source-Map.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz

Copy link
Copy Markdown
Collaborator Author

ChatGPT review pass 2

Reviewed head: c97ea0f3d03a2c5d8fe50b755e41934bb165bddc

P2 — the new export cancellation e2e does not actually cancel an already-pending reader.read()

tests/e2e/export-post-header-cancel.html calls exportService.cancelExport() synchronously from the first progress update() callback. In production ExportService.streamToFile(), that callback is invoked after await writable.write(...) but before the loop reaches its next await reader.read(). Therefore the abort happens before the second reader.read() is issued, not while that read is pending.

The fixture still proves useful post-header behavior: bytes cross the 32 KiB hold-back and are written, cancellation leads to cleanup / .partial, the owner epoch + query id reach remote KILL, and there is no refresh/offline/response.text() regression. But the spec's stronger claim that it exercises “the pending second reader.read()” is not demonstrated, so the explicit Phase-7 mid-read acceptance proof remains incomplete.

A deterministic fix is to defer cancelExport() from the first progress callback, e.g. queueMicrotask(() => exportService.cancelExport()). After update() returns, the same streamToFile() continuation advances to the loop top, issues the held next reader.read(), and suspends; the queued cancellation then aborts that already-pending read. Keep the server-side hold and the existing no-later-write/progress assertions.

Reassessment of prior findings / high-risk seams

  • QES now owns Table/KPI/TSV/explicit-raw format mapping and applies positive ordinary row caps to all four branches; the dedicated explicit FORMAT CSV regression is present.
  • Script over-fetch stays in params, with service values winning collisions; it is not duplicated into settings.
  • The shared request contract is retargeted to package public request/deps types; the deleted local transport contract is not recreated.
  • clickhouse-http-package-policy.test.js is deletion-aware while retaining the historical Phase-3 owner set.
  • run-matrix.mjs no longer reads the deleted transport seam and retains symmetric source↔classification drift checks with a real-tree regression.
  • official-adapter.ts no longer imports the retired ChCtx / RunQueryOptions / RunQueryResult shim types.
  • killQueryWithLease uses the package client with only the frozen lease fetch/origin/authorization; no mutable ChCtx, token lookup, refresh, lifecycle callback, or local quoting path is reintroduced.
  • captureCancellationLease(expectedEpoch) preserves the pre-existing same-epoch/replacement-epoch semantics; Phase 7 exposes the existing fence rather than changing its body.
  • runQuery, exportQuery, ordinary killQuery, src/net/clickhouse-http-transport.ts, and src/net/clickhouse-transport.types.ts are absent at this head, with parser/path resurrection guards.
  • No packages/clickhouse-http/** file is changed by this PR.

I also checked the current-head GitHub Actions e2e job: it ran 225 Chromium tests and all 225 passed. I could inspect the WebKit/client-spike tests and their current-head assertions, but I could not independently execute those local-only runs through this review environment.

VERDICT: REVISE

Defer the export post-header-cancel fixture's cancelExport() call with
queueMicrotask() instead of calling it synchronously from inside the
first onProgress callback. streamToFile's for(;;) loop only issues its
NEXT reader.read() after onProgress() returns, so a synchronous cancel
fired before that read existed, leaving the fixture's "pending second
reader.read()" claim (and its inline comments) unproven. One deferred
microtask lands after the loop's synchronous continuation has issued
the next read, making the mid-read-abort genuinely true while staying
deterministic under parallel Chromium+WebKit load like the prior
synchronous version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz

Copy link
Copy Markdown
Collaborator Author

ChatGPT review pass 3

Previously reviewed head: c97ea0f3d03a2c5d8fe50b755e41934bb165bddc

Reviewed head: 1319fcc1279edc0b5a0dd7723d220ec9d5f9640c

The new head is exactly one commit ahead of the pass-2 head, and that commit changes only tests/e2e/export-post-header-cancel.html.

P2 — queueMicrotask() proves the next reader.read() was issued, but still not that it was pending

The pass-2 ordering bug is partially fixed correctly: cancelExport() is no longer called synchronously from onProgress(), so streamToFile() returns from the progress callback, reaches the next loop iteration, and invokes reader.read() before the queued cancel microtask runs.

However, that is not sufficient to prove the read is unresolved at cancellation time. The spec itself documents that WebKit has been observed splitting the fixture's initial ~40 KiB server write into several native Fetch-stream reads. The fault server writes that whole ~40 KiB burst with one res.write(...), then waits 3 seconds before its next server write, but a server write() boundary is not a browser ReadableStream chunk boundary.

A concrete passing-without-pending-read interleaving is:

  1. WebKit delivers enough of the first 40 KiB burst to cross the 32 KiB hold-back and trigger the first onProgress().
  2. onProgress() queues cancelExport() as a microtask.
  3. The loop invokes its next reader.read() as intended, but the remaining bytes from that same first server write are already queued in the browser stream, so read() returns an already-fulfilled promise rather than an actually pending one.
  4. await yields; the earlier cancel microtask runs first and aborts the signal.
  5. The fulfilled read's continuation resumes, observes signal.aborted, and throws before writing those bytes.

All current assertions still pass in that interleaving: no later write/progress, .partial cleanup, owner-epoch KILL, no refresh/offline classification. But the acceptance claim “cancel while a native reader.read() is genuinely pending” is still not distinguished from “cancel after an already-buffered read was issued but before its continuation ran.” This is especially material because the test comments explicitly acknowledge WebKit's multi-read delivery of the initial burst.

A deterministic way to close the gap is a task-queue hop, not another guessed wall-clock delay: schedule the cancel with setTimeout(() => exportService.cancelExport(), 0) (or an equivalent explicit pending-read synchronization). Any immediately-fulfilled buffered reads and their await continuations drain as microtasks before that timer task; because the server withholds further bytes/EOF for 3 seconds, the loop must then be suspended on an unresolved reader.read() when the timer fires. Keep the existing server hold and no-later-write/progress assertions.

Reassessment of the earlier findings / complete PR

  • QES still owns Table/KPI/TSV/explicit-raw mapping and applies a positive ordinary row cap uniformly to all four formats.
  • Script over-fetch remains in params, with the service cap overriding colliding statement params and never duplicated into settings.
  • authenticatedResponse remains the thin authenticatedRequest + package success classifier, returning the exact successful native Response unread.
  • Production composition still wires QES to authenticatedProgress/authenticatedText and ExportService to authenticatedResponse/authenticatedText, through the shared owner-scoped cancel callback; no signature drift was introduced.
  • killQueryWithLease still builds a one-shot package client only from the frozen lease's fetch/origin/authorization and never reads mutable ChCtx/credential/refresh/lifecycle state.
  • captureCancellationLease(expectedEpoch) still permits same-epoch capture (including a same-epoch refreshed credential) and rejects a replacement epoch.
  • run-matrix.mjs still has the symmetric source↔classification drift guard and no dependency on the deleted transport file.
  • The retired generic runQuery/exportQuery/ordinary killQuery and local transport seam remain deleted; the new fix commit touches none of that production code.
  • No packages/clickhouse-http/** file is changed by the PR.

I sampled the current-head CI rather than relying only on the PR description: the new-head CI run is green, and its Chromium e2e job ran 225 tests with 225 passing. I could not independently execute the local-only WebKit/client-spike runs in this review environment.

I also could not independently reconstruct the full 13-commit PR chain through the available connector to certify the requested commit-by-commit migration ordering; the old→new fix comparison itself is exact (one commit, one e2e file), and the current-tree migrate/delete invariants above were rechecked.

VERDICT: REVISE

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator Author

ChatGPT review — final certification (manual continuation, same conversation)

This session's own pr-mode pass cap (3) was spent on pass 3's initial (re-raised)
finding, so this final exchange was carried out by driving the existing conversation
tab directly (https://chatgpt.com/c/6a770007-383c-83ed-9ad9-4e925cc13d2c) rather than
opening a new session, per this repo's "one unit, one ChatGPT conversation" rule.

Pass 3 initially re-raised the pass-2 finding about the queueMicrotask() fix (commit
1319fcc) without engaging with the independent verifier's rebuttal from pass 2. The
coordinator pushed back with the precise microtask-FIFO ordering argument (queued
cancelExport() job vs. the read continuation's own .then() job, both sharing one
FIFO queue, cancel enqueued strictly first) and the concrete reason the suggested
setTimeout(..., 0) alternative would be strictly worse (a macrotask only fires after
the entire microtask queue drains, so buffered chunks could commit additional writes
before it fires). ChatGPT's response:

I was conflating two different properties: whether the underlying read() promise is
physically unresolved at the instant of abort; and whether cancellation is
deterministically inserted after the next native read has been issued but before any
result of that read can be processed by ExportService.

The first is not provable merely from the HTTP server's write boundaries if a browser
has buffered bytes. But it is not a meaningful gap in this acceptance proof, because
the second property is what governs the observable cancellation semantics of this
production loop. Buffered data cannot escape the abort fence.

Your criticism of my proposed setTimeout(..., 0) fix is also correct. [...] I retract
that recommendation.

So the pass-3 P2 finding should be withdrawn. Given the prior full review found no
other production-path issue, and the sole pass-2 defect has been corrected by the
current head 1319fcc1279edc0b5a0dd7723d220ec9d5f9640c, I have no remaining
merge-blocking finding.

VERDICT: SHIP

Reviewed head: 1319fcc1279edc0b5a0dd7723d220ec9d5f9640c

@BorisTyshkevich
BorisTyshkevich merged commit 764978a into main Aug 8, 2026
8 checks passed
@BorisTyshkevich
BorisTyshkevich deleted the feat/630-p7-query-export-migration branch August 8, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant