This is the third changelog since the v1.0 launch, covering nine releases (1.0.17–1.0.25). Three themes lead it: coco.timeout() for cooperative deadlines, a much cheaper commit path for large target updates, and failure isolation at three layers of the engine, so a failure reaches only the component that caused it. A set of reliability fixes follows, led by one for a permanent hang on Linux, then target-state inspection in cocoindex show, smaller additions, and two example updates.
CocoIndex builds fresh, structured context for AI from sources such as PDFs, codebases, emails, screenshots, and meeting notes, and keeps it up to date with an incremental engine. The source is on GitHub.
Why this release cycle matters
- Cooperative deadlines.
coco.timeout()puts a time budget around a piece of processing, one file or one LLM call, and every call beneath it inherits the deadline. The LiteLLM embedder and transcriber keep one clock per request, and a timeout is terminal rather than retried. - Cheaper large target updates. Declared rows are held encoded and released early, fingerprints are computed natively, the table targets skip
reconcile()for unchanged rows, and Postgres upserts and composite-key deletes use faster statements. Engine memory per declared Postgres row drops by two thirds. - Failure isolation. A batched function can raise
coco.RetryWithSmallerBatchto halve a failed batch; a merged target-sink batch is bisected along component boundaries; a failed write-transaction body fails only its caller. One poisoned row no longer rolls back the other components in its batch. - Reliability. Long updates no longer hang on Linux. Memoized components run once under concurrent same-key runs, redefining or copying a memoized function keeps its memo, memoization preserves target invalidation, and the table and graph targets apply schema changes correctly under
full_reprocess. - Target-state inspection.
cocoindex showrenders target-state paths by name, lists every tracked target state with its owner under--target-states, and reads a database directly with--db. - GPU scheduling. The GPU pool is rewritten in Rust with a fair arrival-order queue, whole-GPU reservations through
acquire_full(n), and a fix for waiters that were never woken when capacity was released from another event loop. - Smaller additions.
Environment.close(), bounded concurrent embedding requests, declarative batching and schemas in the Rust SDK, Dart and Lua splitting, and a SurrealDB connector overhaul.
Upgrade notes
Behavior changes to check if you are coming from 1.0.21 or earlier:
- LiteLLM
timeoutbounds the whole request (v1.0.22).LiteLLMEmbedder(timeout=30)used to forward a per-attempt value to litellm and retry for up to ten minutes; it now means thirty seconds end to end, not retried.LiteLLMTranscriber(timeout=...)means the same, and a per-calltimeouttotranscribe()raisesTypeError. Callers who passedtimeoutbefore re-embed once, because it has left the memo key. - Exception handlers receive the original typed exception (v1.0.23), not a
RuntimeErrorwrapping a formatted string. The traceback is no longer insidestr(exc); usetraceback.format_exception(exc)orlogging(..., exc_info=exc). - Composite-key deletes on user-managed Postgres tables cast to the declared key types (v1.0.25). A
managed_by="user"table whose key column is declaredtextbut is reallyuuidor an enum now fails on multi-column-key deletes. Declare the real type. - Target action sinks (v1.0.23). Returning a
ChildTargetDeflist from a sink callback still works with aDeprecationWarning; usefrom_fn_with_children. Equal sink callbacks must also share a type and be weak-referenceable, so aNamedTuplecallback now raisesTypeError. - Valkey needs
valkey-glide2.5.2 or later (v1.0.25).
Long updates no longer hang on Linux (v1.0.24)
Under sustained load, a long App.update() on Linux could stop making progress and never return, with every later write blocked behind it (#2426, fixing #2424). LMDB ties a write transaction to the OS thread that began it. The engine opened the transaction and then awaited the batched bodies on the multi-thread runtime, so a body that suspended could resume on another worker thread. The commit then released the writer lock from the wrong thread, the release failed silently, and the next write waited forever.
Triggering it needed a body that actually suspended inside the transaction, which took a large coalesced batch or a component with more than about 125 target states, so it surfaced in production rather than in tests. It never reproduced on macOS, where LMDB uses a different lock. Each write transaction now runs on one OS thread from open to commit, whatever a body awaits. One side effect: a batch whose callers have all been cancelled now always commits, where before it committed or rolled back depending on timing. If you run on Linux, upgrade to 1.0.24 or later.
coco.timeout(): cooperative deadlines (v1.0.17)
coco.timeout() takes a timedelta and sets an absolute deadline for everything inside the with block (#2251, addressing #2054). You set the budget once, typically in the component that owns a piece of work, and the whole call tree beneath it inherits it: @coco.fn calls, the item tasks coco.map() fans out to, and coco.use_mount() children with subtrees of their own, with no timeout parameter threaded through any of them.
from datetime import timedelta
import cocoindex as coco
from cocoindex.connectors import postgres
from cocoindex.resources.file import FileLike
@coco.fn(batching=True)
async def embed(texts: list[str]) -> list[list[float]]:
... # batched: each call inherits its caller's deadline; the shared batch body does not
@coco.fn
async def summarize(section: str) -> str:
... # inherits the caller's deadline; nothing configured here
@coco.fn
async def parse(file: FileLike) -> ParsedDoc:
... # inherits it too, through use_mount()
@coco.fn(memo=True)
async def process_file(file: FileLike, target: postgres.TableTarget[DocRow]) -> None:
# 30-second budget for this file; every call below checks it.
with coco.timeout(timedelta(seconds=30)):
parsed = await coco.use_mount(coco.component_subpath("parse"), parse, file)
summaries = await coco.map(summarize, parsed.sections)
summary = "\n".join(summaries)
vector = await embed(summary)
target.declare_row(row=DocRow(name=out_name(file), summary=summary, embedding=vector))
A per-item budget like this stays meaningful as the source grows, where a whole-run limit does not, and it applies however the app runs, including through the cocoindex update CLI. The budget is cooperative rather than preemptive: CocoIndex checks the deadline at its own checkpoints (entering a @coco.fn, coco.use_mount(), coco.check_cancellation()) and raises coco.DeadlineExceededError, a subclass of the built-in TimeoutError, once it has passed. An await of your own, such as an HTTP request or a database query, is generally not interrupted (the built-in retry helpers are the exception and cancel an in-flight attempt at the deadline), so keep the client library’s own timeout on individual I/O calls; coco.timeout() bounds the CocoIndex work between checkpoints.
What inherits the deadline
@coco.fncalls, at every depth of the tree.coco.map()item tasks, so a fan-out to a thousand items is a thousand tasks under one budget. Items are not cancelled mid-flight; each observes the deadline at its next checkpoint.coco.use_mount()children, because the caller awaits them. The child runs under the same deadline and raises at its own checkpoints; the parent seesDeadlineExceededErroronly after the child has completed or failed, so there is never a half-done child.- Nested
coco.timeout()scopes take the shorter remaining budget and restore the outer one on exit.
What does not
coco.mount()andcoco.mount_each()children, and live components. These run in the background and the caller never waits for them, so the caller’s clock must not rush them; the parent’s timeout bounds only the parent’s own wait on the handlemount()returns. To time-box each mounted child, set the timeout inside the child’s processor, asprocess_filedoes above, rather than aroundmount_each()in the parent.- Batched function bodies. One call serves many callers with different budgets, so the body runs without any of them; the calls into it still check their own clocks.
- Target writes (submit and sinks). Once processing has succeeded, target synchronization is allowed to finish rather than being torn in half. A timeout ends only the component’s own work.
Guarantees
- No partial writes. A processor run that times out applies no target writes and memoizes nothing; the next update retries it.
- Completed work stays completed. Committed components and in-progress target synchronization are never rolled back, and
coco.map()items that already finished are not cancelled. - Long loops need a checkpoint. A loop inside a processor should call
coco.check_cancellation()so it stops near the deadline rather than at the function’s return.
That also makes a whole-update budget useful for callers that are themselves time-boxed, such as a CI job or a shared cron window: with coco.timeout(timedelta(minutes=10)): app.update_blocking() indexes as much as it can in ten minutes and then raises DeadlineExceededError; everything that finished stays committed, and the next update resumes where this one left off.
Retries, and one clock per LiteLLM request (v1.0.17, v1.0.22)
Retry loops used to stop after a fixed number of attempts, an ad-hoc cap that does not correspond to time. The built-in retry logic now treats its own time limit as a nested coco.timeout() scope, so it merges with any ambient deadline and the shorter wins; no attempt starts past the limit and backoff sleeps never exceed the remaining time. Without an ambient deadline a helper’s own limit still applies: LlmPairResolver, the LLM-based entity resolver, keeps its attempt cap.
For the LiteLLM embedder and transcriber, that limit is now an explicit parameter. LiteLLMEmbedder(timeout=...) and LiteLLMTranscriber(timeout=...) take a timedelta or a number of seconds and bound one request end to end, defaulting to ten minutes (#2394, #2395). Within that bound, fast failures such as a 429, a 5xx, or a reset connection are retried with backoff. A timeout is terminal: neither retried nor split into smaller batches. A timeout has already spent its duration, so retrying it only re-spends what is left, and splitting is worse, because every sub-batch would start a fresh full budget against a backend that is already too slow. Before this change, one slow self-hosted endpoint could turn a batch of 64 into a 64 → 32 → … → 1 split tree with a fresh ten minutes at every node. An HTTP 408 response stays retryable, since it arrives fast and spends nothing.
Transcription requests are retried under the same contract; they were not retried at all before, and each attempt rebuilds the audio buffer, because litellm reads it to the end and a replayed buffer would transcribe silence. The LiteLLM docs gain a shared behavior section covering timeout, retries, and memoization for both classes (#2396).
Documentation Put a time budget on your processing. How coco.timeout() works, which work inherits a deadline and which stays isolated, deadline-aware retries, and time-boxing a whole update.Large target updates: less memory, fewer reconciles (v1.0.25)
A component that declares a million rows used to cost the engine about a kilobyte per row in Python objects until it committed, and every update called reconcile() once per row whether or not the row had changed. Four changes in v1.0.25 take most of that out. None of them changes a stored fingerprint or tracking record, so nothing re-runs after the upgrade.
Rows are held encoded and released early
The engine holds every declared target state from declaration until the component commits, and every action reconcile() returns until the sink has applied it. In the Python profile both were the Python objects themselves: a row dict of fifteen short strings and ints costs about 1.2 KB, so a component declaring 1.14 million rows held about 1.33 GiB of row dicts, and the map of declared states stayed alive through the final commit although nothing read it after pre-commit (#2469).
Plain-data values are now encoded at declaration: dicts with string keys, lists, tuples, scalars, and the standard library and numpy types all round-trip exactly, and a dict row stores its values plus one interned reference to its key tuple, so column names cost nothing per row. The declared-state map is dropped as soon as pre-commit is done. Dataclasses, pydantic and msgspec models, handles, and encodings over 64 KiB stay as objects.
Smaller costs on the commit path
- Native fingerprints.
fingerprint_object, which connectors call once per declared row at commit, and memo fingerprints now walk plain data natively instead of canonicalizing it in Python first (#2470). The result is identical to before, pinned by golden values in the tests, so existing tracking records and memo keys stay valid. Anything that is not plain data falls back to the Python canonicalizer as a whole. - Postgres upserts through
unnest. Tables whose columns are all builtin scalars, or arrays of them, are upserted with one array parameter per column,INSERT ... SELECT ... FROM unnest($1::t1[], ...) ON CONFLICT ..., 2,000 rows per statement, instead of a multi-rowVALUESlist whose statement text grew with the row count (#2471). Column types come from the catalog, read once per table, so amanaged_by="user"table whose declared types differ from the real ones keeps working. Tables with pgvector, enum, or domain columns stay onVALUES; parsing the vectors dominates either statement. - Postgres composite-key deletes. Rows with multi-column keys were deleted with an
ORchain of per-key conjunctions, which Postgres plans with time that grows quadratically in the key count; a full chunk took about a minute, so a pass deleting many rows could stall for hours. The delete now matches(c1, c2, ...) IN (VALUES ...), one join against the key list (#2458). The caveat from the upgrade notes applies: placeholders are cast to the declared key types.
Unchanged rows skip reconcile() in the table targets
The row handlers of Postgres, SQLite, Doris, Snowflake, BigQuery, LanceDB, Neo4j, and FalkorDB now tell the engine that their tracking record is exactly the value’s fingerprint (#2472). For those targets the engine fingerprints the declared value natively and calls reconcile() only for rows that are new, changed, removed, or possibly missing. An update where most rows are unchanged runs no per-row Python at commit. Qdrant, Valkey, SurrealDB, Turbopuffer, and zvec fingerprint a derived value rather than the row, so they do not opt in yet, nor do index and attachment handlers; they behave exactly as before. full_reprocess, a provider schema change, and a value that is not plain data still go through reconcile().
| Measurement | Before | After |
|---|---|---|
| Engine memory per declared Postgres row | 1,449 B | 487 B |
| Fingerprint, 15-column row with two list columns | 17.8 µs | 1.95 µs |
| 200,000 unchanged rows at commit, no-op sink | 2.34 s, 200,000 reconcile() calls | 1.34 s, 0 calls |
| Postgres upsert, 631,119 rows, ten connections | 5.1–5.6 s | 2.8–3.2 s |
| Postgres delete, 8,000 four-column keys | 59.1 s | 0.26 s |
Failures stay with the component that caused them (v1.0.17 to v1.0.25)
The engine coalesces work at three layers: concurrent calls to a batched function form one batch, actions from every component that finishes while a sink call is in flight merge into the next call, and concurrent write-transaction bodies share one LMDB transaction. Each merge is a pure optimization, and a failure inside a merged unit used to fail everyone in it. Reviewing a shared Postgres sink made the cost concrete: a mount_each fan-out of 60 components produced one sink call with 59 rows, one poisoned row rolled back the other 58 components’ writes, and app.update() returned without raising, because mounted children fail independently.
This cycle applies one rule at all three layers: a failure reaches only the callers whose inputs caused it. The first layer is opt-in; the other two need no code.
Batched functions: coco.RetryWithSmallerBatch (v1.0.17)
Some batch failures depend on the batch’s composition rather than on any single input: a provider’s per-request token or payload cap, one input the provider rejects, a GPU running out of memory. No fixed max_batch_size rules these out, because the threshold depends on the content. A batched function body can raise coco.RetryWithSmallerBatch to have the engine halve the batch and retry the halves, recursing down to single items (#2270).
@coco.fn(batching=True, max_batch_size=64)
async def embed(texts: list[str]) -> list[list[float]]:
try:
return await provider.embed(texts)
except ProviderError as e:
if is_transient(e) or is_auth_error(e) or is_timeout(e):
raise # global, transient and not yet retried, or a timeout: splitting can't help
raise coco.RetryWithSmallerBatch() from e
What that gives you:
- No size check needed. At size 1 the engine unwraps the signal and raises the original error as that item’s own failure, so
raise coco.RetryWithSmallerBatch() from eis correct at every batch size. An error that would fail at any size therefore bottoms out at single items and reaches every caller, at the cost of up to about twice the batch size in extra attempts. - Errors land per item. A failed sub-batch fails only the callers whose inputs were in it. One poison input no longer takes the other 63 down with it.
- Works across subprocess runners. The signal and its cause survive the trip through a subprocess boundary.
Re-raise, rather than wrap, errors where splitting cannot help: global ones such as bad credentials or a missing model, transient ones such as rate limits and network blips that have not yet been retried at the same size, and timeouts, since every sub-batch would start a fresh full budget against a backend that is already too slow.
Two built-in embedders do this for you. LiteLLMEmbedder raises it for errors that are neither transient, global, nor a timeout, such as a per-batch token cap: voyage-code-3 rejects batches over 120k tokens with TOO_MANY_TOKENS_IN_BATCH, which 64 long chunks can exceed. SentenceTransformerEmbedder raises it on OOM and frees the accelerator cache before retrying.
Target sink batches (v1.0.23)
A sink receives one flat action list and cannot see component boundaries, and the runner behind it was all-or-nothing, so a failed merged call fanned one error out to every component in it. The runner now returns one result per component (#2406). A batch spanning several components is applied whole first. If that fails, it is bisected along component boundaries, never inside a component, whose actions stay one atomic unit, and each half is re-applied, recursively. An error reaches only the components in a failing sub-batch, and a component that still fails alone gets its own error.
The happy path is unchanged: one sink call, one transaction. The failure path costs at most 2n − 1 sink calls for n components, about 2·log₂(n) when a single component is bad. Cancellation and deadline errors are not retried, since no particular component caused them, and connector code is untouched.
Write transactions (v1.0.25)
reconcile() runs inside pre-commit, which runs inside a batched write-transaction body. A connector rejecting one component’s declared value therefore failed the pre-commit of every component batched with it, including components of other apps sharing the same Environment, each reporting a copy of an unrelated error (#2451). The runner now reports one result per body. When a body fails, the transaction is aborted, since LMDB has no savepoints, and the error goes to that body’s caller only. The bodies ahead of it ran cleanly, so they run again and commit in a transaction of their own; the bodies behind it continue in the next transaction, still in call order. Each body runs at most twice however many of its batch mates fail, and nothing changes on the happy path.
Where a failure is delivered
Component exception handlers now receive the component’s original Python exception instead of a RuntimeError wrapping a formatted string, and an exception a handler raises keeps its type through await handle.ready() (#2383, v1.0.23). A handler installed with coco.exception_handler around coco.mount_each(fn, items) now receives the items’ failures when items is a LiveMap, as it already did for a list; coco.auto_refresh items are covered the same way (#2478, v1.0.25).
Memoization under concurrency and redefinition (v1.0.23 to v1.0.24)
Two classes of spurious re-execution are fixed. Neither changes a memo key or a stored entry.
Concurrent same-key component runs execute once
Two runs of one memoized component with the same key that both started before either had stored a memo, for example two overlapping App.update() calls on an app whose main function is memoized, both executed the body (#2412). The engine now holds the component’s build permit through the memo store, and a run queued on the permit re-checks the memo a same-key run just stored and reuses it. A failed run stores nothing, so the queued run executes as before. The same reuse applies under full_reprocess, scoped to the current update: a memo stored earlier in the same update is reused, while memos left by previous updates are still ignored (#2414).
Redefinitions and copies no longer drop a memo
A @coco.fn(memo=True) function registers its logic fingerprint in a process-global registry and releases it when the object is collected. Two live objects can share one fingerprint whenever identical code is defined twice: a re-run notebook cell, importlib.reload, or a factory that defines the function on each call. Dropping the old object removed the fingerprint for both, and the surviving function’s memo entries then failed the logic check and re-ran, paid embedding calls included. The registry is now reference-counted (#2405), and copy.copy and copy.deepcopy of a coco function return the function itself rather than an unregistered copy whose deletion had the same effect (#2409).
GPU scheduling: a Rust pool, whole-GPU reservations, no lost wake-ups (v1.0.25)
Since v1.0.15, a function run with runner=coco.GPU takes a whole GPU from a pool for the duration of the call, and coco.GPU(0.5) takes half of one, so two such calls share a card while a third waits. The pool size comes from COCOINDEX_NUM_GPUS, then CUDA_VISIBLE_DEVICES, then nvidia-smi, and defaults to one; coco.configure_gpu_pool(n) overrides it. Inside the function, coco.current_gpu() and coco.current_gpu_fraction() report what was assigned. See the runner section of the function guide.
v1.0.25 rewrites the pool behind that runner in Rust and exposes it as coco.GPUPool (#2277, closing #2243 and #2433). Three things change for users:
- Requests are served in arrival order. A request that arrives while others are queued cannot take the capacity those are waiting for, so a whole-GPU request is not starved by a stream of fractional ones. Releasing capacity serves the queue from the front.
- Whole-GPU reservations.
acquire_full(n)returns the ids ofnGPUs that are completely free. It holds the ones it has as the rest free up and returns only when allnare held, so a caller never ends up with a partial set; asking for more GPUs than the pool has is an error. Thecoco.GPUrunner still requests a fraction of one GPU per call, so this is the building block for functions that need several cards at once, such as a model sharded across GPUs, rather than a new runner form. - A waiter is woken whichever event loop releases. The Python pool kept one
asyncio.Conditionand rebound it to whichever event loop touched it last. A release on one loop woke only that loop’s waiters, so a function waiting on another loop slept forever with capacity free: the process sat idle with embeddings pending. Reported from a cocoindex-code deployment on 1.0.22 with a reproduction that needs no GPU. The Rust pool signals each waiter through its own channel under a mutex, so the loop that releases does not matter.
Two limits are unchanged: subprocess mode (COCOINDEX_RUN_GPU_IN_SUBPROCESS=1) does not yet support more than one GPU (#2382), and a function with both batching=True and the GPU runner still serializes its batches through one queue.
cocoindex show: target-state inspection (v1.0.17–1.0.18)
A target state is what a component declares should exist in an external system: a file in a directory, a row in a table, or an embedding in a vector store. CocoIndex records every declared target state under a stable path together with the component that owns it, and reconciles the declarations from run to run to apply only the necessary creates, updates, and deletes. That record is the ground truth for what CocoIndex has written where, and this cycle makes it readable from the CLI.
Readable paths in the long listing. cocoindex show <app> -l lists each component with the target states it owns. Paths now render as the target’s name and key instead of raw fingerprints (output trimmed):
$ cocoindex show main -l
Stable paths:
...
/@process_file/"bizarre_animals.md"
type:component version:1 processor:process_file
has_memoization:true target_state_count:1
Target states:
- path:/@cocoindex/localfs/[null,"output_html/data__bizarre_animals.md.html"]
The inverted view. --target-states starts from the target side instead: every tracked target state, with the component that owns it. This answers “what does CocoIndex believe exists in this target, and which component put it there?”, and it is the only view that can surface an entry nothing owns anymore, for example after an interrupted run; the listing marks those [dangling].
$ cocoindex show main --target-states
Target states:
/@cocoindex/localfs/[null,"output_html/data__bizarre_animals.md.html"]
owner:/@process_file/"bizarre_animals.md"
/@cocoindex/localfs/[null,"output_html/data__chuck_norris.md.html"]
owner:/@process_file/"chuck_norris.md"
Flags that compose. --tree nests entries under their parent containers, so a table’s rows sit beneath the table. --fingerprints shows the stored fingerprints instead of readable keys, for correlating with logs or the raw store.
Without loading the app. Pass --db <path> --app-name <name> (the name from your coco.AppConfig) instead of an app target to read a database directly, from any process, without importing the app module or installing its dependencies. This is how you look at a database copied out of a deployment:
$ cocoindex show --db ./cocoindex.db --app-name FilesTransform --target-states --tree
Target states:
- @cocoindex/localfs
- [null,"output_html/data__bizarre_animals.md.html"] owner:/@process_file/"bizarre_animals.md"
- [null,"output_html/data__chuck_norris.md.html"] owner:/@process_file/"chuck_norris.md"
When to reach for it:
- After an update, to confirm what was written and by which component.
- To find
[dangling]entries after an interrupted run. - To inspect a database on a machine that does not have the app’s dependencies.
Under the hood, this took five changes from @junzh0u (#2299, #2301, #2303, #2306, #2307), including a fix without which --db inspection failed across processes, and a detail view about 2× faster at 10,000 target states.
Correctness fixes
- SurrealDB connector overhaul (#2439, v1.0.25). A statement error inside a batch was silently swallowed by
query(); it is now raised. Values go as typed CBOR parameters, record IDs may be strings, integers, or arrays, and record writes useUPSERT ... MERGE, so fields no longer declared are unset and a newTableTarget.declare_fieldslets more than one component own fields of the same record. Transactions are bounded at 500 actions and retried on conflict, DDL is idempotent, and vector indexes are HNSW only, since MTREE was removed in SurrealDB 3. - Replacing a component with a deeper one no longer deletes the deeper component’s rows (#2379, v1.0.22). When a component at
a/bfrom a previous run was replaced bya/b/c, the demotion ofa/bwas meant to remove only its own target states; it removed every child row under it as well, and the next sweep then deleted target statesa/b/chad written in the same update. - Live mode terminates even when a child’s exception is retained (#2385, v1.0.22). A component counted as active while any strong reference to it existed, and an exception’s traceback holds such a reference, so appending a child’s exception to a list in a handler kept
update_blocking(live=True)from ever returning. Activity is now an explicit count of in-flight tasks. - LiteLLM embeddings are aligned to inputs by index (#2381, v1.0.22). A provider or proxy that returned items out of order attached embeddings to the wrong texts with no error. The memo version is deliberately unchanged, so vectors cached while misaligned are not invalidated automatically; if you suspect a proxy reordered responses, run a
full_reprocess. - Schema changes under
full_reprocess, in the table and graph targets. These connectors silently skipped a schema change (a table column, or a node or relationship property in the graph targets) that arrived in an update run withfull_reprocess=True. Row writes then failed on the missing column, yet later updates reported success, and the target stayed out of sync. The gap was in the connectors’ reconcile and apply paths rather than in the engine, and it is fixed in Postgres, SQLite, Doris, Snowflake, BigQuery, and SurrealDB (#2346), LanceDB (#2352), and Neo4j and FalkorDB (#2362). - Failed DDL is reported, not recorded as applied. SQLite no longer treats a genuine
ADD COLUMNfailure as an idempotent no-op (#2328), and Doris does the same forADD COLUMNandDROP COLUMN(#2359), so the tracked schema cannot drift from the real table. - Memoization preserves target invalidation (the same #2346). Before, if a memoized function declared rows and the target’s provider changed in a way that should rewrite them, a cache hit could skip the rewrite. Provider changes are now part of what a cache hit validates.
- Recreated container target states no longer replay stale deletes (#2365). Recreating a container, for example a partition after its parent table’s primary key changed, could replay deletes from the old layout against its children, failing the component or silently dropping fresh rows.
- Memoization keys with
Fingerprintvalues are order-independent (#2334). Dictionaries and sets ofFingerprintkeys built in different orders now produce the same key instead of a spurious cache miss.
Other fixes
| Fix | Impact | Release |
|---|---|---|
| Recursively strip NUL from Postgres array and composite bindings | Strings containing U+0000 inside text[] or composite values no longer fail with ValueError: string cannot contain NUL | v1.0.17 |
| Clear the inverted target-owner index on component deletion | No permanently dangling owner rows after a component is dropped | v1.0.19 |
| Avoid blocking the event loop during iterator cleanup | Async source iterators no longer stall the event loop while their producer thread exits | v1.0.21 |
| Parse Windows app target paths | C:\project\main.py:app2 is no longer parsed as module C | v1.0.21 |
| Preserve tuple and bytes path segments through state-store decoding | Components keyed by a tuple or by bytes no longer leak or misattribute target-state ownership; rows already leaked are not repaired | v1.0.23 |
| Collect preview actions once when a write batch re-runs | Preview no longer reports an action once per LMDB map grow | v1.0.25 |
| Purge Valkey document hashes when an index is deleted | Un-declaring an index or running App.drop() no longer leaves the index’s document hashes behind | v1.0.25 |
Tag Valkey connections with LIB-NAME cocoindex | CocoIndex clients are identifiable in CLIENT LIST; requires valkey-glide ≥ 2.5.2 | v1.0.25 |
| Stop recursive localfs walks at symlink cycles | A directory containing a symlink back to itself or a parent no longer yields an endless stream of keys | v1.0.25 |
| Return empty bytes for zero-length and empty-object S3 reads | read(0) and partial reads of an empty object no longer send a range request S3 rejects | v1.0.25 |
For connector authors (v1.0.21 to v1.0.25)
Several contracts in the custom target connector API changed this cycle. Shipped connectors are all migrated.
- Child slots replace aligned child lists (#2407, v1.0.23). A container sink used to return a list of child handler definitions index-aligned with the batch; only the length was checked, so a wrong index attached a child to the wrong target state silently, and a leaf-only batch through a shared sink had to return a
Noneper row. Container sinks are now built withTargetActionSink.from_fn_with_childrenorfrom_async_fn_with_children, whose callback receiveschild_slots, a mapping with an entry only for actions that carry a child provider, and fulfills each one.TargetActionSinktakes one type argument, so one shared sink serves a container level and its leaf level without casts. The old shape keeps working with aDeprecationWarning; see the migration section. - Value-based sink identity, now type-keyed (#2368, v1.0.21; #2404, v1.0.23). Sinks built from callbacks that compare equal share one batching identity, so writes to the same resource from different components are batched and, for transactional targets, committed together, without a hand-kept registry of sinks. Equality alone was not safe, since a
NamedTupleequals any same-shaped tuple and two connectors’ sinks could merge. Canonicalization now also requires the same type, and a callback that cannot be weak-referenced raisesTypeErrorat construction; use a frozen dataclass, withweakref_slot=Trueif it usesslots=True. - Handlers and sinks see equal copies of plain-data values (#2469, v1.0.25). The copy is taken at declaration, so mutating a value after declaring it is not observed.
tracks_value_fingerprint = True(#2472, v1.0.25). A handler whose tracking record is exactlyfingerprint_object(value)can set this class attribute to let the engine skipreconcile()for unchanged values, as described above. A handler that opts in but tracks something else never matches, so it never skips.reconcile()may run more than once per commit (#2475, v1.0.25). It runs inside the pre-commit callback, which is re-invoked when an LMDB batch re-runs after a map grow or after a body ahead of it fails. Only the attempt that commits is applied, soreconcile()must be free of side effects.- Deleting a container destroys or abandons its children (#2418, v1.0.24). Once a container is no longer declared, its own removal runs and its children are not visited individually. A container handler therefore either destroys, for containers CocoIndex owns (
DROP TABLE,rmtree), or abandons, for user-managed ones such as Kafka and Iggy topics andmanaged_by="user"tables, which CocoIndex stops writing to and leaves untouched, with no tombstones or deletion messages.App.drop()follows the same rule; the target-state guide and the connector guide now say so.
Other improvements
- Bounded concurrent embedding requests.
LiteLLMEmbedder(max_inflight_requests=N)caps how many requests one embedder keeps open against its backend (#2392, v1.0.22). Batching bounds how many texts ride in one request, never how many requests are open: 2,334 chunks from one file atmax_batch_size=64formed 38 batches, 37 of them concurrent, and a batch that splits fans out further. The permit covers one backend request only, so it is not held during a backoff sleep or across a split; the default is no cap, and the cap is per instance. See limiting concurrent requests. Environment.close()releases the LMDB environment explicitly, so the same database path can be reopened without waiting for every Python reference to be collected (#2481, v1.0.25). On free-threaded Python 3.14 a thread started inside a component inherited the component’s context for life, which kept the environment open and made reopening fail withEnvAlreadyOpened.LazyEnvironment.stop()now closes its environment. See closing an environment.recollect_after_runfor memo states. A memo state function can returnrecollect_after_run=Truein itsMemoStateOutcometo have the state collected again after the function body re-runs, for states that depend on what the body read (#2474, v1.0.25). Without the flag the stored state is the one the check returned before the run, which is right for states read from outside, such as a modification time. Functions only for now; a memoized component raisesValueErrorfor it. See state that depends on what the function did.- Rust SDK ergonomics (#2287, v1.0.25).
#[cocoindex::function(memo, batching)]gives a Rust function item-shaped batching with per-item memoization; thecontext_key!macro replaces hand-writtenLazyLock<ContextKey<_>>definitions; and connector schemas derive from Rust row types throughSchemaFieldsandTableSchema::from_row, with vector dimensions resolved at runtime from the embedder. The Rust quickstart and the Rust examples teach the new forms. - Syntax-aware splitting for Dart and Lua.
RecursiveSplittergained tree-sitter grammars for both (#2316, #2370), bringing grammar-backed splitting to 38 languages;.dartand.luafiles previously fell back to separator-based splitting. The supported-languages table was audited and corrected at the same time (#2367). - Unknown
language=values fall back silently. A value that matches no known language falls back to separator splitting rather than raising, so if chunk quality looks off, check the value against the table ("csharp", not"c_sharp"). - Documentation.
- Memoization placement: where
memo=Truebelongs relative to component structure, what a cache entry costs, and thatfull_reprocessre-runs every memoized function and component, paid LLM and embedding calls included (#2363). - LMDB map auto-resize and logic-fingerprint propagation, including the gotcha that setting
version=on a@coco.fnreplaces automatic source-change tracking, so later code edits stop invalidating until you bump it (#2364). - A dedicated rate limiting page (#2265),
mount_each()return semantics (#2327), connection resolution at action time in the custom target connector guide (#2291), and the LiteLLM shared behavior section (#2396).
- Memoization placement: where
Examples
Slides-to-speech, local narration and per-slide incrementality
The slides-to-speech example now narrates locally on the CPU with no TTS API key: Kyutai’s Pocket TTS replaces Piper, and a typed DSPy signature drives the vision model, which still needs LLM credentials (Gemini by default) (#2262). It is also incremental per slide rather than per deck (#2267): editing one slide re-runs the vision call, synthesis, and embedding for that slide only, and the chosen TTS voice is a tracked input, so switching voices re-narrates every slide.
Meeting notes to a knowledge graph on SurrealDB
A new example builds a knowledge graph from meeting notes into SurrealDB (#2329), joining the Neo4j and FalkorDB versions of the same example (the walkthrough covers the Neo4j one).
Security and dependencies
- pyo3 0.27 → 0.29 plus lockfile dependency bumps, resolving open security advisories (#2292, #2293).
- surrealdb ≥ 3.2.3 (#2315), raising the floor for a quinn-proto denial of service (CVSS 7.5) and two ammonia XSS advisories in the optional SurrealDB feature; the lockfile bump that moved quinn-proto to 0.11.16 and ammonia to 4.1.4 shipped in v1.0.20 with #2355.
- Dependency upgrade sweep clearing the remaining high-severity alerts; two low-severity, upstream-blocked ones remain (#2355).
Summary
This cycle gives a pipeline a time budget it sets once and every foreground call inherits, takes most of the per-row cost out of committing a large table, and applies one rule at three layers of the engine so that a failure reaches only the component that caused it. Underneath, long updates no longer hang on Linux, memoized components run once under concurrent same-key runs, memoization preserves target invalidation, and the table and graph targets apply schema changes correctly under full_reprocess, which is what keeps a pipeline converging rather than reporting success while drifting.
For the complete list of changes, see the GitHub releases. If CocoIndex is useful to you, consider starring the repository.
Thanks to the community
Thanks to everyone who contributed this cycle, including eleven first-time contributors.
@junzh0u
Thanks @junzh0u, a first-time contributor, for making target state legible: fixing cross-process show --db, rendering tracked paths readably, adding the --target-states listing, persisting names for provider-only segments, and a faster detail view.
@Sujit-1509
Thanks @Sujit-1509 for carrying the schema-evolution fix across connectors: LanceDB and then Neo4j and FalkorDB, plus deterministic Fingerprint canonicalization, recursive NUL stripping in Postgres array and composite bindings, stopping localfs walks at symlink cycles, and zero-length and empty-object S3 reads.
@hardness1020
Thanks @hardness1020, a first-time contributor, for wiring the tree-sitter Lua grammar, then for three correctness fixes: aligning LiteLLM embeddings to inputs by index, preserving typed exceptions in component exception handlers, and preserving tuple and bytes path segments through state-store decoding.
@tomz-alt
Thanks @tomz-alt for cooperative timeout support: coco.timeout(), DeadlineExceededError, and the inheritance rule that separates foreground work from background and shared work; and for the Rust SDK ergonomics release: declarative batching with per-item memoization, context_key!, and schemas derived from row types.
@prrao87
Thanks @prrao87 for rebuilding slides-to-speech: local CPU TTS with a DSPy vision signature, then moving the incremental boundary to the slide with the TTS voice as a tracked context value.
@martinschaer
Thanks @martinschaer, a first-time contributor, for the SurrealDB connector overhaul: raised statement errors, typed CBOR values, flexible record IDs, UPSERT ... MERGE writes with declare_fields, bounded transactions, and HNSW-only indexes for SurrealDB 3.
@YaxinCheng
Thanks @YaxinCheng, a first-time contributor, for rewriting the GPU pool in Rust: a fair arrival-order queue, whole-GPU reservations through acquire_full(n), and a fix for waiters never woken when capacity was released from another event loop.
@suju-droid
Thanks @suju-droid, a first-time contributor, for clearing the inverted target-owner index on component deletion and for preserving SQLite schema consistency on failed column migrations.
@ca-ke
Thanks @ca-ke, a first-time contributor, for wiring the tree-sitter Dart grammar, so .dart files get syntax-aware splitting instead of the separator fallback.
@AmirF194
Thanks @AmirF194, a first-time contributor, for making the Doris connector re-raise genuine column DDL failures instead of swallowing them, including tests that exercise the real control flow without a live cluster.
@AbhayMahalle
Thanks @AbhayMahalle, a first-time contributor, for tagging Valkey connections with the CocoIndex library name, so they can be told apart in the server’s CLIENT LIST.
@aeonframework
Thanks @aeonframework, a first-time contributor, for raising the surrealdb floor to ≥ 3.2.3 for two security advisories.
@justavibedev
Thanks @justavibedev, a first-time contributor, for keeping the event loop unblocked during async iterator cleanup.
@yhz5613813
Thanks @yhz5613813, a first-time contributor, for parsing Windows app target paths, so a drive-letter colon is no longer read as the app-name separator.