date: 2026-09-10 title: 'Spice v2.3.0 (Sep 10, 2026)' type: blog authors: [phillipleblanc] tags: [release, caching, github]
Spice v2.3.0 brings performance improvements, broader query federation, and expanded data connector capabilities. The release improves cache reuse and reduces memory use for cached SQL results. It also extends BigQuery support and adds GitHub review, release, and repository data for SQL analysis. Google models now use Vertex AI, so deployments with credentials for Google AI Studio require migration.
Highlights in v2.3.0 include:
caching_max_size and caching_max_items bound what a refresh_mode: caching accelerator holdspulls columns, and a repo / owner column on every rowfrom: google model authenticates as a GCP service accountThis release improves SQL translation and function handling for accelerated and federated datasets. The following bug fixes cover string functions, NULL handling, correlated subqueries, and timezone declarations.
trim call failed on DuckDB, SQLite, and MySQL because DataFusion emitted its canonical name, btrim. DuckDB now receives trim, with an explicit space argument for the one-argument form. The native SQLite and MySQL paths evaluate btrim locally.to_hex output to match local evaluation.sha256 output into a 32-byte digest instead of a hexadecimal string.concat uses || on DuckDB to consistently propagate NULL arguments.inner_product now returns NULL for an undefined dot product on DuckDB for consistency.U or R flags) are executed locally.EXISTS shapes emitted SQL which evaluated the correlation over the whole relation, so the bound selected nothing. A semi or mark join then reported a match on a row the plan never read. Both shapes now refuse pushdown and run locally.TIMESTAMPTZ column with the connection's own timezone. The connector built its pool and never pinned that setting. A dataset's schema therefore carried the host timezone, and the same query returned different rows on different machines. A connector session is now pinned to UTC.BigQuery (opens in a new tab) federation runs more query shapes as one remote job.
STRING column now pushes down scalar json_as_text expressions and json_get(...) IS NULL checks.date_trunc is preserved, and the dialect and the federation policy now agree on which aggregate and window calls are eligible.regexp_match null checks federate safely.UNION DISTINCT. BigQuery rejects a bare UNION, so such a query failed outright. UNION ALL is unchanged.ROW_NUMBER no longer carries a window frame, which BigQuery rejects. An aggregate window function keeps its frame.array_element translates a non-negative integer literal index that fits in Int64 as . Other indexes evaluate locally. This preserves DataFusion's end-relative semantics for negative indexes.Cancellation: A BigQuery query whose client goes away now stops. The BigQuery job ends as cancelled, and the pooled connection returns immediately. Before this release the query ran to completion, and the connection stayed busy for its whole duration. Enough cancellations left an application unable to query at all.
A refresh_mode: caching accelerator had nothing bounding what it held. Retention was derived from caching_ttl plus caching_stale_while_revalidate_ttl, and only when caching_stale_if_error was disabled. A dataset that set caching_stale_if_error therefore got no policy at all, and nothing was ever evicted. Nothing capped the acceleration by size or by count either.
Two settings now bound the acceleration. Each one refuses an unparseable value rather than falling back to a default:
caching_max_size — a byte budget, such as 512MiB.caching_max_items — a row budget.caching_ttl is also accepted as caching_item_ttl, which is the spelling the SQL results, search results, and embeddings caches use. Eviction is entry-granular. A cached response can span several rows, so the runtime ranks entries by their oldest page and removes all of an entry's rows together. The storage schema is unchanged, and no existing acceleration needs a rebuild.
caching_stale_if_error now fires on the failure it exists for. It keyed off a fetch that returned an error. The HTTP connector reports a failing origin as a successful fetch whose rows carry a 429 or 5xx status once it exhausts max_retries. An operator who enabled the setting received the origin's error body instead of the cached response.
A caching accelerator that has nothing bounding it now says so at startup.
Stale results remain available across a refresh when configured. An acceleration refresh evicted every dependent SQL results cache (opens in a new tab) entry, and any successful refresh counted as a change. A refresh_mode: full update still flushed the whole per-table cache. For a workload with consistently high QPS, each refresh turned a population of cached results into simultaneous synchronous misses.
When stale_while_revalidate_ttl is configured, an invalidation now marks dependent entries stale as of the refresh instead of evicting them. Inside the stale window the runtime serves the previous result with Results-Cache-Status: STALE and starts one background revalidation per key. Past the stale window the request is a miss, exactly as before. With no stale window configured, invalidation stays hard.
Cache accounting covers more retained memory. A cache with a million empty results reported 0.09 GiB against 1.85 GiB of retained memory. Its max_size accounting omitted parts of each entry. The cache now shares schemas and input-table sets, copies foreign buffers, and accounts for per-buffer allocation overhead.
In the reported benchmark (opens in a new tab), each entry retained 1%–80% less memory across 20 combinations of result shape and source. The benchmark ran on macOS/arm64 with snmalloc. Reported size ranged from 0.65x to 1.73x of retained memory after the change. The lowest ratio before the change was 0.31x. These figures compare the fix with its merge base, not v2.2.1, and are not guarantees for every workload.
This release also corrects memory accounting for the search results and embeddings caches.
Pingora cache engine: Table invalidation read every entry with a destructive get, so an invalidation promoted every key in the cache. Scan order replaced recency, and each visited key became a momentary miss to concurrent readers. A read also served a hit destructively, so a second reader reported a miss for a key the cache holds. Both reads are now non-destructive.
The GitHub Data Connector (opens in a new tab) adds eight tables, 17 columns on pulls, and repository identity on every row. An application can now answer a code-review question in SQL. Before this release, pulls.reviews_count was a bare integer with no state and no reviewer, and pulls.review_comments recorded only inline comments.
| Path | Rows |
|---|---|
github.com/{owner}/{repo}/reviews | One per pull request review, with state, author, submitted_at, and commit_sha |
github.com/{owner}/{repo}/review_threads | One per resolvable thread, with is_resolved, is_outdated, path, and resolved_by |
github.com/{owner}/{repo}/releases | One per release, with total_download_count and assets_count |
github.com/{owner}/{repo}/release_assets | One per asset, with download_count, size, and content_type |
pulls adds is_draft, mergeable, merge_state_status, review_decision, status_check_rollup, merge_queue_state, merge_queue_position, merged_by, closed_by, base_ref, head_ref, head_sha, milestone_id, milestone_title, closing_issues_references, closing_issues_count, and reactions_count. issues adds state_reason, , , , and . Every table returns a and an column, so a multi-repository keeps its rows apart.
The connector also asks GitHub for a narrower pull request page. A 100-node page now exceeds GitHub's per-request compute budget on a large repository. GitHub rejects that page with Resource limits for this query exceeded and returns every node as null, so the dataset never loaded.
A from: google chat or embedding model now authenticates as a GCP service account against Vertex AI (opens in a new tab). Spice no longer accepts a Google AI Studio API key. See Breaking Changes (opens in a new tab) for the migration.
Other AI model fixes in this release:
claude-3-5-sonnet-latest, which Anthropic has retired, so every such request failed.top_logprobs request instead of translating it to top_k. The two fields are unrelated. top_logprobs reports log probabilities and top_k narrows sampling, so the translation silently changed the model's output.openai, xai, and spiceai are now classified from the provider's typed error fields. Each path searched the rendered error text for 401, 429, or rate, and then replaced the provider's own detail with a fixed string.from: huggingface: chat model reads hf_token again. The parameter moved to the prefix huggingface, so hf_token was warned about as unknown and a gated repository was downloaded anonymously.This release fixes bugs in search (opens in a new tab) result limits, index updates, and deletes.
vector_search(tbl, 'query', 10) against an Elasticsearch-backed index now respects the requested limit.text field. The delete now filters on a field that can match the key exactly._delete_by_query returns 2xx when the request ran, and it reports per-document failures and version conflicts in the body. Spice discarded that body, so a delete could leave documents behind and still report success.Float32, Float16, and Binary keys, and both the old and the new row stayed in the index.LIMIT on a partitioned scan: PartitionTableProvider::scan passed the scan limit as the skip argument rather than the fetch argument. LIMIT 10 over a three-row partitioned dataset returned zero rows.acceleration.enabled: false: A dataset or a view can set enabled: false and leave the rest of the block in place. The runtime read every other setting, accepted it, and then ignored it. The component reported healthy and served federated queries. The runtime now names the settings it discards.ready_state on a view: A view's acceleration.ready_state was accepted by the schema and by the parser, and then never applied. The identical key on a dataset was honoured. A view now resolves the key the way a dataset does.retention_check_enabled: true, a retention_period, and a time_column but no retention_check_interval got no retention task and no diagnostic. The builder now reports the refusal.Decimal128 on write paths: Three conversions could produce a plausible wrong number instead of an error. The sum of two in-range halves wrapped to a large negative decimal at scale 38. A float-to-int cast saturated to i128::MAX, and NaN became 0. The declared precision was never checked, so a value needing more digits than the column declares was appended anyway. All three were reachable from Debezium decimal ingestion, where the input is source-controlled. The runtime now validates the destination precision once, where every input form converges.
An Iceberg DELETE an equality key cannot express: An Iceberg delete writes an equality delete file, which removes rows whose key columns equal the given values. That statement matches the user's WHERE only when the condition reads key columns alone. A condition on a float or a nested column removed rows that did not match. Spice now refuses the statement and names the offending column.
A Parquet object overwritten mid-scan: A listing-table Parquet scan decoded two object generations as one file. The scan now pins one generation through a version id or an If-Match header. dataset_acceleration_refresh_errors carries reason=object_generation_changed|parquet_decode|other, so an expected overwrite is distinguishable from corruption.
An Arrow relabel: relabel_array_data carries an array's values across a type change untouched, and only field names and nested nullability flags may differ. Nothing enforced that contract. It now refuses three kinds of target:
service.name and service.instance.id and then dropped them. Data points from two processes were therefore indistinguishable once written. Resource attributes now reach the metric data points. The same change closes four ingest races that dropped data with no error. One of those races let a write publish through a table provider that a schema evolution had already replaced.trunk. A panicking query is now always an error.runtime.cpu.cores above the container's ceiling: runtime.cpu.cores was the one CPU entitlement setting taken raw rather than clamped. A pod configured with runtime.cpu.cores: 6 under resources.limits.cpu: 2 sized every derived pool for six cores and was then throttled. The runtime now warns and names both readings. It does not clamp, because an operator may size the runtime for a node the pod has not reached yet.TCP_NODELAY, which lowers the latency of a small response body. The Flight SQL server already set it.runtime.task_history: A proxied MCP tool call was recorded under two different task values depending on the entry point, so one logical tool split across two rows. Grouping by gave wrong per-tool counts. Both entry points now use the encoded name.spark feature.list_length pushdown: DataFusion array_length(expr) and array_length(expr, 1) now convert to Vortex list_length and push into the scan. A list length is computed from offsets, and element values are not materialized.make install-scylladb or --features scylladb builds it. A Spicepod that names scylladb: on a build without it now says the build lacks the connector rather than offering the closest registered name. The connector also declines a physical sort that CQL cannot serve.spice run resolves beside the CLI before it reaches for the managed install, and never from . A Spice Cloud project listing is attributed to the organization it was requested for. and keep the API key on its origin across a redirect. A Cloud Connect managed instance no longer warns about the default pods watcher on every .Compared with v2.2.1, this release changes the following versions:
| Dependency / Component | v2.2.1 | v2.3.0 |
|---|---|---|
| iceberg-rust | v0.10.0 | v0.10.1 |
| Rust toolchain | v1.96.1 | v1.97.1 |
DataFusion remains at v54.1.0 and Arrow remains at v58.3.0. Spice updates their fork revisions for the federation, Parquet scan, and cache fixes described above.
Google models authenticate against Vertex AI. A from: google chat or embedding model no longer accepts google_api_key. Every such model now authenticates as a GCP service account.
Update each from: google model and embedding to set google_project, google_location, and exactly one credential setting.
Before:
After:
| Setting | Description |
|---|---|
google_project | The GCP project id. Required. |
google_location | The GCP region, such as us-central1, or global. Required. |
google_service_account_path | The path to a GCP service account JSON key file. |
google_service_account_key | A GCP service account JSON key as a string. |
google_application_default_credentials | Read the key path from the GOOGLE_APPLICATION_CREDENTIALS environment variable. |
Set exactly one of the three credential settings.
Two behavior changes to note before you upgrade:
mode: file, remove retention settings, and declare a single-column primary_key for each durable write-back dataset. The runtime rejects unsupported settings at load time. Submit writes inside a transaction as one BEGIN; ...; COMMIT; request. Write-back datasets reject DELETE and TRUNCATE when you issue those statements.--features scylladb, or run make install-scylladb, to keep it. ODBC already worked this way.No new cookbook recipes.
The Spice Cookbook (opens in a new tab) includes more than 104 recipes to help you get started with Spice quickly and easily.
To upgrade to v2.3.0, use one of the following methods:
CLI:
Homebrew:
Docker:
Pull the spiceai/spiceai:2.3.0 image:
For available tags, see DockerHub (opens in a new tab).
Helm:
AWS Marketplace:
Spice is available in the AWS Marketplace (opens in a new tab).
Full Changelog: https://github.com/spiceai/spiceai/compare/v2.2.1...v2.3.0 (opens in a new tab)
SAFE_ORDINALgithub.com/{owner}/{repo}/milestones | One per milestone, with due_on and progress_percentage |
github.com/{owner}/{repo}/repo | One row of repository metadata |
github.com/{owner}/repos | Every repository an owner has |
github.com/{login}/user | The public profile of one login |
closed_byreactions_counttypetype_colorrepoownerUNION ALLunhandled error./health.refresh_check_interval. A schema repair now leaves the freshness clock alone.Map that declares its entries field nullable is a declaration no accelerator can hold. The restore path now conforms that declaration to the Arrow map layout.The third kind produced silent column-value transposition on the Delta Lake column-mapping path.
Arrow MAP columns: The Arrow map layout forbids a nullable entries field, and MapArray::try_new refuses one. Nothing enforced it at decode. A producer that declared it that way handed over a column that decoded cleanly and then failed in the first kernel that rebuilt it. The runtime now normalizes entries nullability at every Arrow decode point. A Databricks SQL Warehouse MAP column, which declares entries nullable, no longer panics the runtime. A Cayenne accelerator that has already persisted the non-conforming declaration is now repaired.
A nested nullability difference: try_cast_to decided its fast paths with Schema::contains, which permits a nested field's nullability to differ. RecordBatch requires the two types to be identical. The shared entry point now aligns the difference instead of publishing it.
A retired Vortex file: Retiring a Cayenne file released its Vortex segments and left its footer in DataFusion's file-metadata cache, which has no TTL. A file opened during retirement could also repopulate the path that retirement had just cleared. The retirement drain had no ceiling. A stalled put therefore held every caller of the invalidation, and the delete sink is one of them. All three faults are fixed.
A Cayenne teardown that deleted a shared metastore: Recreation of a Cayenne dataset could delete a shared metastore and leave other datasets unavailable after restart. The guard checked only the metastore named by that dataset's settings, not catalogs inside its data directory. Open file handles hid the loss until restart. The runtime now scans that directory before catalog changes and again before deletion. It refuses recreation if it finds a Cayenne metastore or cannot safely resolve the configured paths.
taskdata_loss count that counted a message by its body length. A batch whose body is empty still carries rows, so the count was wrong. The runtime now reads the IPC header.ScalarUDF and the TableSource it was planned against. A hot reload that redefined a SQL function or replaced a catalog left those plans in place. The same SQL then kept answering from the replaced component. The plan cache is installed unconditionally with a one-hour TTL, so no caching configuration was needed to hit this. Both handlers now discard the affected plans.spicedPATHspice queryspice nsql analyzespice runstrict() and as_mcp_proxy() to the tool it wraps. runtime-tools declares what its mcp feature needs.array_length down as Vortex list_length by @lukekim (opens in a new tab) in #13888 (opens in a new tab)