Skip to content

fix: X-API-Key guard, ADBC connection lifetime and mTLS, and 64-bit result precision - #77

Open
lukekim wants to merge 9 commits into
trunkfrom
feat/search
Open

fix: X-API-Key guard, ADBC connection lifetime and mTLS, and 64-bit result precision#77
lukekim wants to merge 9 commits into
trunkfrom
feat/search

Conversation

@lukekim

@lukekim lukekim commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

#79 landed Search on trunk on 2026-08-01, five days after this PR opened and touching the same four files, so the feature work here is superseded and has been dropped in favour of trunk's implementation. What remains are fixes — the two this branch already carried, plus four found by an adversarial review of the surrounding code.

None of the four originate in this PR. All are live in trunk today, so they are fixed here rather than left for a release to carry.

Empty X-API-Key is not an absent one

Search and RefreshDataset set X-API-Key unconditionally, so a client with no key configured sent an empty one. Auth middleware reads that as a supplied-but-invalid credential rather than an absent one — the behaviour gospice#70 exists to allow. client.go, nsql.go, status.go and active_queries.go already guard the header; these were the only two callers that did not.

Re-authentication could kill an unrelated in-flight query

SqlWithParams read the ADBC connection pointer under adbcMu and then used it after releasing the lock, while reinitADBC closed that same connection under a different request's failure path. One expired session could therefore terminate a concurrent query — and because a RecordReader goes on streaming from the connection long after SqlWithParams returns, an already-returned reader could fail mid-iteration with a use-after-close.

Connections are now leased. A replaced connection is retired rather than closed, and closes only once the last query and reader using it has released. The lease travels with the returned reader, whose Retain/Release the wrapper mirrors.

Re-authentication also no longer triggers on StatusUnauthorized. That status means the credential is recognised but lacks permission, which a fresh handshake cannot fix — retrying on it churned the connection for nothing, and after the change above would retire one only to fail with the same error.

mTLS never reached SqlWithParams

The FlightSQL driver options carried only URI, credentials and user agent, omitting the root CA and client certificate that WithTLSRootCertificate and WithTLSClientCertificate configure. Init and every HTTP method could reach an mTLS or private-CA runtime while SqlWithParams alone failed verification. Configuring either now also forces grpc+tls://, since the material is meaningless over a plaintext connection.

Search and Nsql silently rounded 64-bit values

Their results reach callers through interface-backed maps, and encoding/json decodes an untyped JSON number into float64 — 53 bits of integer precision. A primary key of 9007199254740993 came back as 9007199254740992: not an error, just a plausible-looking ID naming the wrong row. Both now decode with UseNumber.

Breaking. Numbers in SearchMatch.Matches/PrimaryKey/Data/Metadata and NsqlResponse.Data are now json.Number, not float64. A float64 type assertion on one of those becomes json.Number plus Int64(), Float64(), or String(). Typed fields such as Score and RowCount are unaffected. Recorded in the v9.0.0 release notes; v9 is not yet tagged, so no released consumer is affected.

Not fixed: unscoped active-query endpoints

The review also flagged that ListActiveQueries and CancelActiveQuery are unscoped on every runtime release up to and including v2.1.5, so any caller with write access sees and can cancel every principal's queries.

That is real, but it is a runtime gap this SDK cannot close. A client-side guard would not be a security control — the endpoints are open to any HTTP caller regardless of what this SDK declines to do — and there is no version endpoint to gate on. Scoping landed upstream in spiceai/spiceai#12841 and needs a runtime release. The doc comments now lead with the caveat instead of trailing a sentence that says the opposite.

Tests

  • TestSearchApiKeyHeader / TestRefreshDatasetApiKeyHeader — header absent, not empty, with no key.
  • TestSearchErrorSurfacesJSONMessage — JSON and empty-body error shapes.
  • TestADBCRetireClosesOnlyWhenUnused, TestADBCLeaseIsRaceFree, TestLeasedRecordReaderHoldsConnectionOpen, TestLeasedRecordReaderBalancesRetain — connection closes only when nothing is using it.
  • TestIsADBCAuthStatusUnauthenticated reconnects, Unauthorized does not.
  • TestADBCOptionsCarryTLSMaterial — CA and client cert reach the driver, TLS scheme forced.
  • TestSearchPreservesLargeIntegers / TestNsqlPreservesLargeIntegers — 2^53+1, int64 max, uint64 max.

