CocoIndex Changelog 1.0.26 - 1.1.0

CocoIndex 1.0.26 and 1.1.0: a preview that writes nothing, per-call logic deps, bounded batching and component concurrency, and a connector key audit.

Changelog 26 min read

This is the fourth changelog since the v1.0 launch, covering two releases (1.0.26 and 1.1.0). It leads with new APIs: a preview that runs on real pipelines and writes nothing, coco.LogicDep for logic dependencies decided per call, max_in_flight and a per-batch rate limiter for batched functions, and a component limit that holds across the whole hierarchy. A faster commit path for moving target states between components follows, then fixes, led by an audit of how every Python connector builds its keys, and internal improvements.

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

  • Preview writes nothing. cocoindex update --preview now handles rows under table targets, so it runs on most pipelines, and it never stores a memo or writes committed state, so it can’t make the next real update skip work.
  • Per-call logic dependencies. coco.LogicDep and coco.use_logic_dep() let one call record a dependency, such as the version of the parser it used. Bumping that dependency re-runs only the calls that recorded it and the memoized callers above them.
  • Bounded batching. A batched function takes max_in_flight, which caps how many batches run at once, and rate_limiter, which takes one token per batch instead of per call.
  • A component limit that holds across the hierarchy. max_inflight_components now bounds every running component: a parent lends its slot to one child at a time, and live components share the pool fairly.
  • Faster commits when target states move. Moving 5,000 target states from one component to another in one commit took 243 seconds and now takes 1 to 2.
  • Keys that hold in the edge cases. CocoIndex has identified each object by one key from the start. An audit of every Python connector closed the cases where a connector’s key construction broke that rule: two objects sharing one key, one object’s key changing between runs, or a delete missing its object.

Upgrade notes

Check these before upgrading. Each item names the release it applies from. The 1.1.0 items are what make this a minor version rather than another patch.

Deprecated, still working (with a DeprecationWarning):

  • Neo4j and FalkorDB table and relation APIs (v1.1.0). Each has a replacement with the same parameters: table_target → node_target, declare_table_target → declare_node_target, mount_table_target → mount_node_target (which returns a NodeTarget), relation_target → relationship_target, declare_relation_target → declare_relationship_target, mount_relation_target → mount_relationship_target, and RelationTarget.declare_relation → RelationshipTarget.declare_relationship. The deprecated calls keep the keys they had. Switching an existing table takes two updates, listed under node and relationship APIs; skipping the first deletes the table’s nodes or edges.
  • Kafka topic_as_map with a list of topics (v1.1.0). The list form still works with its old keys. With one topic, pass it as a string; the keys don’t change. With several, use one feed and one consumer per topic, each under its own subpath. That changes the item keys, so follow the replay steps in the Kafka docs.
  • Postgres TableTarget(provider, table_schema) (v1.1.0). Pass the table name: TableTarget(provider, table_name, table_schema). The keys don’t change.
  • Connector sink contracts (v1.0.26). from_fn_with_children and from_async_fn_with_children are replaced by TargetActionSink.from_fn(fn, child_handler=...) and from_async_fn(...). The old forms still run but don’t support preview.
  • coco.logic_tracked (v1.0.26) is a deprecated alias of coco.logic_propagated_to_callers.

Behavior changes:

  • Object-store prefix names a folder (v1.1.0). For S3, Azure Blob and OCI, prefix="docs" now lists only docs/...; docsa.md and docs-old/x are no longer included. Target states built from objects that drop out of the listing are removed on the next update. To select objects by how their names start, use path_matcher.
  • Preview leaves no trace (v1.0.26). A preview no longer stores, deletes or refreshes memos and no longer writes committed state.
  • FilePath comparisons (v1.1.0). FilePaths of different types no longer compare equal, and ordering them raises TypeError.

