Stores
A store is where the runtime keeps one kind of state: where each piece of labware is, which incidents were recorded, which teachpoints are taught. Every store is a small interface with a working default, so SystemRuntime(build.system, event_bus=build.event_bus) is a valid call, and any of them can be swapped for an implementation that talks to a database, a remote service, or whatever else suits your deployment. Read this page when you are deciding what your deployment has to keep across a restart, or when you are writing a backend of your own.
The shipped defaults do not outlive the process
"SQLite" here means an in-memory engine. Every SQLite-backed default is built on create_memory_engine(): sqlite+aiosqlite:// with a static pool, so one connection is shared. The state survives a runtime rebuild inside the same process and does not survive the process. orca.runtime.db.create_sqlite_engine(path) exists and applies the WAL pragmas a durable deployment wants, but nothing in the framework calls it: pointing a deployment at a file is the deployment's job.
The labware store is genuinely in memory. InMemoryLabwareStore is the only implementation in orca; there is no SQLite labware table. If labware positions have to survive a process restart, that is the first store to replace.
What comes back after a restart
At start() the runtime re-places every persisted labware onto the system graph and writes an observation-gap record against each one, so the numbers stand but stop counting as confirmed. Which of those facts actually return depends on which stores you made durable:
| Fact | Comes from | Durable by default? |
|---|---|---|
| Where each labware is | ILabwareStore.list_active_locations | No. InMemoryLabwareStore is in-process. |
| Well volumes | folded from IOpsHistoryStore | Only within the process, with the default ephemeral store. |
| Tip layout on a rack | folded from IOpsHistoryStore | Same. |
| What a liquid handler head is carrying | folded from IOpsHistoryStore | Same. |
| Incidents | IIncidentStore | Only within the process, with the in-memory SQLite engine. |
| Execution records and their threads | IExecutionRecordStore | Same. |
| Teachpoints, deck layouts, access configs, move defaults, grip profiles | the store factory | Same. |
| Deployment profiles | FileDeploymentProfileStore | Yes, on disk. |
Every completed labware move is mirrored into the labware store through a background queue, so the position that returns is where the plate ended up, not where it was registered. A plate in a mover's jaws is persisted as being in the jaws, because after a restart the arm is still holding it, and a model that says otherwise would send it back into an empty slot.
What comes back is re-seeded as STALE rather than confirmed, and an operator settles it. See Runtime state.
When to swap a default
| Use case | Swap what |
|---|---|
| Labware positions must survive a process restart | ILabwareStore to a database-backed store |
| Well volumes and tip state must survive a process restart | IOpsHistoryStore to JsonlOpsHistoryStore(<dir>) or your own |
| Incidents must survive restarts and feed an alerting pipeline | IIncidentStore behind IncidentService |
| An operator UI queries labware history months back | ILabwareStore to a database-backed store |
| Teachpoints managed in an external system | ITeachpointStore behind TeachpointService |
| Approach and retract patterns defined per-deployment in a database | IAccessConfigStore |
| Profiles managed in a central admin tool | IDeploymentProfileStore |
| Events must land in a database plus a websocket fan-out | Register an IEventSink per channel (see Events) |
Most local deployments run on the shipped defaults plus a durable ops-history directory and an event sink or two. Database-backed everything is what a hosted deployment does; the framework does not require it.
The full set
Most stores follow a Service-over-Store split: a *Service owns the orchestration (locking, events, lifecycle) and delegates raw persistence to an injected store that satisfies the store Protocol. That is why the runtime constructor takes services for those, not stores.
| Interface | Holds | Default impl |
|---|---|---|
IEventSink | Receives RuntimeEvents as they fire. External integration point. | None; sinks are registered explicitly. |
ILabwareStore | Labware identity, current position, position history, relationships. | InMemoryLabwareStore |
IAccessConfigStore | Named approach and retract patterns referenced by teachpoints. | AccessConfigService over SqliteAccessConfigStore |
ITeachpointStore | Named transporter positions. | TeachpointService over SqliteTeachpointStore |
IDeckLayoutStore | Named PLR deck configurations per liquid handler. | DeckLayoutService over SqliteDeckLayoutStore |
IMoveDefaultsStore | Per-transporter move-parameter defaults. | MoveDefaultsService over SqliteMoveDefaultsStore |
IGripProfileStore | Per-labware-type grip parameters, layered over a transporter's defaults. | GripProfileService over SqliteGripProfileStore |
IDeploymentProfileStore | Named variable-default bundles loaded at submit time. | NullDeploymentProfileStore, or FileDeploymentProfileStore over a directory of JSON files |
IIncidentStore | Queryable record of runtime incidents. | IncidentService over SqliteIncidentStore |
IExecutionRecordStore | Persisted per-execution records and their threads. | ExecutionRecordService over SqliteExecutionRecordStore |
IOpsHistoryStore | Append-only operation history: every operation any action performed. | JsonlOpsHistoryStore.ephemeral() |
| labware catalog store | Labware DEFINITIONS catalog, the single CRUD path. | seeded_labware_catalog_service() over SqliteLabwareCatalogStore |
Most of the interfaces live in orca.runtime.interfaces. Three do not: IIncidentStore is in orca.runtime.incident_store, IExecutionRecordStore in orca.runtime.execution_record_store, and IOpsHistoryStore in orca.state.ops_store.
IEventSink is a Protocol with one method: on_event(event: RuntimeEvent) -> None. Register sinks with runtime.register_sink(...). See Events.
There is no IStateStore. Live execution state lives on the runtime. The execution-record store exists so operator-facing status is honest after a restart, not so an execution can resume mid-flight.
Wiring stores into a runtime
from orca.runtime.system_runtime import SystemRuntime
from orca.runtime.labware_store import InMemoryLabwareStore
from orca.runtime.incident_service import IncidentService
runtime = SystemRuntime(
build.system,
event_bus=build.event_bus,
labware_store=InMemoryLabwareStore(),
incident_service=IncidentService(MyCustomIncidentStore()),
# also: execution_record_service, access_config_service,
# move_defaults_service, grip_profile_service, profile_store,
# ops_history_store, labware_catalog_store, gateway_registry,
# connection_source.
)
Teachpoints and deck layouts are per-device: they come from the store factory passed to build_topology and build_system, not from a SystemRuntime kwarg. The full argument list is in Runtime.
Ownership: what the runtime disposes
The runtime tracks which services it built itself and closes only those at shutdown(). Six services follow that rule: incidents, execution records, access configs, move defaults, grip profiles, and the labware catalog. The same flag decides whether the runtime creates the schema and starts the drain task at start(). An injected service is its injector's to manage, all the way through.
One deliberate exception: ExecutionTrackingSink is registered whether or not you injected the record service, because the runtime owns event delivery either way.
labware_store, profile_store, ops_history_store, gateway_registry and connection_source are never disposed by the runtime.
The store factory
Teachpoints, deck layouts and the other per-deployment registries come from an IRuntimeStoreFactory, which is passed to the async builder and held on the resulting SystemBuild:
from orca.runtime.store_factory import InMemoryRuntimeStoreFactory
stores = InMemoryRuntimeStoreFactory()
build = await orca.build_system(name="my_lab", topology=topology, stores=stores)
The name is not quite accurate. Only variable_store() is plain in-process state. profiles() is file-backed, defaulting to ./profiles relative to the working directory, because deployment profiles are operator artefacts that should outlive a process. Everything else is SQLite over an in-memory engine.
Two hooks matter if you write your own factory. preload_for_build() is called once before the build, for stores that need async setup. apply_seeds() reconciles topology-declared seeds into the substrate afterwards, insert-if-missing, so an operator edit made through REST or the CLI is never overwritten by a seed.
Interface surfaces
The interfaces are small and mostly CRUD. Each Protocol's docstring is the canonical reference. The highlights below cover what matters when writing a custom backend.
ILabwareStore
class ILabwareStore(Protocol):
async def register(self, instance: LabwareInstance, execution_id: str | None = None) -> None: ...
async def get_by_id(self, labware_id: str) -> LabwareInstance | None: ...
async def get_by_barcode(self, barcode: str) -> LabwareInstance | None: ...
async def update_location(self, labware_id: str, position_id: str) -> None: ...
async def get_by_position(self, position_id: str) -> LabwareInstance | None: ...
async def list_active_locations(self) -> list[tuple[str, str]]: ...
async def clear_location(self, labware_id: str) -> None: ...
async def get_location_history(self, labware_id: str) -> list[tuple[str, str | None, float]]: ...
async def record_relationship(self, relationship: LabwareRelationship) -> None: ...
async def get_relationships(self, labware_id: str) -> list[LabwareRelationship]: ...
async def delete(self, labware_id: str) -> None: ...
clear_location drops a labware's ACTIVE position while keeping its row and its history. A lifecycle end is not a retraction of the record, and without it the next boot would resurrect a disposed plate at its end slot and block that slot for the next run.
delete must be idempotent: unknown ids are no-ops, so the operator sweep surfaces can work best-effort without choking on partially-removed state. get_by_position returns the labware persisted at a position, most-recently-registered winning on ties, so a resident reagent rebinds to its stable identity.
This is the one store Protocol here with no create_schema / aclose.
ITeachpointStore
class ITeachpointStore(Protocol):
async def get(self, position_id: str) -> Teachpoint | None: ...
async def resolve(self, position_id: str) -> Teachpoint | None: ...
async def list(self) -> list[Teachpoint]: ...
async def add(self, teachpoint: Teachpoint) -> None: ...
async def update(self, teachpoint: Teachpoint) -> None: ...
async def delete(self, position_id: str) -> bool: ...
async def create_schema(self) -> None: ...
async def aclose(self) -> None: ...
resolve(position_id)is the wire source of truth: it returns a fully-flattenedTeachpointwithAccessConfigfields inlined. Use it when handing a teachpoint payload to a remote driver. The store is consulted at every dispatch, so mid-run edits are visible to the next move.create_schema/acloseare the lifecycle hooks the owningTeachpointServicecalls.
add raises on a duplicate position id. update raises on an unknown one. delete returns True only if something was removed.
IDeckLayoutStore
class IDeckLayoutStore(Protocol):
async def get(self, name: str) -> DeckLayoutConfig | None: ...
async def list(self) -> list[tuple[str, DeckLayoutConfig]]: ...
async def add(self, name: str, config: DeckLayoutConfig) -> None: ...
async def update(self, name: str, config: DeckLayoutConfig) -> None: ...
async def delete(self, name: str) -> bool: ...
async def create_schema(self) -> None: ...
async def aclose(self) -> None: ...
Deck layouts are rebuild-required. A running LiquidHandler has its deck configured once and cannot be reconfigured mid-run; physical labware cannot reposition while motion is in flight. Edits take effect on the next rebuild.
build_system awaits the async get to derive child Locations from the layout's carriers, reading the live store with no sync snapshot in front of it.
IMoveDefaultsStore and IGripProfileStore
class IMoveDefaultsStore(Protocol):
async def get(self, transporter_name: str) -> MoveParameterPatch | None: ...
async def list(self) -> dict[str, MoveParameterPatch]: ...
async def set(self, transporter_name: str, patch: MoveParameterPatch) -> None: ...
async def delete(self, transporter_name: str) -> bool: ...
async def create_schema(self) -> None: ...
async def aclose(self) -> None: ...
IGripProfileStore has the same shape, keyed by labware_type instead of transporter name. They are the two persisted layers of move-parameter resolution: the arm's defaults, then the labware's profile over them. Every transporter is bound to the move-defaults service and every mover to the grip-profile service at start(), so edits reach the next move without a rebuild. See Move parameters.
IAccessConfigStore and IDeploymentProfileStore
Both follow the same CRUD shape: get returns None for an unknown name, add raises on duplicates, update raises on unknown, delete returns whether anything changed. IAccessConfigStore also carries create_schema / aclose. IDeploymentProfileStore is the only store Protocol here that carries neither.
Editing a deployment profile does not affect a running execution. The new body is picked up at the next submission naming that profile.
IIncidentStore
A Protocol, not a base class. Extend it by implementing the Protocol and injecting your store through IncidentService. The store is the dumb per-database persistence layer; the Service owns the off-loop record queue, the locking, and the event emission.
from datetime import datetime
from orca.runtime.incident_store import IIncidentStore, IncidentCategory, SystemIncident
from orca.runtime.incident_service import IncidentService
class MyDbIncidentStore: # satisfies IIncidentStore structurally
async def create_schema(self) -> None: ...
async def insert(self, incident: SystemIncident) -> None: ...
async def get(self, incident_id: str) -> SystemIncident | None: ...
async def fetch(self, *, unacknowledged_only: bool = False,
category: IncidentCategory | None = None,
execution_id: str | None = None,
since: float | None = None) -> list[SystemIncident]: ...
async def mark_acked(self, incident_id: str, acknowledged_at: datetime) -> None: ...
async def mark_all_acked(self, category: IncidentCategory | None,
acknowledged_at: datetime) -> int: ...
async def aclose(self) -> None: ...
runtime = SystemRuntime(
build.system,
incident_service=IncidentService(MyDbIncidentStore()),
)
fetch returns incidents ordered by ascending timestamp. mark_acked is idempotent and a no-op on an unknown id. The full category taxonomy is in Recovery.
IExecutionRecordStore
class IExecutionRecordStore(Protocol):
async def upsert_running(self, execution_id: str, workflow_name: str,
submitted_at: datetime) -> None: ...
async def mark_terminal(self, execution_id: str, state: ExecutionState,
reason: str | None, terminal_at: datetime) -> bool: ...
async def get(self, execution_id: str) -> PersistedExecution | None: ...
async def upsert_thread(self, execution_id: str, thread: PersistedThread) -> None: ...
async def list_threads(self, execution_id: str) -> list[PersistedThread]: ...
async def list_all(self) -> list[PersistedExecution]: ...
async def list_non_terminal(self) -> list[PersistedExecution]: ...
async def create_schema(self) -> None: ...
async def aclose(self) -> None: ...
An execution record carries its threads. PersistedThread holds thread_id, name, template_name, status, last_error and pause_reason, so a record read after the fact says not only that a run ended badly but which thread ended it and why. The runtime's ExecutionTrackingSink upserts a thread row on every transition, which is why terminal thread events carry the error: without it, the terminal upsert would erase the stored cause.
The live ExecutionRecord you get back from submit_workflow is a separate, flatter object: id, workflow_name, status, error, paused, pause_reason, abort_armed.
IOpsHistoryStore
class IOpsHistoryStore(Protocol):
async def append(self, execution_id: str, record: TrackingRecord) -> None: ...
def for_execution(self, execution_id: str) -> IOpsHistoryView: ...
async def list(self, execution_id: str) -> list[TrackingRecord]: ...
async def search(self, query: OpsHistorySearchQuery) -> list[tuple[str, TrackingRecord]]: ...
This is the append-only record of every operation an action performed: each pick-up, each aspirate, each dispense. Well volumes and tip state are folded from it, so it is the store that decides whether those facts survive anything. Records come back in ascending timestamp order across all buckets.
Two constructors. JsonlOpsHistoryStore(ops_dir) writes one <execution_id>.jsonl per execution under a directory you choose. JsonlOpsHistoryStore.ephemeral() writes into a temporary directory that is cleaned up when the store is dropped.
ephemeral() is the default, and nothing in the framework constructs the path-based form. Out of the box, operation history survives a runtime rebuild in the same process and is gone when the process exits. Pass ops_history_store=JsonlOpsHistoryStore(Path("/var/lib/orca/ops")) to build_system and to SystemRuntime (the same instance to both, so reads and writes stay coherent) if you want it to outlast the process.
Pitfalls
- The shipped defaults do not outlive the process. "SQLite-backed" means an in-memory engine. Read What comes back after a restart before assuming anything is durable.
- Stores are async all the way down.
build_systemawaits the store reads it needs (teachpointlist, deck-layoutget); there is no sync snapshot to reconcile. A custom store just implements the async Protocol methods. deletesemantics differ by store.ILabwareStore.deleteis idempotent and returns nothing.ITeachpointStore.delete,IAccessConfigStore.deleteandIDeckLayoutStore.deletereturnTrueonly if something was removed.InMemoryLabwareStore.deleteleaves history behind. It removes the id, barcode and position; the location history and relationships stay.- Event sinks are NOT stores. They are write-only observers. To query historical events, persist them through a sink and read from your own backing store.
- There is no
IStateStore. An execution cannot resume mid-flight after a restart, and no interface pretends otherwise.
See also
- Runtime state: what re-seeds after a restart and what an operator has to confirm.
- Events: the sink Protocol and the event taxonomy.
- Recovery: the incidents the incident store records.
- Runtime: the full constructor argument list.
- Glossary: every term on this page, in one place.