All are runtime-free and run in the Windows CI job. CI now also runs the lease tests under -race, which is the only thing that catches the connection-lifetime class of bug.

lukekim added 2 commits July 27, 2026 08:46
Wraps POST /v1/search, which was previously unreachable from Go without
hand-rolling the HTTP call. spice.js is the only other SDK that exposes it.

Only Text is required; Datasets, Limit, Where, AdditionalColumns and Keywords
are optional. Supplying Keywords pre-filters the embedding column with a lexical
search before the vector search, making the search hybrid.

Decodes the runtime's wire format directly, including the `_score` field name
and the data/primary_key/metadata objects the runtime omits when empty.
The error path only unpacked a JSON {"error": ...} body and otherwise reported
a bare "failed with status=400", dropping the part that says what to fix — for
example "Search cannot be run on X because it has no embeddings or full text
search indexes", which the runtime returns as plain text.

Caught by running the new search path against a live runtime.
@lukekim lukekim self-assigned this Jul 27, 2026
@lukekim
lukekim requested review from Copilot and sgrebnov July 27, 2026 18:07
@lukekim lukekim added the enhancement New feature or request label Jul 27, 2026
@lukekim lukekim added this to the v8.0.0 milestone Jul 27, 2026

Copilot AI 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.

Pull request overview

Adds first-class Go SDK support for the runtime’s POST /v1/search endpoint by introducing SpiceClient.Search plus request/response types, tests, and README documentation so Go users can perform vector, keyword, and hybrid search without hand-rolling HTTP.

Changes:

  • Introduces SearchRequest, SearchResponse, and SearchMatch wire-format types and SpiceClient.Search implementation.
  • Adds unit tests covering request encoding, method/path, response decoding (including omitted objects), validation, and runtime error surfacing.
  • Documents Search usage and behavior in the README, including hybrid search via Keywords.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
search.go Adds SpiceClient.Search and associated request/response types plus runtime error-message handling.
search_test.go Adds httptest-based unit tests for encoding/decoding, validation, and error surfacing.
README.md Documents the new Search API with examples and field behavior notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread search.go Outdated
lukekim added 3 commits July 27, 2026 15:39
…gistry

`spice add spiceai/quickstart` makes every run depend on the Spicepod registry.
That service is currently returning a body the CLI cannot unpack, so every job
fails at:

  Invalid argument: Failed to extract Spicepod archive: invalid Zip archive:
  Could not find EOCD

The runtime and the SDK are fine; only the fetch is broken. Declaring the same
dataset spiceai/quickstart provides removes the dependency.

Applies to both the Unix and WSL setup steps. Verified locally: taxi_trips
loads and is queryable within seconds (2,964,624 rows).

Mirrors spicepy#176.
An empty X-API-Key reads as a supplied-but-invalid credential to auth
middleware, which behaves differently from omitting the header. IsSpiceReady
already guards this; Search now matches.

Addresses review feedback on #77.
@claudespice

Copy link
Copy Markdown
Contributor

Heads-up: this PR looks superseded, and that is very likely the source of its conflict.

#79 ("feat: add Search for the runtime's /v1/search endpoint") merged on 2026-08-01 and changed exactly the same four files this PR does:

.github/workflows/go.yml, README.md, search.go, search_test.go

So trunk already has a Search implementation, and this branch now conflicts with it rather than adding something missing.

That duplication is our fault, not yours — #79 was opened by this bot on 2026-08-01 without checking for an existing open PR implementing the same feature, and yours predates it by five days. The same pair is still open in two other SDK repos (spice-rs#82 vs #84, spicepy#174 vs #177); I have flagged both there.

Suggestion: this PR can probably be closed — but it is worth diffing against trunk first, because if it covers anything #79 missed, that gap is now unfixed in trunk and worth a follow-up. I have not touched or closed anything here; that is your call.