New errors:

  • DirTarget names that are absolute or leave the directory (v1.1.0).
  • A new zvec collection with an Any, object, union or bytes key column (v1.1.0).
  • LanceDB key column types that deletes can’t filter on, such as dates, timestamps, float16 and lists (v1.1.0).
  • Postgres vector indexes whose names truncate to the same 63 bytes (v1.1.0).
  • The Valkey field name vector (v1.1.0).
  • @coco.fn(batching=...) and @coco.fn(runner=...) are type errors; use @coco.fn.as_async. A lone max_batch_size= on a sync function is rejected instead of ignored (v1.0.26).
  • A class the state reader can’t load fails at the first write, with a hint to use coco.unpickle_safe, instead of on the next run (v1.1.0).

One-time effects on the first update after upgrading:

  • Google Drive components get new keys and are reprocessed (v1.1.0).
  • Objects that shared a key become separate items (v1.1.0).
  • SurrealDB relations declared without an explicit id are re-created under a new record ID (v1.1.0).
  • DirTarget files declared under a name that now normalizes, such as ./a.txt, are written once under the new name (v1.1.0).

To remove duplicate graph edges left by earlier versions, run one update with --full-reprocess (v1.1.0).

Memo fingerprints change for values with shared sub-objects (v1.1.0)

Memo keys and fingerprints now depend only on values, so a structure that references one object twice fingerprints the same as one holding equal copies. For such values, on the first update after upgrading: memoized work may run once more; generate_id, generate_uuid, IdGenerator and UuidGenerator may return new IDs, which changes row keys built from them; and rows tracked by fingerprint_object are written again. See the fix.

Requirements and the Rust SDK:

  • FalkorDB needs falkordb 1.6.0 or later (v1.0.26).
  • The Rust crates need Rust 1.91.1 or later (v1.0.26).
  • Rust SDK: the Neo4j and FalkorDB table and relation target functions take &ContextKey<Graph> instead of &Graph (v1.0.26).
  • litellm 1.107.0 is excluded from every litellm requirement (v1.0.26); see Security and dependencies.

New APIs and capabilities

Preview runs on real pipelines and writes nothing (v1.0.26)

