Tree stores

For a step-by-step introduction to memory, SQLite, and continuing saved sessions, start with Save and resume a session. To implement another backend, see Implement a custom Store in Advanced Usage.

SyncStore and AsyncStore store immutable trees. Sessions, Docker builds, and upload reuse share one Store. Helpers and direct constructors accept it through store=. The Store holds references; images and files remain on the ConTree server.

Nodes and tree kinds

A Node contains id, parent_id, kind, and JSON-object data. Its generated ID identifies a record, not a command, image, timestamp, or execution order. Retain the same Node when retrying a write. Two executions can have equal inputs and still produce different nodes.

A tree has one root and an immutable string kind. Standard consumers use session, docker-build, upload-cache, and file-sources. Applications can supply their own kinds. A node’s kind describes its step, such as run, stage, or config. Tree kinds let a CLI exclude service histories before pagination.

from contree_sdk.store import Node, SyncMemoryStore

with SyncMemoryStore() as store:
    store.create_session("example", kind="session", ttl=3600)
    root = Node(parent_id=None, kind="init", data={"image_uuid": "base-image"})
    store.append("example", root)
    first = Node(parent_id=root.id, kind="run", data={"user": {"label": "first"}})
    second = Node(parent_id=root.id, kind="run", data={"user": {"label": "second"}})
    store.append("example", first)
    store.append("example", second)
    store.append("example", first)  # A repeated delivery creates no duplicate.
    assert store.get_nodes("example", (first.id, "missing")) == (first, None)
    assert len(store.list_children("example", root.id).items) == 2
    page = store.list_sessions(kind="session", limit=20)
    assert page.items[0].session_id == "example"
from contree_sdk.store import AsyncMemoryStore, Node

async with AsyncMemoryStore() as store:
    await store.create_session("example", kind="session", ttl=3600)
    root = Node(parent_id=None, kind="init", data={"image_uuid": "base-image"})
    await store.append("example", root)
    first = Node(parent_id=root.id, kind="run", data={"user": {"label": "first"}})
    second = Node(parent_id=root.id, kind="run", data={"user": {"label": "second"}})
    await store.append("example", first)
    await store.append("example", second)
    await store.append("example", first)
    assert len((await store.list_children("example", root.id)).items) == 2
    page = await store.list_sessions(kind="session", limit=20)
    assert page.items[0].session_id == "example"

Contract

  • create_session() creates an empty tree. Repeating it with the same kind returns the existing tree without renewing its TTL. A different kind raises ValueError.

  • append() requires a live tree and a parent in that tree, except for its root. It atomically inserts one node. Equal identity and contents succeed on retry; changed contents or a second root raise NodeConflictError without overwriting.

  • Reads return independent JSON values. Mutating a returned dictionary does not modify storage. Updating a saved node requires a new node.

  • Calls are atomic across clients sharing a backend. A successful write is visible to subsequent reads from any client, unless deletion or expiry removes the tree. Concurrent calls may take either order. Separate calls are not a transaction. A lost response leaves the write outcome unknown; retry an append with the same prepared Node.

  • get_session() returns None for a missing or expired tree. Reading a missing node or appending to a missing tree raises ValueError.

  • get_nodes(session_id, node_ids) reads up to 1000 identities in one atomic call. The returned tuple preserves input order and duplicates. Missing nodes occupy None slots. A missing or expired tree raises ValueError, including for an empty batch. Each returned node contains independent JSON values.

  • list_sessions(), list_nodes(), and list_children() filter before pagination and order by identity. list_history() follows parents from the specified node. Pass next_cursor with the same selection; None means the end. Pages are live reads and do not share a snapshot across calls.

  • List methods request 1000 items by default. A backend can return fewer items with a continuation cursor to bound response size. Always follow next_cursor; a short page does not imply the end. Treat cursors as opaque positions. They are not authorization tokens; a remote backend must check access separately.

  • TTL belongs to the whole tree. set_session_ttl() sets a new expiry from now. Reads and append do not renew it. ttl=None uses the implementation policy; the supplied Memory and SQLite implementations retain the tree until deletion. Zero expires immediately; negative or nonfinite TTL raises ValueError.

  • delete_session() removes the tree and returns whether stored data existed, including expired data. It does not delete remote images or cancel operations. A late append cannot recreate a deleted tree. Explicit creation can reuse its name; use a new session ID when a new lifetime must remain distinguishable.

Execution belongs to Session

The Store has no selected branch or shared head. Each Session keeps its own position and local branch names. Two actors executing from the same parent add two children. A late result keeps its captured parent even after local navigation. Use separate Session instances for independent actors. Serialize navigation when sharing one Session across threads. See Branch, switch, and roll back and Save and resume a session for selection and explicit resume.

For a live tree with root R, actors can append R → A and R → B in either order. Both nodes remain available. Each actor can then extend its own node or explicitly select a node written by another actor. Repeating the same append adds no duplicate. Changing a node’s parent or data under the same ID raises NodeConflictError. This holds for any number of actors because each successful append adds one node without changing existing nodes.

