Skip to content

ExperimentStore and JobRunner

The port that keeps experiments and each cell a run scored, and the port that runs one in the background.

Ports: ExperimentStore and JobRunner, declared in indx_interfaces.protocols over the models in indx_interfaces.experiments (ADR-0082, ADR-0083).

An Experiment is a key, a name, the dataset and router it was made from, its candidates (each a key, a name and a router Branch, production first), and what it froze when it was created: the type, the router version, the documents and the labels they held. A CellResult is one candidate on one document: the rows it scored, the pages, the pages still needing a person, and the cost and time the result reported, or the error it failed with.

class ExperimentStore(Protocol):
def put(self, owner: OwnerId, experiment: Experiment) -> None: ...
def get(self, owner: OwnerId, experiment_id: ExperimentId) -> Experiment | None: ...
def list(self, owner: OwnerId) -> tuple[Experiment, ...]: ...
def delete(self, owner: OwnerId, experiment_id: ExperimentId) -> bool: ...
def put_result(
self, owner: OwnerId, experiment_id: ExperimentId, result: CellResult
) -> None: ...
def results(self, owner: OwnerId, experiment_id: ExperimentId) -> tuple[CellResult, ...]: ...
class JobRunner(Protocol):
def submit(self, job_id: str, work: Callable[[Callable[[], bool]], None]) -> bool: ...
def cancel(self, job_id: str) -> bool: ...
def active(self, job_id: str) -> bool: ...
def close(self) -> None: ...

Results are rows of their own rather than a list on the experiment, because a run writes one cell at a time and two cells must never share a write. Nothing stores a status: the outstanding cells are the candidates times the documents less the stored results, so a run that died with its process reads as stopped and resumes when asked. Three backends ship the store: memory, sqlite and postgres, named by INDX_EXPERIMENT_STORE.

JobRunner is what lets the domain start work that outlives a request without building a thread itself. A runner is a plugin like a store: an entry point in indx.job_runners, named by INDX_JOB_RUNNER, which no first-party package imports. One ships, indx-jobs-memory: a queue and one worker thread in the server’s process, gone with it. A deployment that names no runner keeps its experiments and runs none.

  • Owner. Every store method takes an OwnerId first, and nothing crosses owners.
  • Identity. Within an owner, an experiment is its key, and a result is its experiment, candidate and document; put and put_result replace.
  • Idempotence. delete of a key that is not there is False, and it removes the experiment’s results with it.
  • Order. list returns experiments by key; results by candidate, then digest.
  • Runner. submit of a job that is already active starts nothing and is False. cancel asks the work to stop before its next step and is False for a job that is not active. Work that raises is logged and never stops the runner. close asks every job to stop and waits for the one in flight; the app calls it when it stops.

tests/unit/test_store_conformance.py holds every installed store backend to these rules.