A preview lists what an update would change in the targets, without changing them. Before 1.0.26 it handled only target states with nothing nested under them. Most real pipelines nest: rows under a Postgres table, points under a Qdrant collection, files under a directory target, where the table or directory is a container and the rows or files are its children. As soon as a preview reached a container with children it stopped with an error, preview currently supports flat/leaf target actions only. It was also unsafe: a preview could store, delete or refresh memos, and write committed state, so the next real update could skip work it had never done (#2510).

Both are fixed. Preview now lists the planned actions for target states under a container, reads memos without changing them, and writes no committed state. It also runs no deletes for live components. One exception is deliberate: generate_id and IdGenerator reserve IDs in a preview as in an update, because IDs are opaque and the gap costs nothing; the IDs a preview prints for new items are therefore not the ones the next update assigns.

bash
cocoindex update --preview path/to/app.py
python
handle = app.update(preview=True)
actions = await handle.result()  # one entry per planned target action; nothing was applied

The CLI prints one line per planned action, or No target actions planned. A container’s action is listed in every preview, even when its tracking record (what CocoIndex stored about it after the last run) is unchanged, because equal tracking records do not guarantee that applying the action has no external effect. A preview also applies no actions to a live map, so consumers of the map see it empty; preview the producers’ own targets instead.

For connector authors, a container’s child handler is now a pure function of its action. TargetActionSink.from_fn(fn, child_handler=...) and from_async_fn(...) replace from_fn_with_children and from_async_fn_with_children: fn(context_provider, actions) only applies the actions, and child_handler(context_provider, action) builds the handler for the container’s children, before fn applies them in an update and in place of fn in a preview. The old forms still run with a DeprecationWarning but can’t be previewed.

Documentation Container targets and the child handler factory. How a container sink declares its children, why the factory must be side-effect free, and how to migrate a sink written against the deprecated contracts.

Logic tracking beyond the decorator (v1.0.26 to v1.1.0)

@coco.fn fingerprints a function’s logic so that editing the function invalidates the memos that depend on it. Two cases a decorator parameter can’t express now have their own tools.

coco.LogicDep and coco.use_logic_dep(): a dependency decided per call (v1.1.0)

deps= on the decorator describes the whole function: every call depends on the value. Some dependencies are decided per call. A tree walk’s visit_file depends on the parser for that file’s language, and a change to one parser should re-run the files it parsed, not every file the walk saw. coco.LogicDep declares such a value once, at module import, and coco.use_logic_dep() records it as part of the current call’s logic (#2590):

python
PARSER_DEPS = {
    name: coco.LogicDep(("parser", name, version))
    for name, version in PARSER_VERSIONS.items()
}

@coco.fn(logic_tracking="self", version=1)
async def visit_file(file: File) -> None:
    language = detect_language(file.path)
    if (dep := PARSER_DEPS.get(language)) is not None:
        coco.use_logic_dep(dep)  # this call's logic also depends on that parser
    ...

In the sample, visit_file is tracked as "self", so the functions it calls are not part of its fingerprint; the parser dependency is recorded whatever the mode, because it describes the call rather than the function’s code. A recorded dependency travels like the function’s own fingerprint: into the entry of a memoized caller, onward through "full" callers, and from a mounted component into every ancestor component’s entry. When a later release constructs the LogicDep with a new value, only the entries that recorded the old one are invalidated. In the example, bumping the Python parser’s version re-runs the Python files and the directories above them, and every other subtree is reused.

A file tree: src/ contains api/ (main.py and routes.py, each tagged parser v2) and web/ (app.ts and index.ts, tagged parser v1). After the Python parser's LogicDep is bumped from 2 to 3, main.py, routes.py, api/ and src/ are marked re-run; web/, app.ts and index.ts are marked reused from memo.

Two rules follow from how entries are validated:

  • Build every LogicDep at module import, before any component runs. One constructed on first use registers too late, and one constructed per call is released too early; in both cases memoization silently stops working rather than returning stale data. Constructing one inside a component context raises.
  • Adopting use_logic_dep in a function whose memoized callers already stored entries does not re-store those entries. Ship the adoption with a version bump on those callers.

The same PR fixed a related bug: a function-memo hit lost the logic dependencies its entry had recorded, so a caller re-stored on that hit missed later changes to deeper callees.

coco.logic_propagated_to_callers(): callbacks through a "self" boundary (v1.0.26)

logic_tracking="self" hides the logic of everything a function calls, including callbacks it received as arguments. A library whose accept is tracked as "self" therefore hides the user’s visit_file: an edit to it invalidates nothing, even though it is the user’s input and not the library’s implementation detail. coco.logic_propagated_to_callers(fn) closes the gap (#2483, #2529). It is library-side; the user only puts @coco.fn on the callback.

python
# Library
@coco.fn(memo=True, logic_tracking="self", version=1)
async def accept(file: File, visit_file: Callable[[File], Awaitable[None]]) -> None:
    await visit_file(file)

@coco.fn
async def walk(repo: Repo, visitor: RepoVisitor) -> None:
    visit_file = coco.logic_propagated_to_callers(visitor.visit_file)  # walk owns the callback
    for file in repo.files():
        await coco.use_mount(coco.component_subpath(file.path), accept, file, visit_file)

The function that calls it is the callback’s owner. The wrapper runs fn as usual; afterwards, fn’s logic is recorded in every memo entry between the call site and the owner, whatever their logic_tracking. When the user edits visit_file, every accept memo entry misses. Two wrappers of the same function produce the same key. coco.logic_tracked, its name before 1.0.26 shipped, remains as a deprecated alias and works with a DeprecationWarning.

Documentation What a function's logic fingerprint covers. logic_tracking modes, deps=, per-call dependencies with LogicDep and use_logic_dep, and carrying a callback's logic through a self-tracked layer.

Batching: a bound on in-flight batches (v1.1.0)

A batched function starts a batch in three situations. The first call on an idle queue runs at once, on its own. A batch that fills to max_batch_size starts as soon as it fills. And when a batch finishes, the next pending batch may start. A call that arrives while batches are running joins the open partial batch and waits for one of those triggers. By default the partial batch starts only once no batch is running at all, so one slow batch holds back the partial batch behind it.

@coco.fn.as_async(batching=True, ...) now takes max_in_flight, which bounds how many batches run at once and changes when a batch may start (#2588). With max_in_flight=n, a batch that fills while n batches are running waits in order, and whenever a batch finishes the next pending batch, full or partial, starts as long as fewer than n are running. A partial batch no longer waits for the slowest batch, only for a free slot.

python
@coco.fn.as_async(batching=True, max_batch_size=32, max_in_flight=4)
async def embed(texts: list[str]) -> list[list[float]]:
    return await client.embed(texts)

A shared time axis with four call arrivals c1 to c4 above two groups of three rows, one row per batch. In both groups batch 1 (c1, runs at once) runs long from the first arrival and batch 2 (c2 and c3, full) starts the moment c3 fills it; batch 3 (c4, partial) shows a dashed pending stretch. Under the default it waits until batch 1 also ends; with max_in_flight = 2 it starts the moment batch 2 finishes, while batch 1 is still running.

Batching: one rate-limit token per batch (v1.1.0)

When each batch is one request against a quota, the same decorator takes rate_limiter, the quota’s RateLimiter (#2589). Every batch takes one token before it runs, so a token pays for one batch rather than one call. No call runs on its own: the first call waits for a token like any other and goes out with everything that arrived meanwhile. Under a scarce quota, calls that arrive while the batcher waits for a token join the next batch; with tokens to spare, a batch goes out at once and the next one fills during its round trip. For requests the body makes beyond the batch’s own, such as a retry or a per-item fallback, the body should take its own tokens from the same limiter. For a limiter kept on the instance, pass a callable that receives self:

python
class Client:
    def __init__(self, limiter: RateLimiter) -> None:
        self._limiter = limiter

    @coco.fn.as_async(
        batching=True, max_batch_size=100, rate_limiter=lambda self: self._limiter
    )
    async def fetch(self, ids: list[str]) -> list[Item]:
        return await self._api.fetch_many(ids)  # one request, one token

A stream of calls flows into a dashed open batch that is filling, with a lighter arrow into the batch above it, which holds five of a hundred calls and is sending. A RateLimiter bucket above passes one token down to the sending batch, and that batch sends one request to an API box; calls that arrive after a batch is sent fill the next one.

Slot lending: a component limit that holds across the hierarchy (v1.0.26)

max_inflight_components (or COCOINDEX_MAX_INFLIGHT_COMPONENTS, default 1024) caps how many processing components run at once. The difficulty with any such cap is a hierarchy: a parent that mounts children and waits for them holds a slot while it waits, so with a small cap the slots fill with waiting parents and the children they wait for can never start. Before 1.0.26 the engine avoided that deadlock by releasing a parent’s slot when it mounted its first child, which left everything after that first mount unbounded. A tree walk that read files faster than the downstream stage drained them kept every file component in flight, each holding its contents; one indexer was killed for memory with about fifteen thousand file components in flight while the application’s own limit allowed 256 (#2486).

1.0.26 replaces that with slot lending. A waiting parent keeps its slot and lends it to one child at a time: the first child runs on the parent’s slot, further children take free slots from the pool and wait while it is empty, and when the lent child finishes the slot passes to the parent’s next waiting child. A blocked parent therefore always has one child that can run, so a hierarchy of any depth completes even with max_inflight_components=1, and the components executing at once never exceed the cap. Counting the parents waiting on children, the components in flight are bounded by the cap times the depth of the hierarchy.

With max_inflight_components = 2 and an empty two-slot pool, the parent process_dir holds slot 1 while waiting on its children and lends it along an arrow to file 1, which runs on it; file 2 runs on slot 2 from the pool; file 3 and file 4 are drawn dashed and wait for a free slot.

Three related rules:

  • A component that awaits a function call, batched or not, keeps its slot; only mounting a child lends it.
  • Live components borrow the slot of the component that mounted them while it runs, then take slots from the pool.
  • Component deletion draws on the same pool.

Fair sharing between live components (v1.1.0)

Live components update on their own schedules, and one can have far more work queued than another: a new component’s first full index next to a refresh of a few changed items. A freed slot used to go to the oldest waiting component, so the component with the large backlog held back every other live component, and the small refresh waited behind the whole backlog (#2624).

The pool is now shared fairly between independent updates: an App.update(), one cycle of a live component, or one item update of mount_each() over a live map. A slot that frees up goes to whichever waiting update holds the fewest slots, then to the earliest waiter among equals. No slot is reserved, so an update that runs alone still gets the whole pool. In the test that pins this, a small refresh running beside a 400-leaf first index used to finish after 283 to 292 of the large leaves and now finishes after 80 to 82.

With max_inflight_components = 4, two update cards sit under a four-slot pool: first_index, a new component's first full run with 900 queued items, holds slots 1 and 2 on two running items; refresh, live with 3 changed items, holds slot 3 and has a dashed waiting lane, to which the freed slot 4 flows from the pool along an arrow.

Documentation Bound a hierarchy of components without deadlocking it. Setting max_inflight_components, how a parent lends its slot, and how live components share the limit fairly.

Smaller additions (v1.0.26)

  • Nix gets syntax-aware splitting and structural code patterns (#2434, by @mulatta).
  • regex_required_literals is public (#2525).
  • Error cause chains. Internal errors show their full cause chain, for example db error: ERROR: deadlock detected instead of db error (#2489).
  • The optional application telemetry tag is documented (#2539).
  • ConnectionFactory.close(). The Neo4j, FalkorDB and SurrealDB factories keep one connection per event loop, and the Neo4j and FalkorDB ones gain close(), which SurrealDB’s already had. FalkorDB needs falkordb 1.6.0 or later (#2510).

Rust SDK: memo frames, mount keys, and root-provider cleanup (v1.0.26)

  • Memo frames. Memoized calls join the enclosing caller’s frame (#2503), so a memoized caller no longer returns stale values or deletes its children’s outputs on a cache hit. Mounting inside #[function(memo)] is rejected, as in Python.
  • Mount keys. use_mount! and mount_each! key by memo identity, so touching one file no longer re-runs every component. Connectors use one sink per handler instead of one apply per row.
  • Root-provider cleanup. App::drop_state and normal updates remove targets declared through root providers (directory files and Postgres, SQLite, LanceDB and Qdrant tables, among others) when they are no longer declared (#2512). Breaking: the Neo4j and FalkorDB table and relation target functions take &ContextKey<Graph> instead of &Graph.
  • CI coverage. Every SDK feature is type-checked and its hermetic feature tests run (#2523).

Performance

Moving target states between components (v1.1.0)

When a component takes target states that another component owns, the commit did work proportional to the old owner’s whole tracking record for every moved target. The pending-retry check decoded the record once per moved target, and the first commit phase decoded it again, scanned every remaining item, and re-encoded it. Moving N targets from one owner was quadratic in N (#2621, from a fix by @badmonster0).

Each preempted owner’s record is now decoded once per commit and shared by both passes, items are taken out of it in place, and each modified record is encoded once for writing. Writes are unchanged, since there was already one upsert per old owner, and they still apply inside the same transaction as before.

Targets moved in one commitBeforeAfter
1,0008.7 s0.29 s
2,50067.2 s0.65 s
5,000242.7 s1.0 to 1.8 s

Debug build, LMDB, same machine for both columns. The previous changelog’s commit-path work covered the per-row cost of a large table; this removes the per-owner cost when ownership moves.

Fixes

Connector keys: collisions and lost writes (v1.1.0)

Every source item and every target state has a key. CocoIndex uses the key to match an object with its previous run and to decide what to create, update or delete. That has been the rule from the first release; what 1.1.0 changes is the edge cases where a connector built keys in a way that broke it. A Google Drive fix (#2485, by @Sujit-1509) started an audit of how every Python connector builds its keys. The audit found four shapes of bug, most of them edge cases, and 1.1.0 closes each one.

Two keys you choose can also reach one object, such as overlapping directories or two ContextKeys for one database. CocoIndex can’t detect that, so it stays your responsibility; the docs now explain where it can happen.

Two different objects shared one key

One overwrote or deleted the other.

  • Google Drive: files with the same name, even in one folder. Items are now keyed by file ID (#2485).
  • S3, Azure Blob and OCI: object keys that differ only in a doubled or leading /, a ./ segment, or \ versus /; with prefix="docs", docs/a.md and docsa.md; and paths in different buckets, which compared equal (#2601).
  • Kafka: the same message key on two topics of one topic_as_map feed. A tombstone on one topic deleted the other topic’s outputs (#2597).
  • Neo4j and FalkorDB: a node label and a relationship type with the same name, and derived edge IDs of different endpoints (1 versus "1", IDs containing _). The fix comes as the new node and relationship target APIs below (#2612).
  • SurrealDB: derived relation IDs of different endpoints, such as doc:'chunk_1' and doc_chunk:1 linked to the same tag (#2599).
  • zvec: 1 and "1" in an Any, object or union key column became one document (#2608, #2613).
  • Postgres: two vector indexes on one table whose names Postgres truncates to the same 63 bytes, so only one of them could exist (#2603, #2615).
  • Valkey: a payload key or FieldDef named vector, the hash field that holds the document’s vector (#2606).

One object’s key changed between runs

The delete for the old key removed what the new key had just written.

  • localfs: a case-only rename on macOS and Windows, or ./a.txt changed to a.txt. The file stayed missing until its content changed (#2598).
  • Qdrant: a UUID point ID written in another text form: upper case, no hyphens, or a URN (#2602).
  • Kafka target: a message key that switched between str and bytes. Its tombstone erased the record on a compacted topic (#2597).
  • Neo4j and FalkorDB: a renamed vector index (#2611).

A delete missed its object

Stale data stayed behind.

  • LanceDB: every row delete failed for a key column that needs quoting (doc-id), or that holds binary, fixed-size binary or the int64 minimum (#2600).
  • Doris: keys containing \. Each update appended a duplicate row, removed items stayed, and a trailing \ broke the statement (#2605).
  • Neo4j and FalkorDB: an edge with an explicit ID whose endpoints changed. Neo4j kept the old edge beside the new one, and FalkorDB failed with a unique constraint violation (#2611). Dropping a node table, by un-declaring it or with cocoindex drop, left its vector indexes (#2618).

A change went undetected

The item was not reprocessed.

  • S3, Azure Blob, OCI and Google Drive binary files: an overwrite that kept the modification time (S3 times are whole seconds). An equal time is now checked against the ETag or MD5 (#2601).
  • Doris: a changed vector or inverted index parameter (index_type, metric_type, dimension, parser). It now rebuilds the table (#2605).

Graph targets: node and relationship APIs

The Neo4j and FalkorDB fix for a label and a relationship type sharing a name, and for ambiguous derived edge IDs, is a new pair of target APIs with the same parameters as the old ones (#2612):

python
# Before (deprecated; still works with a DeprecationWarning)
entities = await neo4j.mount_table_target(KG_DB, "Entity", entity_schema, primary_key="value")
edges = await neo4j.mount_relation_target(
    KG_DB, "RELATIONSHIP", entities, entities, rel_schema, primary_key="id"
)
edges.declare_relation(
    from_id="CocoIndex", to_id="Neo4j", record=RelationshipRow(id="rel-1", predicate="writes_to")
)

# After
entities = await neo4j.mount_node_target(KG_DB, "Entity", entity_schema, primary_key="value")
edges = await neo4j.mount_relationship_target(
    KG_DB, "RELATIONSHIP", entities, entities, rel_schema, primary_key="id"
)
edges.declare_relationship(
    from_id="CocoIndex", to_id="Neo4j", record=RelationshipRow(id="rel-1", predicate="writes_to")
)

On the new APIs, Neo4j labels and relationship types can’t contain __ or end with _, because constraint and index names join the label to a property name with __; on both connectors, derived edge IDs need int or str endpoint IDs.

Switching an existing table keeps its nodes, edges, constraints and indexes, but takes two updates:

  1. Add managed_by="user" to each deprecated declaration and run an update. Removing those declarations now deletes nothing.
  2. Replace each call with its replacement, without managed_by="user", and run another update.

Without the first step, removing a deprecated declaration destroys its table, possibly after the new table has written it. The steps are in the Neo4j and FalkorDB docs under “Switching an existing table”.

Documentation A target state's identity is its key. How CocoIndex matches a declared target with its previous run, and where two keys you choose can reach one physical object.

Memo fingerprints depend only on values (v1.1.0)

A memo key or fingerprint used to depend on object identity in two ways. A structure that referenced one object twice fingerprinted differently from one holding two equal copies. And a temporary object freed during fingerprinting could produce a wrong memo hit, rather than a harmless recomputation, once Python reused its ID for another temporary value. Both are fixed by canonicalizing shared values independently and tracking only the active recursion path for cycle detection (#2411, by @hatemadi15, closing #2391). The one-time effect on fingerprints is in the upgrade notes.

Hangs and shutdown (v1.0.26 to v1.1.0)

  • GPU pool. A GPU released to a cancelled acquire goes back to the pool (#2595). Before, every later acquire of that GPU hung.
  • Cancelled source listings. Cancelling a pending read of a synchronous source listing (localfs, Google Drive) no longer hangs asyncio.run() at shutdown (#2619, by @Sujit-1509).
  • Resources released at stop. coco.stop() and coco.stop_blocking() release the lifespan-provided resources that registered apps held, instead of keeping them until each app’s next update (#2425, by @hatemadi15).
  • Free-threaded Python 3.14: the background event loop no longer starts twice, and an environment can be reopened without close() after a localfs-reading app (#2490, #2499).

LiteLLM embedder: media-like text and refused requests (v1.0.26)

LiteLLMEmbedder embeds text starting with data:…;base64,, gs:// or files/ as text rather than treating it as a file reference. A request litellm refuses to build no longer retries until the ten-minute deadline; the batch is split to isolate the bad text, the same mechanism RetryWithSmallerBatch introduced last cycle (#2532).

Other fixes

  • Graph drop (v1.0.26). cocoindex drop and un-declaring a table delete the Neo4j and FalkorDB nodes and relationships the app wrote, and a primary-key change rebuilds the table instead of leaving old-key nodes behind (#2498).
  • Transient errors (v1.0.26). A Neo4j apply batch retries on transient errors such as DeadlockDetected instead of failing the component (#2491).
  • code_match prefilters (v1.0.26). Regex prefilters no longer drop nested alternatives, which made searches miss matches (#2525).
  • C# file-scoped namespaces (v1.1.0). A file-scoped namespace (namespace X;) shows in the render_match context frames of the declarations after it (#2629).
  • VectorSchema(dtype=np.float32) (v1.1.0). A schema whose dtype is the class np.float32 rather than a dtype instance now reloads on the second run. Other classes the state reader can’t load now fail when first written, with a hint to use coco.unpickle_safe (#2536, by @rtmalikian).
  • Passwords out of repr (v1.1.0). Snowflake and Doris connection configs leave the password out of their repr (#2620, by @jerryqu-coco).
  • OCI Object Storage (v1.1.0). Zero-length reads, and partial reads of empty objects, return empty bytes instead of failing with HTTP 416 (#2466, by @pranav440).
  • Reopening an open db_path (v1.1.0). The RuntimeError now names the path and Environment.close(), instead of a misleading message about different options (#2561, by @Siddesh-bype).

Internal improvements

Documentation

  • Key identity. The docs explain that a target state’s identity is its key, and where two keys can reach one object: target states, contexts, mount_each(), and Postgres, SQLite and BigQuery tables. Mounting one component path twice in one run was already an error; its message now says how to fix it (#2609, #2610).
  • Examples that failed when run as written.
    • The SeparatorSplitter usage example (#2476, by @March-7).
    • The localfs lifespan example, which named a coco.EnvBuilder that doesn’t exist (#2397, by @Siddesh-bype).
    • The get_context example, which called the async coco.default_env() as if it were synchronous, and the App guide’s component path tree (#2416, by @hardness1020).
  • drop_app semantics. The docs now say why drop_app clears the sub-database instead of removing it (#2431, by @hardness1020).
  • HTTP clients. The docs standardize on httpx and explain why Doris Stream Load keeps aiohttp (#2626).
  • Valkey client_info_tag defaults to "cocoindex" (#2480, by @Jonathan-Improving).
  • Agent skills install with uv rather than pip (#2546, #2552), a release playbook was added (#2627), and the Rust guidelines distilled from review comments are linked from AGENTS.md (#2500).

Security and dependencies

  • litellm 1.107.0 was published to PyPI with a credential stealer and a backdoor. It is excluded from every litellm requirement: the litellm, entity_resolution_llm and all extras, and the examples. The locked version, 1.96.2, is unaffected (#2576).
  • Rust dependencies. The legacy rustls 0.21 stack is dropped from the S3 client, which cleared the rustls-webpki advisories (#2544); aws-smithy-json 0.62.7 is taken for GHSA-8ffr-xgwf-xj56, which raises the minimum Rust version to 1.91.1 (#2542); rustls moves to 0.23.46 for GHSA-2mjx-qc3c-rqvc (#2543).
  • Dependabot. Security updates are grouped, one PR per ecosystem (#2591).

CI

The main change is that each landed commit now gets exactly one full run: a merge queue with tiered checks, a gate job that the required status checks point at, a skip for drafts, and support for stacked PRs (#2584). Around it:

  • Job setup is shared through a composite action (#2578), sccache objects stay on local disk and are saved once per leg (#2583), and jobs free only the disk space they need (#2579).
  • The Rust SDK checks run only for SDK changes, plus daily (#2622); CodeQL scans main daily instead of every pull request (#2596).
  • Testcontainers images are pre-pulled before the first test, with a stall timeout and retries (#2551, #2556); ruff check and clippy are gated in pre-commit (#2540); root-relative /docs/ links are checked against the build output (#2557, #2559).

Summary

This cycle adds a preview that lists what an update would do to the targets and leaves nothing behind, and a way to record a logic dependency for one call so that bumping it re-runs only the calls that used it and their callers. A batched function can cap how many batches run at once and pay one rate-limit token per batch, and the component limit holds across a hierarchy of any depth and is shared fairly between live components. Moving target states between components is no longer quadratic. Underneath, the loose ends in how connectors build keys were closed, so one key per object holds in the edge cases too, memo fingerprints depend only on values, a cancelled GPU acquire no longer poisons the pool, and shutdown no longer hangs on a cancelled source listing.

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 nine first-time contributors.

@Sujit-1509

Sujit-1509

Thanks @Sujit-1509 for keying Google Drive items by file ID, the fix that prompted the key audit across every Python connector, and for keeping a cancelled source listing from hanging shutdown.

@hatemadi15

hatemadi15

Thanks @hatemadi15, a first-time contributor, for making memo fingerprints depend only on values, which also closed a wrong-memo-hit bug from reused object IDs, and for releasing retained app resources at coco.stop().

@hardness1020

@Siddesh-bype

@mulatta

mulatta

Thanks @mulatta, a first-time contributor, for adding Nix syntax support to code splitting and structural code patterns.

@rtmalikian

rtmalikian

Thanks @rtmalikian, a first-time contributor, for failing fast at write time for classes the state reader rejects, so the error arrives on the run that caused it rather than the next one.

@pranav440

@jerryqu-coco

@March-7

March-7

Thanks @March-7, a first-time contributor, for fixing the SeparatorSplitter usage example so it runs as written.

@Jonathan-Improving

@Avicennasis

Build fresh context for your agents.

CocoIndex keeps derived data in sync with its sources. Only what changed re-runs.