Each actor must retain its selected (session_id, node_id) and the prepared Node for a retry. Use explicit node_id when opening a shared session; automatic selection is not a durable actor position. Store does not merge filesystem changes or make VM execution exactly once.

An identity cursor is not an event subscription. Concurrent inserts with IDs before that cursor will not appear in the remaining pages. Restart enumeration to discover them, or exchange explicit node IDs between actors. The ancestry of a known node is stable while other actors append, so list_history() can page that path without mixing branches. Deletion and expiry can still interrupt it.

Pending operations and their completions are immutable nodes too. A completion node contains the response and retained image, so atomic append also protects completion from partial writes. See Advanced: resume a background operation.

Session lists operation registrations by page and reads their completion nodes with get_nodes(). It does not fetch each registration again. Successful image commits record a staging_complete marker in the SDK payload. Staging recovery stops there because that image contains all staged inputs captured for the operation. Older records remain readable through ancestry traversal. Failed or discarded operations do not consume their source staging.

Docker searches run children of the selected parent and use nodes for FROM. It still checks Docker hashes in the client. Large sibling sets and accumulated FROM records can require multiple pages. Opening a session without node_id still enumerates its tree to detect ambiguous continuation; pass node_id when resuming a known position.

Custom backends implement this contract through SyncStore or AsyncStore. Atomicity must cover all clients, including separate processes and connections. An SDK-side lock alone does not meet that requirement. Memory uses SQLite’s in-memory engine; SQLite stores persist to a file. AsyncSQLiteStore uses aiosqlite. Existing databases from earlier experimental contracts are not migrated into these trees.

class contree_sdk.store.AsyncMemoryStore(*, clock=<built-in function time>)[source]
async append(session_id, node)[source]
Return type:

None

async close()[source]
Return type:

None

async create_session(session_id, *, kind='session', ttl=None)[source]
Return type:

SessionInfo

async delete_session(session_id)[source]
Return type:

bool

async get_node(session_id, node_id)[source]
Return type:

Node

async get_nodes(session_id, node_ids)[source]

Read up to 1000 identities atomically, preserving input order and duplicates.

Missing nodes occupy None slots. A missing or expired tree raises ValueError, including for an empty batch. Returned nodes contain independent JSON.

Return type:

tuple[Node | None, ...]

async get_session(session_id)[source]
Return type:

SessionInfo | None