krinart added a commit to claudespice/gospice that referenced this pull request Aug 19, 2026
An empty X-API-Key reads as a supplied-but-invalid credential to auth
middleware, which behaves differently from omitting the header.
Matches the fix already applied to Search in spiceai#77.
krinart added a commit that referenced this pull request Aug 19, 2026
#82)

* feat: add Nsql and NsqlGenerateSQL for the runtime's /v1/nsql endpoint

Text-to-SQL was reachable from spice.js but from no other SDK, so Go callers
had to hand-roll the HTTP call - including knowing to ask for
application/vnd.spiceai.nsql.v1+json, without which the runtime returns a bare
array of rows and drops the generated SQL.

Nsql runs the generated query and returns the rows alongside the SQL.
NsqlGenerateSQL stops after generation, so the query can be inspected, edited,
or run through Sql to get Arrow-typed results instead of decoded JSON.

Adds the new tests to the CI job that runs without a live runtime.

* fix: only send X-API-Key when a key is configured

An empty X-API-Key reads as a supplied-but-invalid credential to auth
middleware, which behaves differently from omitting the header.
Matches the fix already applied to Search in #77.

---------

Co-authored-by: claudespice <270518434+claudespice@users.noreply.github.com>
Co-authored-by: Viktor Yershov <viktor@spice.ai>
#79 landed the same Search feature on trunk five days after this branch
opened, touching the same four files, so this was an add/add conflict on
search.go and search_test.go rather than a divergence to reconcile.

Resolved in favour of trunk's implementation, which is the superset: it
takes *int / *string for Limit and Where so an explicit zero is
expressible, validates Limit >= 1, and carries a malformed-response test
this branch lacked. README.md and .github/workflows/go.yml are restored
to trunk exactly — the automatic merge had appended this branch's Search
section as a second "## Search" heading documenting a Limit signature
that no longer compiles, and had reverted the Windows CI allowlist to
its pre-#79 form, dropping TestSearch, TestNsql, TestListActiveQueries
and TestCancelActiveQuery.

Two fixes from this branch are kept, because trunk does not have them:

- Search sent X-API-Key unconditionally, so a client with no key
  configured sent an empty one. Auth middleware reads that as a
  supplied-but-invalid credential rather than as an absent one, which is
  the bug Copilot flagged here. client.go, nsql.go, status.go and
  active_queries.go all guard the header; search.go now matches them.

- The runtime reports some search failures as JSON {"error": "..."} and
  others as plain text. Trunk printed the raw body, so a JSON failure
  reached the caller as an envelope instead of a message.

Both are covered by new tests: TestSearchApiKeyHeader asserts the header
is absent, not empty, when no key is set, and TestSearchErrorSurfacesJSONMessage
covers the JSON and empty-body shapes.
RefreshDataset set the header unconditionally, so a client with no key
configured sent an empty X-API-Key. Auth middleware reads that as a
supplied-but-invalid credential rather than as an absent one, which is
the same bug just fixed in Search: client.go, nsql.go, status.go and
active_queries.go all guard the header, and these were the two callers
that did not.

TestRefreshDatasetApiKeyHeader asserts the header is absent, not empty,
when no key is set. It needs no live runtime, so it joins the allowlist
the Windows CI job runs natively.
@lukekim lukekim changed the title feat: add Search for vector, keyword, and hybrid search fix: only send X-API-Key when a key is configured, and unwrap JSON search errors Aug 21, 2026
@lukekim

lukekim commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed and resolved — thanks for catching this.

Diffed against trunk before doing anything, and #79 does cover the feature. Resolved the conflict in favour of trunk's implementation, which is the superset: *int / *string for Limit and Where, Limit >= 1 validation, and a malformed-response test this branch lacked.

