Skip to content

feat: auto-recover Reyden Thrift connections onto the kernel backend - #523

Merged
rahuls-db merged 7 commits into
mainfrom
feat/reyden-thrift-auto-recovery
Sep 11, 2026
Merged

feat: auto-recover Reyden Thrift connections onto the kernel backend#523
rahuls-db merged 7 commits into
mainfrom
feat/reyden-thrift-auto-recovery

Conversation

@rahuls-db

Copy link
Copy Markdown
Collaborator

Description

Ports the Reyden Thrift auto-recovery feature (already in the Python driver, databricks/databricks-sql-python#948; Go port databricks/databricks-sql-go#479) to the Node.js driver.

An unconfigured connection to a Reyden / Real-Time SQL warehouse defaults to the Thrift backend, which the SQL Gateway proxy rejects with SQLSTATE KP001. This change detects that rejection and transparently re-opens the session on the KernelBackend (SEA), so no connection-parameter change is needed.

  • Detection: StatusError now carries sqlState; ThriftBackend.openSession treats sqlState === 'KP001' as the Reyden marker (matched on the SQLSTATE only).
  • Recovery: on KP001, openSession re-opens once via KernelBackend. On a double failure the kernel error is surfaced with the original Thrift rejection preserved as its cause.
  • Pinning: a process-wide ReydenWarehouseCache keyed by (host_lowercased, warehouse_id), ~6h TTL, with opportunistic eviction; a pre-check skips the Thrift round-trip for a known-Reyden warehouse. (Node is single-threaded, so no locking.)
  • Guardrail: the explicit-backend choice is honored upstream in the client — ThriftBackend is only reached on the default path, so it never overrides an explicit useKernel.

Incidental bug fix

The non-recovery paths now re-throw the original error unchanged. StatusError implements Error but does not extends it, so new StatusError(...) instanceof Error is false at runtime; the recovery catch's error instanceof Error ? error : new Error(String(error)) normalization wrapped every StatusError into Error("[object Object]"), losing its sqlState on the non-Reyden path and corrupting the double-failure cause.

How is this tested?

  • Unit tests

ReydenThriftRecoveryOrchestration.test.ts drives the real ThriftBackend.openSession (stubbing only the two leaf I/O methods): reactive recovery onto the kernel, cache pre-check skipping Thrift, cache marking, non-KP001 pass-through, and double-failure cause preservation. ReydenThriftRecovery.test.ts covers the cache, warehouse-ID extraction, and SQLSTATE capture. Full unit suite green (1287 passing); type-check, eslint, prettier clean.

Notes

  • Kernel availability: the kernel binding ships as per-platform optionalDependencies (pinned 0.2.0). If the platform's binding isn't installed, the fallback surfaces the load error — the same kernel-availability constraint the Python driver has with its optional [kernel] extra.
  • CHANGELOG: deferred to maintainers — the in-flight 2.0.0 section is a themed breaking-security release, so the target release for this feature is a maintainer call.

Related

Design: "Simplifying Reyden Onboarding on Drivers" (Option A).


This PR was created with GitHub MCP.

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Medium · 1 Low

Solid, well-tested feature port. One medium concern: the fallback KernelBackend is never close()d, so its process-global log-bridge onLevelChange listener leaks on every Reyden recovery (F1). Also a minor test-coverage gap on the cache TTL-expiry path (F2). The StatusError re-throw fix and sqlState capture look correct, and backend selection confirms ThriftBackend is only reached on the default path.

Comment thread lib/thrift-backend/ThriftBackend.ts Outdated
Comment thread lib/ReydenWarehouseCache.ts
rahuls-db and others added 2 commits September 10, 2026 00:53
An unconfigured connection to a Reyden / Real-Time SQL warehouse defaults to
the Thrift backend, which the SQL Gateway proxy rejects with SQLSTATE KP001.
Detect that rejection (StatusError.sqlState === "KP001") in
ThriftBackend.openSession and transparently re-open the session on the
KernelBackend (SEA), remembering the warehouse in a process-wide cache keyed
by (host, warehouse_id) with a ~6h TTL so later connects skip the doomed
Thrift attempt. Only the default path auto-recovers; an explicit backend
choice (routed upstream in the client) is unaffected. On a double failure the
kernel error is surfaced with the original Thrift rejection preserved as its
cause.

Also re-throw the original error unchanged on the non-recovery paths:
StatusError implements Error but does not extend it, so the previous
`error instanceof Error ? error : new Error(String(error))` normalization
wrapped every StatusError into Error("[object Object]"), losing its sqlState
and the double-failure cause.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>
- ThriftBackend: track the KernelBackend(s) created for Reyden (KP001) fallback
  and close them in close(), so the process-global log-bridge onLevelChange
  listener installed by connect() is released instead of leaking on every
  recovery. Add a createKernelBackend() seam so tests can inject a fake without
  the native binding, plus a test that close() releases the fallback backend.
- ReydenWarehouseCache test: add a TTL-expiry test (sinon fake timers) covering
  the 6h boundary and opportunistic eviction on access.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Medium · 1 Low

Solid, well-tested port of the Reyden Thrift→SEA auto-recovery. Detection/recovery/cache logic and the StatusError instanceof bug fix all look correct. One Medium around per-session accumulation of fallback KernelBackends, plus a Low about blind cause overwrite.

Comment thread lib/thrift-backend/ThriftBackend.ts Outdated
Comment thread lib/thrift-backend/ThriftBackend.ts
- ThriftBackend: reuse a single fallback KernelBackend across all Reyden (KP001)
  fallback sessions on the connection instead of constructing one per openSession.
  connectionOptions are fixed after connect, so it is created + connected once
  (lazily, memoized; the attempt is cleared on connect failure so a later open can
  retry) and released in close() — this stops per-session accumulation of backends
  and process-global log-bridge listeners.
- On the double-failure path, only set the kernel error's `cause` when it is absent,
  so a cause the kernel error may already carry is not clobbered.
- Test now opens two fallback sessions and asserts a single KernelBackend is created
  and connected once, reused for both, and closed once on close().

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good — the connect(options) change matches the IBackend contract, sqlState is a real TStatus field, and the KP001 detection / cache / single-reuse fallback logic is coherent and well-tested. One low-severity resource-lifecycle edge case: a close() that races an in-flight fallback kernel connect can orphan a KernelBackend (and its process-global log-bridge listener).

Comment thread lib/thrift-backend/ThriftBackend.ts
Comment thread lib/errors/StatusError.ts
this.name = 'Status Error';
this.message = status.errorMessage || '';
this.code = status.errorCode || -1;
this.sqlState = status.sqlState;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems concerning that node driver never looks into sql state before 😬

/**
* Mark a warehouse as being Reyden (KP001 rejection detected).
*/
public markReyden(host: string, warehouseId: string): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any reason why we don't sweep for expired keys here like Python and Go?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm.. we should

markReyden only added the new entry, so the per-key lazy eviction in
isKnownReyden never reclaimed an entry that was marked and then never
looked up again — it would persist for the life of the process. Sweep
expired entries when marking a warehouse; markReyden runs only on an
actual Thrift rejection, so the sweep is near-free and bounds the cache
to warehouses seen within the TTL window. Adds a fake-timer test.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good — the auto-recovery flow is well-structured and thoroughly unit-tested; the sqlState capture, KP001 detection, cache keying/TTL, and useKernel guardrail all check out against the surrounding code. One low-severity lifecycle edge: close() can miss a fallback KernelBackend whose connect is still in flight, leaking its process-global log-bridge listener.

Comment thread lib/thrift-backend/ThriftBackend.ts Outdated
// Release the process-global log-bridge listener held by the Reyden-fallback KernelBackend.
// DBSQLClient owns the rest of the connection lifecycle and clears its own state
// (connectionProvider, authProvider, thrift client) after this returns.
if (this.fallbackKernelBackend) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — close() releases the fallback backend only when this.fallbackKernelBackend is already assigned. But getFallbackKernelBackend() sets this.fallbackKernelBackend inside the async IIFE, after await kernelBackend.connect(...) resolves. If close() runs while a fallback connect is still in flight (e.g. openSession() triggered recovery and hasn't resolved when the client is torn down), this.fallbackKernelBackend is still undefined, so close() skips it. The pending connect then completes, installs the process-global kernel log-bridge listener, and assigns this.fallbackKernelBackend on a backend nobody will ever close() — leaking that listener for the life of the process.

Consider awaiting/closing the in-flight attempt as well, e.g. if (this.fallbackKernelBackendConnect) { const kb = await this.fallbackKernelBackendConnect.catch(() => undefined); await kb?.close(); } and then clearing both fields. Narrow window (requires close() racing an unresolved fallback connect), hence low severity.

close() released the fallback backend only via this.fallbackKernelBackend,
which getFallbackKernelBackend assigns after connect() resolves. A close()
that ran while a fallback connect was still in flight found the field unset
and skipped it; the pending connect then resolved, installed the process-
global log-bridge listener, and assigned the field on a backend nobody would
ever close — leaking that listener for the process lifetime.

Await the in-flight connect promise in close() instead of only the resolved
field, closing whatever it produces (whether already connected or still in
flight). Adds a test that calls close() during an unresolved fallback connect.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Solid, well-tested port of the Reyden KP001 auto-recovery — the guardrail (ThriftBackend only on the default path), StatusError.sqlState wiring, connect(options) signature, and the close() lifecycle for an in-flight fallback connect all check out against the surrounding code. Two low-severity notes: a write-only fallbackKernelBackend dead field (with a harmless stale-write-after-close), and that the fallback also fires for an explicit useKernel: false, which slightly contradicts the described guardrail.

Comment thread lib/thrift-backend/ThriftBackend.ts Outdated
Comment thread lib/thrift-backend/ThriftBackend.ts
After close() switched to awaiting the in-flight connect, the
fallbackKernelBackend field became write-only: assigned in the connect
IIFE and cleared in close(), but read nowhere. It also left a confusing
stale write — a close() racing an in-flight connect nulled the field
before the IIFE re-assigned it onto an already-closed backend.

Remove the field; the fallbackKernelBackendConnect promise (whose
resolved value is the backend) is the single source of truth.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Solid, well-tested port of the Reyden Thrift auto-recovery feature — the StatusError.sqlState plumbing, KP001 detection, single-fallback semantics, cause-chaining, memoized fallback-backend reuse, and the close()/in-flight-connect race are all handled carefully and covered by unit tests. The instanceof Error normalization fix for StatusError is correct and verified against Status.assert. Two low-severity notes only: a dead tri-state in the cache and a pinning/no-retry behavior worth confirming as intentional.

Comment thread lib/ReydenWarehouseCache.ts Outdated
Comment thread lib/thrift-backend/ThriftBackend.ts
markReyden was the only writer and only ever stored isReyden: true, so the
CacheEntry.isReyden field and isKnownReyden's boolean | undefined return type
carried an unreachable "known not Reyden" state. Drop the field and return a
plain boolean: an unexpired entry means Reyden, its absence means not known.
Callers already used the result in a boolean context. Update the tests that
asserted the old undefined return to expect false.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Solid, well-structured port of the Reyden Thrift→SEA auto-recovery feature with thorough mocked unit coverage (KP001 detection, cache pre-check/TTL/sweep, single-fallback-backend reuse, and the close()-races-connect leak case). StatusError.sqlState wiring is correct (TStatus declares the field), the instanceof StatusError re-throw correctly avoids the implements Error normalization trap, and the memoized fallback-backend lifecycle/close semantics look right. One low-severity note inline about the double-failure cause guard discarding the KP001 when the kernel error already has a cause. Minor: the feature ships unit-tests-only (acknowledged in the PR) — an e2e path isn't practical without a real Reyden warehouse, so that's informational, not blocking.

Comment thread lib/thrift-backend/ThriftBackend.ts
@rahuls-db
rahuls-db added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit b227e3a Sep 11, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants