Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Temporary ClickHouse authentication loss now suspends only authenticated
execution, not the in-memory document session** (#512 phase 2, absorbing
#502/#520/#522). A disposable, epoch-fenced execution scope coordinates
Workbench and Dashboard queries, exports, schema graphs, catalog/reference
loads, formatting, SHOW CREATE, and detached-result refreshes. Closing it
aborts local work before best-effort server cancellation with an immutable
origin and `Authorization` lease, and stale completions cannot publish into
the next authenticated epoch; stale preflight work is also rejected before
it can send a replacement epoch's credentials. The mounted shell, exact
editor/tab/result objects, drafts, dirty state, route, and unload guard survive while the
header turns red and the existing authentication controls appear inline.
Successful Basic reauthentication installs a fresh scope and reloads
connection metadata in place; its reusable inline form clears the password,
resets submission state, and hides password visibility after success, ready
for a later recovery. Inline OAuth deliberately remains unavailable until the
Phase 3 checkpoint; explicit Log out remains destructive.
Async error-body classification and config discovery are epoch-fenced; refresh
authority snapshots its epoch and rechecks it after config discovery before
token-endpoint I/O, so stale discovery cannot replace a newer auth-header
policy. Catalog, schema, reference, and docs transports share a
connection-generation abort signal: `invalidate()` aborts them synchronously,
while stale writes remain fenced.
- **Connection state now has one explicit, epoch-fenced lifecycle** (#512
phase 1). OAuth/Basic credentials, single-flight token refresh, transport
success/failure, auth loss, reauthentication, and explicit sign-out flow
Expand Down
35 changes: 28 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ module is tested with plain stubs at the per-file coverage gate.

| Module | Owns |
|---|---|
| `authenticated-execution-scope` (`app.executionScope`) | one disposable, epoch-fenced registry for authenticated operation owners; closes local work synchronously and performs best-effort remote cancellation from an immutable credential lease |
| `query-execution-service` (`app.exec`) | the shared request/stream/normalize read core + the script transport loop (retry classification, stop-on-first-failure, per-attempt `query_id`); stateless `kill(queryId)` — cancellation is caller-owned (`AbortController`s live with the owning session) |
| `connection-session` (`app.conn`) | authoritative auth + connection lifecycle (`starting` / `connected` / `refreshing` / `offline` / `auth-required` / `reauthenticating` / `signed-out`), OAuth PKCE login/refresh, Basic probing, IdP config, identity, token storage, sign-out, and **the single live `chCtx` object** (mutated in place — `authConfirmed` by `net/ch-client`, `origin` by sign-in — never reconstructed) |
| `schema-catalog-service` (`app.catalog`) | server version, schema tree, lazy columns, SQL reference/completions, entity-doc cache, `invalidate()` |
| `schema-catalog-service` (`app.catalog`) | server version, schema tree, lazy columns, SQL reference/completions, entity-doc cache; catalog/schema/reference/docs transports share a connection-generation abort signal, and `invalidate()` synchronously aborts them while generation fences reject stale writes |
| `workbench-parameter-session` (`app.params`) | `{name:Type}` analysis/prepare/gate policy, input-vs-execute hardening, enum inference, recent values; reads the live shared `AppState` slices through accessors |
| `export-service` (`app.exports`) | direct + script export behind an injectable `ExportSink` (`pickFile`/`pickDirectory`); hold-back exception inspection, `.partial` semantics, its own cancellation state |
| `query-document-session` (`app.queryDoc`) | Spec evaluation/diagnostics/dirty flags over `QueryTab`s, editor-mode policy |
Expand Down Expand Up @@ -86,17 +87,37 @@ module is tested with plain stubs at the per-file coverage gate.

Lifecycle ownership: **cancellation state always lives with the session that
owns the operation** (issue #276 rule 5) — never in the transport service.
`destroy()`/`invalidate()` are wired where a session really ends today:
`signOut` tears down the workbench session, cancels graph/export work, and
invalidates the catalog before the login screen renders. There is no
route-remount mechanism — the dashboard is its own browser tab whose closure
JS never observes — so no teardown theater beyond that.
While authentication is valid, those owners register with the current
`AuthenticatedExecutionScope`. An involuntary auth-loss transition closes that
scope exactly once: owner aborts happen synchronously, old registrations become
observationally stale, and query ids captured before each abort are cancelled
best-effort through a frozen lease containing the exact origin and complete
`Authorization` header. That cancellation path has no token refresh, retry, or
auth-loss callback.

The application shell and document session are deliberately outside the
execution scope. Auth loss therefore preserves the mounted editor, tabs,
drafts, dirty flags, navigation, and completed results while inline login
controls replace authenticated actions. In-place Basic login creates a fresh
scope for the new credential epoch and reloads connection-scoped metadata
without rebuilding the shell. Its inline form is reusable: a successful
recovery resets submission state, clears the password, and hides password
visibility, so a later loss can be recovered the same way. Inline OAuth is
deliberately unavailable until the Phase 3 checkpoint. Explicit Log out is the
separate destructive policy: it closes the scope, tears down the workbench and
Dashboard, clears credentials, and renders the signed-out login surface.

Connection lifecycle ownership follows the same rule. `ConnectionSession`
publishes one read-only signal and assigns a monotonically increasing
credential epoch whenever credentials are installed or invalidated. Refresh is
single-flight within an epoch; a late refresh or transport response cannot
write tokens, report auth loss, or repaint the replacement epoch.
write tokens, report auth loss, or repaint the replacement epoch. The network
boundary rechecks that epoch after every credential await and immediately
before each fetch attempt, so old work cannot execute under a replacement
session's credentials. This includes async HTTP error-body classification and
IdP config discovery: refresh authority snapshots its epoch, then rechecks it
after discovery and before token-endpoint I/O. A stale discovery therefore
cannot rewrite the replacement epoch's auth-header policy.
`net/ch-client` reports only successful 2xx transport settlement as connected
and rejected, non-aborted `fetch` as offline. HTTP query failures — including a
post-confirmation 401/403 — remain query outcomes, not connection state. The
Expand Down
152 changes: 152 additions & 0 deletions src/application/authenticated-execution-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { AuthenticatedCancellationLease } from '../net/ch-client.js';

export type { AuthenticatedCancellationLease } from '../net/ch-client.js';

// Per-authenticated-session coordination for cancellable work. Operation
// owners retain their own AbortControllers and query lifecycle; this scope only
// provides the common epoch fence and invokes an injected network cancellation
// seam with the credentials that were valid when closing began.

/** Network-owned, best-effort KILL QUERY seam. */
export type CancelRemoteQuery = (
lease: AuthenticatedCancellationLease,
queryId: string,
) => Promise<void> | void;

/** A caller-owned unit of authenticated work. */
export interface AuthenticatedExecutionOperation {
/** Diagnostic identity only; useful to an owner when retaining its handle. */
readonly name: string;
/** Must stop local work synchronously (normally AbortController.abort()). */
abort(): void;
/** Reads the operation's current server query id, if it has one. */
getQueryId?(): string | null | undefined;
}

/** A registration is current only while its scope remains open and retained. */
export interface AuthenticatedExecutionRegistration {
readonly name: string;
release(): void;
isCurrent(): boolean;
}

export interface AuthenticatedExecutionScope {
readonly epoch: number;
readonly closed: boolean;
isOpen(): boolean;
isCurrent(registration: AuthenticatedExecutionRegistration): boolean;
register(operation: AuthenticatedExecutionOperation): AuthenticatedExecutionRegistration;
/** Idempotently closes the epoch, aborting local work before remote cleanup. */
close(lease?: AuthenticatedCancellationLease | null): void;
}

export interface AuthenticatedExecutionScopeDeps {
/** Connection epoch this scope fences. Refresh stays within this epoch. */
readonly epoch: number;
readonly cancelRemote: CancelRemoteQuery;
}

interface RegisteredOperation {
readonly id: number;
readonly operation: AuthenticatedExecutionOperation;
}

function hasQueryId(queryId: string | null | undefined): queryId is string {
return queryId !== null && queryId !== undefined && queryId !== '';
}

/**
* Creates one disposable scope for a single authenticated connection epoch.
* `close` sets the closed latch before it invokes any owner code, so an abort
* callback that reports authentication loss or calls `close` again cannot
* restart disposal or make stale completion code current.
*/
export function createAuthenticatedExecutionScope(
deps: AuthenticatedExecutionScopeDeps,
): AuthenticatedExecutionScope {
let closed = false;
let closeLease: AuthenticatedCancellationLease | null = null;
let nextOperationId = 0;
const operations = new Map<number, RegisteredOperation>();
const registrations = new Set<AuthenticatedExecutionRegistration>();

function isCurrent(registration: AuthenticatedExecutionRegistration): boolean {
return !closed && registrations.has(registration) && registration.isCurrent();
}

function cancelRemote(lease: AuthenticatedCancellationLease, queryId: string): void {
// Both a synchronous seam failure and its eventual rejection are explicitly
// non-fatal. Cancellation has already stopped the local owner, and neither
// case may trigger refresh/retry/auth callbacks from this coordination layer.
try {
void Promise.resolve(deps.cancelRemote(lease, queryId)).catch(() => {});
} catch { /* best-effort remote cancellation */ }
}

function stop(entry: RegisteredOperation, lease: AuthenticatedCancellationLease | null): void {
// Capture before abort: several owners clear their query id while stopping.
let queryId: string | null | undefined;
try {
queryId = entry.operation.getQueryId?.();
} catch { /* an owner that cannot report its id is still locally aborted */ }

try {
entry.operation.abort();
} catch { /* one faulty owner cannot prevent other session cleanup */ }

if (lease && hasQueryId(queryId)) cancelRemote(lease, queryId);
}

function register(operation: AuthenticatedExecutionOperation): AuthenticatedExecutionRegistration {
const entry: RegisteredOperation = { id: nextOperationId++, operation };
let released = false;
const registration: AuthenticatedExecutionRegistration = {
name: operation.name,
release() {
if (released) return;
released = true;
operations.delete(entry.id);
registrations.delete(registration);
},
isCurrent() {
return !released && !closed && operations.get(entry.id) === entry;
},
};

if (closed) {
// A race-free caller may still finish setup just as auth is lost. It must
// not escape cancellation merely because registration was late.
released = true;
stop(entry, closeLease);
} else {
operations.set(entry.id, entry);
registrations.add(registration);
}
return registration;
}

function close(lease?: AuthenticatedCancellationLease | null): void {
if (closed) return;
// This assignment is intentionally first: owner abort callbacks may be
// re-entrant, but can only observe an already-stale, closed scope.
closed = true;
// The caller obtains this just before credentials are cleared. A refresh
// remains in the same epoch, so close—not scope construction—must retain
// the latest credential. A foreign epoch is never authorized to clean up
// this scope, though its local operations still need immediate abortion.
closeLease = lease?.epoch === deps.epoch ? lease : null;
const active = [...operations.values()];
operations.clear();
registrations.clear();
for (const entry of active) stop(entry, closeLease);
}

return {
epoch: deps.epoch,
get closed() { return closed; },
isOpen: () => !closed,
isCurrent,
register,
close,
};
}
Loading