Running a fleet over a shared fact store¶
A FleetEngine holds one Engine per session, all loaded from the same
rules directory, and a FactStore they share. Facts of a template marked
scope: fleet are written to the store; every other fact stays in the
session that asserted it. A session pulls the fleet's facts into its own
working memory with sync_fleet_facts.
Three stores implement the FactStore protocol:
| Store | Import | Install | Shared across processes |
|---|---|---|---|
InMemoryFactStore |
fathom |
included | no |
RedisFactStore |
fathom.fleet_redis |
pip install fathom-rules[fleet] |
yes |
PostgresFactStore |
fathom.fleet_pg |
pip install fathom-rules[fleet-pg] |
yes |
Mark a template fleet-scoped¶
scope is a template field; it defaults to session.
templates:
- name: lock
scope: fleet
slots:
- name: resource
type: string
required: true
- name: holder
type: string
- name: request
slots:
- name: agent
type: string
A fleet-scoped template may not declare a slot named fact_id (the store's
row key), and a rule may not assert into one: that is a CompilationError
at load.
Run a fleet in memory¶
InMemoryFactStore needs no setup and has no connect() or close(). It
lives in one process, so it suits tests and single-process fleets. This
example writes the template above to a temporary rules directory:
import asyncio
import pathlib
import tempfile
from fathom import FleetEngine, InMemoryFactStore
rules = pathlib.Path(tempfile.mkdtemp())
(rules / "templates").mkdir()
(rules / "templates" / "fleet.yaml").write_text(
"""
templates:
- name: lock
scope: fleet
slots:
- name: resource
type: string
required: true
- name: holder
type: string
- name: request
slots:
- name: agent
type: string
"""
)
async def main() -> None:
fleet = FleetEngine(fact_store=InMemoryFactStore(), rules_path=str(rules))
fleet.create_session("agent-a")
b = fleet.create_session("agent-b")
# Fleet scope: written to agent-a and the store; returns the store's fact_id.
fact_id = await fleet.assert_fact("agent-a", "lock", {"resource": "db"})
assert fact_id is not None
# Session scope: stays in agent-a; returns None.
assert await fleet.assert_fact("agent-a", "request", {"agent": "a"}) is None
# The store holds the fact as the engine accepted it, defaults applied.
rows = await fleet.query("lock", {"resource": "db"})
assert rows == [{"fact_id": fact_id, "resource": "db", "holder": ""}]
# agent-b sees it only after a sync.
assert b.query("lock") == []
await fleet.sync_fleet_facts("agent-b")
assert b.query("lock") == [{"resource": "db", "holder": ""}]
asyncio.run(main())
FleetEngine(fact_store, rules_path, **engine_kwargs) passes
engine_kwargs to Engine.from_rules for every session.
Run a fleet on Redis¶
RedisFactStore connects lazily on first use, so there is no connect().
Call close() when done; it also cancels any subscription listeners.
```python no-verify import asyncio
from fathom import FleetEngine from fathom.fleet_redis import RedisFactStore
async def main() -> None: store = RedisFactStore(host="localhost", port=6379, ttl=3600) try: fleet = FleetEngine(fact_store=store, rules_path="rules/") fleet.create_session("agent-a") await fleet.assert_fact("agent-a", "lock", {"resource": "db"}) print(await fleet.query("lock")) finally: await store.close()
asyncio.run(main())
The constructor also takes `db`, `password` and `ssl`. With `ttl`, each fact
expires after that many seconds; an expired fact drops out of `query` and
`count` but sends no `retract` notification. Connection errors and timeouts
are retried (3 attempts) before `FleetConnectionError`; other Redis errors
raise `FleetError` at once.
## Run a fleet on Postgres
`PostgresFactStore` must be connected first: `await store.connect()` creates
the connection pool and the `fleet_facts` table. Using the store before
`connect()` raises `FleetError`.
```python no-verify
import asyncio
from fathom import FleetEngine
from fathom.fleet_pg import PostgresFactStore
async def main() -> None:
store = PostgresFactStore("postgresql://fathom:fathom@localhost:5432/fathom")
await store.connect()
try:
fleet = FleetEngine(fact_store=store, rules_path="rules/")
fleet.create_session("agent-a")
await fleet.assert_fact("agent-a", "lock", {"resource": "db"})
print(await fleet.query("lock"))
finally:
await store.close()
asyncio.run(main())
Postgres caps a NOTIFY payload at 8000 bytes, and each write sends the fact
as JSON in one. A fact larger than that fails assert_fact with
FleetError and nothing is stored. Errors are not retried: an unreachable
server or a connection lost mid-statement raises FleetConnectionError, any
other driver error FleetError.
Manage sessions¶
Every method that addresses a session takes its id and raises KeyError for
an id with no session.
| Call | Does | Raises |
|---|---|---|
create_session(id) |
Loads a new Engine and returns it |
FleetError if id already has a session |
replace_session(id) |
Discards the session's engine, returns a fresh one with empty working memory | KeyError if unknown |
remove_session(id) |
Forgets the session | KeyError if unknown |
sessions |
A copy of the {id: Engine} mapping |
— |
Assert, query and sync¶
await fleet.assert_fact(id, template, data)validates the fact in the session first, so a fact the engine rejects never reaches the store. A fleet-scoped fact is then written to the store, and itsfact_idreturned. If the store write fails, the local assert is rolled back and the error re-raised. Asserting into a fleet-scoped template directly on theEngineraisesScopeError; go throughFleetEngine.assert_fact.await fleet.query(template, fact_filter=None)reads the store, not a session. Each row carries itsfact_id. A filter naming a slot the template does not declare raisesValidationError.await fleet.sync_fleet_facts(id)queries the store for every fleet-scoped template and asserts the rows into the session. A row the session rejects is logged and skipped.
Fleet retraction does not propagate¶
FleetEngine has no retract, never subscribes to the store, and
sync_fleet_facts only ever asserts. A fact removed from the store (with
store.retract(...), or by Redis TTL expiry) stays in every session that
already synced it, and a local Engine.retract of a fleet-scoped template
raises ScopeError. To drop removed facts from a session, rebuild it and
sync again:
async def drop_retracted() -> None:
store = InMemoryFactStore()
fleet = FleetEngine(fact_store=store, rules_path=str(rules))
s = fleet.create_session("agent-a")
await fleet.assert_fact("agent-a", "lock", {"resource": "db"})
assert await store.retract("lock", {"resource": "db"}) == 1
assert s.query("lock") == [{"resource": "db", "holder": ""}] # still there
s = fleet.replace_session("agent-a")
await fleet.sync_fleet_facts("agent-a")
assert s.query("lock") == []
asyncio.run(drop_retracted())
Subscribe to store changes¶
A store's subscribe(template, callback) calls await callback(notification)
with a FactChangeNotification (template, fact_id, action of assert
or retract, data) and returns an unsubscribe callable. FleetEngine does
not use it; it is for your own code.
| In-memory | Redis | Postgres | |
|---|---|---|---|
| Delivered | synchronously, inside assert_fact / retract |
after the write, from a listener task reading the stream | after commit, through LISTEN |
| History | none | none; every change after subscribe() returns |
none; every change after subscribe() returns |
| Gaps | — | — | changes during a LISTEN reconnect are lost |
On all three, a fact and its notification are written together or not at
all, concurrent retracts of one fact count and announce it once, and a callback that raises is logged on the fathom.fleet logger and
skipped: it never fails the write, ends the subscription or reaches the
other subscribers.