async list_children(session_id, parent_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

async list_history(session_id, from_node, *, limit=1000, cursor=None)[source]
Return type:

Page[Node]

async list_nodes(session_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

async list_sessions(*, kind=None, prefix='', limit=1000, cursor=None)[source]
Return type:

Page[SessionInfo]

async set_session_ttl(session_id, ttl)[source]
Return type:

SessionInfo

class contree_sdk.store.AsyncSQLiteStore(db_path, *, clock=<built-in function time>)[source]

Native aiosqlite implementation of the same tree contract.

async append(session_id, node)[source]
Return type:

None

async close()[source]
Return type:

None

async create_session(session_id, *, kind='session', ttl=None)[source]
Return type:

SessionInfo

async delete_session(session_id)[source]
Return type:

bool

async get_node(session_id, node_id)[source]
Return type:

Node

async get_nodes(session_id, node_ids)[source]

Read up to 1000 identities atomically, preserving input order and duplicates.

Missing nodes occupy None slots. A missing or expired tree raises ValueError, including for an empty batch. Returned nodes contain independent JSON.

Return type:

tuple[Node | None, ...]

async get_session(session_id)[source]
Return type:

SessionInfo | None

async list_children(session_id, parent_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

async list_history(session_id, from_node, *, limit=1000, cursor=None)[source]
Return type:

Page[Node]

async list_nodes(session_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

async list_sessions(*, kind=None, prefix='', limit=1000, cursor=None)[source]
Return type:

Page[SessionInfo]

async set_session_ttl(session_id, ttl)[source]
Return type:

SessionInfo

class contree_sdk.store.AsyncStore[source]

Async equivalent of SyncStore with the same guarantees.

abstractmethod async append(session_id, node)[source]
Return type:

None

abstractmethod async close()[source]
Return type:

None

abstractmethod async create_session(session_id, *, kind='session', ttl=None)[source]
Return type:

SessionInfo

abstractmethod async delete_session(session_id)[source]
Return type:

bool

abstractmethod async get_node(session_id, node_id)[source]
Return type:

Node

abstractmethod async get_nodes(session_id, node_ids)[source]

Read up to 1000 identities atomically, preserving input order and duplicates.

Missing nodes occupy None slots. A missing or expired tree raises ValueError, including for an empty batch. Returned nodes contain independent JSON.

Return type:

tuple[Node | None, ...]

abstractmethod async get_session(session_id)[source]
Return type:

SessionInfo | None

abstractmethod async list_children(session_id, parent_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

abstractmethod async list_history(session_id, from_node, *, limit=1000, cursor=None)[source]
Return type:

Page[Node]

abstractmethod async list_nodes(session_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

abstractmethod async list_sessions(*, kind=None, prefix='', limit=1000, cursor=None)[source]
Return type:

Page[SessionInfo]

abstractmethod async set_session_ttl(session_id, ttl)[source]
Return type:

SessionInfo

class contree_sdk.store.Node(*, parent_id, kind, data=<factory>, id=<factory>)[source]
parent_id: str | None
kind: str
data: dict[str, str | int | float | bool | None | list[str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]] | dict[str, str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]]]
id: str
exception contree_sdk.store.NodeConflictError[source]

An identity already names a different node, or the tree already has a root.

add_note()

Exception.add_note(note) – add a note to the exception

args
with_traceback()

Exception.with_traceback(tb) – set self.__traceback__ to tb and return self.

contree_sdk.store.NodeId

alias of str

class contree_sdk.store.Page(items, next_cursor=None)[source]
next_cursor: str | None = None
items: tuple[TypeVar(T), ...]
class contree_sdk.store.SessionInfo(session_id, kind, created_at, expires_at)[source]
session_id: str
kind: str
created_at: float
expires_at: float | None
class contree_sdk.store.SyncMemoryStore(*, clock=<built-in function time>)[source]
append(session_id, node)[source]
Return type:

None

close()[source]
Return type:

None

create_session(session_id, *, kind='session', ttl=None)[source]
Return type:

SessionInfo

delete_session(session_id)[source]
Return type:

bool

get_node(session_id, node_id)[source]
Return type:

Node

get_nodes(session_id, node_ids)[source]

Read up to 1000 identities atomically, preserving input order and duplicates.

Missing nodes occupy None slots. A missing or expired tree raises ValueError, including for an empty batch. Returned nodes contain independent JSON.

Return type:

tuple[Node | None, ...]

get_session(session_id)[source]
Return type:

SessionInfo | None

list_children(session_id, parent_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

list_history(session_id, from_node, *, limit=1000, cursor=None)[source]
Return type:

Page[Node]

list_nodes(session_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

list_sessions(*, kind=None, prefix='', limit=1000, cursor=None)[source]
Return type:

Page[SessionInfo]

set_session_ttl(session_id, ttl)[source]
Return type:

SessionInfo

class contree_sdk.store.SyncSQLiteStore(db_path, *, clock=<built-in function time>)[source]

Persist trees. None TTL means no expiry for this implementation.

append(session_id, node)[source]
Return type:

None

close()[source]
Return type:

None

create_session(session_id, *, kind='session', ttl=None)[source]
Return type:

SessionInfo

delete_session(session_id)[source]
Return type:

bool

get_node(session_id, node_id)[source]
Return type:

Node

get_nodes(session_id, node_ids)[source]

Read up to 1000 identities atomically, preserving input order and duplicates.

Missing nodes occupy None slots. A missing or expired tree raises ValueError, including for an empty batch. Returned nodes contain independent JSON.

Return type:

tuple[Node | None, ...]

get_session(session_id)[source]
Return type:

SessionInfo | None

list_children(session_id, parent_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

list_history(session_id, from_node, *, limit=1000, cursor=None)[source]
Return type:

Page[Node]

list_nodes(session_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

list_sessions(*, kind=None, prefix='', limit=1000, cursor=None)[source]
Return type:

Page[SessionInfo]

set_session_ttl(session_id, ttl)[source]
Return type:

SessionInfo

class contree_sdk.store.SyncStore[source]

Store independent trees with atomic, idempotent node insertion.

Repeating create_session with the same kind returns the existing tree without renewing TTL. append requires a live tree and an existing parent, except for its single root. Repeating an identical node succeeds; changed data conflicts. Reads return detached JSON. Node IDs are identities, not execution order. Pages use stable identity order; they are live reads, not a shared snapshot. None TTL delegates retention to the implementation.

Calls are atomic across clients sharing a backend. Once a write succeeds, subsequent reads through any client observe it unless the tree was deleted or expired. Concurrent calls may take either order; separate calls do not form a transaction. A lost response does not imply that the write failed. Retry append with the same node identity, parent, kind, and data.

abstractmethod append(session_id, node)[source]
Return type:

None

abstractmethod close()[source]
Return type:

None

abstractmethod create_session(session_id, *, kind='session', ttl=None)[source]
Return type:

SessionInfo

abstractmethod delete_session(session_id)[source]
Return type:

bool

abstractmethod get_node(session_id, node_id)[source]
Return type:

Node

abstractmethod get_nodes(session_id, node_ids)[source]

Read up to 1000 identities atomically, preserving input order and duplicates.

Missing nodes occupy None slots. A missing or expired tree raises ValueError, including for an empty batch. Returned nodes contain independent JSON.

Return type:

tuple[Node | None, ...]

abstractmethod get_session(session_id)[source]
Return type:

SessionInfo | None

abstractmethod list_children(session_id, parent_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

abstractmethod list_history(session_id, from_node, *, limit=1000, cursor=None)[source]
Return type:

Page[Node]

abstractmethod list_nodes(session_id, *, kind=None, limit=1000, cursor=None)[source]
Return type:

Page[Node]

abstractmethod list_sessions(*, kind=None, prefix='', limit=1000, cursor=None)[source]
Return type:

Page[SessionInfo]

abstractmethod set_session_ttl(session_id, ttl)[source]
Return type:

SessionInfo