The diff was worth doing — two things this branch had did not make it into #79, so they were live gaps in trunk:

  1. Search sent X-API-Key unconditionally, so a client with no key configured sent an empty one. That is exactly what Copilot flagged here, and feat: add Search for the runtime's /v1/search endpoint #79 shipped without the guard. client.go, nsql.go, status.go and active_queries.go all guard it. datasets.go RefreshDataset had the same bug, so it is fixed here too — those were the only two callers missing it.
  2. JSON error bodies were printed raw. The runtime answers some search failures with {"error": "..."} and others with plain text; trunk surfaced the envelope instead of the message.

Also had to undo two things the automatic merge did quietly: it appended this branch's README section as a second ## Search heading documenting a Limit signature that no longer compiles, and reverted the Windows CI allowlist to its pre-#79 form, dropping TestSearch, TestNsql, TestListActiveQueries and TestCancelActiveQuery. Both are back to trunk's version.

Retitled and rewrote the description to match what the PR actually does now. Net diff is 5 files, +168/-6.

Worth checking whether spice-rs#82 and spicepy#174 carry the same API-key guard their merged counterparts dropped — if the SDKs were written from the same template, that bug is likely live in all three.

…ults

Addresses four findings from an adversarial review. None originate in this
PR — all four are live in trunk — but they are fixed here rather than left
for a release to carry.

Re-authentication no longer disrupts concurrent work. SqlWithParams read
the connection pointer under adbcMu and then used it after releasing the
lock, while reinitADBC closed that same connection under another
request's failure path. One expired session could therefore kill an
unrelated in-flight query, and a RecordReader already returned to a
caller keeps streaming from the connection long after the call returned,
so it could fail mid-iteration with a use-after-close. Connections are
now leased: a replaced connection is retired rather than closed, and
closes when the last query and reader using it releases. The lease
travels with the returned reader, whose Retain/Release the wrapper
mirrors. Covered by tests under -race, which CI now runs for them.

Re-authentication no longer triggers on StatusUnauthorized. That status
means the credential is recognised but lacks permission, which a fresh
handshake cannot fix; retrying on it churned the connection, and after
the change above would retire one, only to fail with the same error.

ADBC now receives the TLS material. The FlightSQL options carried only
URI, credentials and user agent, omitting the root CA and client
certificate that WithTLSRootCertificate and WithTLSClientCertificate
configure — so Init and every HTTP method could reach an mTLS or
private-CA runtime while SqlWithParams alone failed verification.
Configuring either now also forces grpc+tls://, since the material is
meaningless over a plaintext connection. Option building is split out
into adbcOptions so it is testable without dialing.

Search and Nsql no longer round 64-bit values. Their results reach
callers through interface-backed maps, and encoding/json decodes an
untyped number into float64 — 53 bits of integer precision. A primary key
of 9007199254740993 came back as 9007199254740992: not an error, just a
plausible-looking ID naming the wrong row. Both now decode with
UseNumber, so those values arrive as json.Number with their original text
intact. This is a breaking change for callers asserting float64 on them,
recorded in the release notes; typed fields such as Score and RowCount
are unchanged.

The fourth finding — that ListActiveQueries and CancelActiveQuery are
unscoped on runtimes up to v2.1.5 — is a runtime gap this SDK cannot
close. A client-side guard would not be a security control, since the
endpoints are open to any HTTP caller regardless of what this SDK
declines to do, and there is no version endpoint to gate on. The doc
comments now lead with the caveat instead of trailing a sentence that
says the opposite.
@lukekim lukekim changed the title fix: only send X-API-Key when a key is configured, and unwrap JSON search errors fix: X-API-Key guard, ADBC connection lifetime and mTLS, and 64-bit result precision Aug 21, 2026
TestIsADBCAuthError still asserted that adbc.StatusUnauthorized triggers
re-authentication, which the previous commit deliberately changed.

The status means the credential is recognised but is not permitted to
perform the operation, so a fresh handshake with the same credential
cannot fix it. #73, which introduced the retry, describes only the
Unauthenticated session-expiry path in its title and its rationale;
Unauthorized was included alongside it without one. Now that a reconnect
retires the connection for every other user of it, retrying on a
permission error costs a live connection to arrive at the same failure.

Also covers a PermissionDenied message reaching the string fallback.
@lukekim
lukekim enabled auto-merge (squash) August 21, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants