loopable / platform /core /store_pg.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
20.4 kB
"""core/store_pg.py β€” `core/store.py`'s interface, backed by Postgres (X4 / EXIT-2b, 2026-07-30).
THE INTERFACE IS THE CONTRACT, and it is `core/store.py`'s nine functions:
available() Β· get(name, fresh=False) Β· exists(name) Β· put(name, data)
update(name, fn, flush='sync') Β· upload_bytes(path, data, message=None)
download_bytes(path) Β· delete_path(path) Β· flush(name=None, timeout=30.0)
Same names, same signatures, same return types, same failure semantics β€” so switching backends is
an env var (`STORE_BACKEND=hf|pg`) and not a rewrite of every caller. `core/store_backend.py`
does the selection; nothing above it needs to know which store it is talking to.
βœ… VERIFIED against a real managed Postgres 2026-08-04 (W19: Neon us-east-2; `verify_store_pg.py`
80/80 INCLUDING the integration half β€” schema apply, tenant provisioning, jsonb/bytea round-trips,
80 concurrent FOR-UPDATE writes β€” run from HF egress; the owner's local network resets TLS:5432).
The CUTOVER remains parked behind C1e's triggers and this module is not the default backend.
Without `DATABASE_URL` the gate still SKIPS its integration half LOUDLY, never silently.
The schema is `harness/pg/schema.sql`. Sequenced around the blocker, never stubbed past it.
WHAT POSTGRES FIXES, precisely (C1c β€” these are the reasons, not a preference):
* **`get()` is cache-first on the HF store**, so a write from another process is invisible to a
running app until restart. Observed live in wave 11. Here a read is a SELECT: cross-process
coherence is the default rather than a thing to remember.
* **`update()` was read-modify-write with no concurrency control** β€” two writers raced and the
loser vanished silently. Here it runs inside ONE transaction with `SELECT … FOR UPDATE`, so
the second writer waits and then sees the first writer's value.
* **A write was an HTTP commit** against a 256-commits/hour repo budget, which is why the hot
path needed a coalescing async flusher at all. A Postgres write is a write; `flush='async'`
stays in the signature and becomes a no-op, because there is nothing to coalesce.
⚠ ONE DELIBERATE DIFFERENCE FROM THE HF BACKEND, and it is a fix. `store.get()` swallows transient
read failures and hands back a cached-or-empty dict, because a display read must not break a page.
That leniency is exactly what wiped the user registry once (an empty read merged into a write), and
`_read_strict` exists to opt out of it. Postgres has no equivalent "the network blipped" state
worth hiding: a connection failure RAISES from `get()` here. Callers that must not break on a read
should catch it β€” silently returning `{}` from a database that is simply unreachable is how an
empty dict gets written back over real data.
"""
import json
import os
import threading
_URL_ENV = 'DATABASE_URL'
_LOCK = threading.RLock()
_POOL = {'pool': None, 'url': None, 'schema': None}
def _url():
return os.environ.get(_URL_ENV) or None
def _schema_for(tenant_slug=None):
"""`t_<slug>` with `-` normalised to `_` (a hyphen is not legal in an unquoted identifier).
Mirrors `control.provision_tenant_schema` exactly β€” the two MUST agree or a write lands in a
schema the DDL never created. `AIOS_TENANT` names the tenant this process serves; it defaults
to tenant #0 so the default behaviour is unchanged, the same rule `harness.datastore.path_for`
follows for the DuckDB filename.
"""
slug = (tenant_slug or os.environ.get('AIOS_TENANT') or 'royal-imports').strip().lower()
return 't_' + slug.replace('-', '_')
def _pool():
"""The psycopg connection pool, opened once per process.
psycopg is imported LAZILY and only here: `STORE_BACKEND=hf` must not require the dependency
to be installed at all, which is what keeps this file safe to commit before B-3 unblocks.
"""
with _LOCK:
url = _url()
if not url:
raise RuntimeError(
f'{_URL_ENV} is not set β€” the Postgres store backend has nothing to connect to.')
if _POOL['pool'] is not None and _POOL['url'] == url:
return _POOL['pool']
try:
from psycopg_pool import ConnectionPool
except ImportError as e: # pragma: no cover - depends on B-3
raise RuntimeError(
'STORE_BACKEND=pg needs psycopg[binary,pool]. It is deliberately NOT in '
'requirements.txt until the database exists (owner blocker B-3), so that the '
'default hf backend installs nothing it does not use.') from e
if _POOL['pool'] is not None:
try:
_POOL['pool'].close()
except Exception:
pass
# min_size=0 so a process that never touches the store opens no connection at all β€” the
# shared API serves plenty of requests (health, static, a cached payload) that never read.
_POOL['pool'] = ConnectionPool(url, min_size=0, max_size=8, open=True, timeout=10.0)
_POOL['url'] = url
return _POOL['pool']
def available():
"""True when a connection can actually be MADE, not merely when a URL is configured.
`core.store.available()` only checks for a token, which is all it can cheaply do. Here the
honest answer needs a round-trip, so the result is memoised per process: callers use this to
decide whether to degrade the UI, and a `SELECT 1` per render would be absurd.
"""
if not _url():
return False
if _POOL.get('ok'):
return True
try:
with _pool().connection() as con:
con.execute('SELECT 1')
_POOL['ok'] = True
return True
except Exception:
return False
def _table(kind, tenant_slug=None):
"""`"t_slug"."store_kv"` β€” the qualified table, safely quoted."""
from psycopg import sql
return sql.SQL('{}.{}').format(sql.Identifier(_schema_for(tenant_slug)),
sql.Identifier(kind))
def _q(template, kind, tenant_slug=None):
"""Compose `template` with `{tbl}` = the QUALIFIED table and `{bare}` = the table name alone.
⚠ THE TWO ARE NOT INTERCHANGEABLE, and getting it wrong is a runtime syntax error nothing
local would catch (no database here β€” owner blocker B-3). Inside `ON CONFLICT … DO UPDATE`,
Postgres refers to the conflicting row by the table's ALIAS, which defaults to the bare
table name: `store_kv.rev` is valid, `t_royal_imports.store_kv.rev` is not. So the FROM
position takes `{tbl}` and the DO-UPDATE position takes `{bare}`.
Identifiers go through `psycopg.sql.Identifier`, so the schema name β€” derived from a tenant
slug β€” cannot be an injection vector even though it is interpolated. The slug is ALSO
regex-constrained at its source (`control.tenants`), which is the belt to this brace.
"""
from psycopg import sql
return sql.SQL(template).format(tbl=_table(kind, tenant_slug),
bare=sql.Identifier(kind))
def get(name, fresh=False, tenant_slug=None):
"""The value stored under `name`, or `{}` when the key is genuinely absent.
`fresh` is accepted and IGNORED: it exists in the HF signature to bypass a process cache, and
there is no cache here β€” every read is a SELECT. Keeping the parameter means callers do not
branch on the backend.
"""
with _pool().connection() as con:
row = con.execute(
_q('SELECT value FROM {tbl} WHERE key = %s', 'store_kv', tenant_slug),
(str(name),)).fetchone()
return dict(row[0]) if row and isinstance(row[0], dict) else (row[0] if row else {})
def get_projection(name, drop=(), tenant_slug=None):
"""`core.store.get_projection`'s twin β€” see `PgStore.get_projection` for why the saving does
NOT transfer and why that is stated rather than implied.
β›” Present because `verify_store_pg.IFACE` names it, which is the mechanism working as designed:
a capability only the HF backend had would 500 on `/nav` at the cutover instead of failing here,
at review, with no database attached.
"""
import core.store as _hf # noqa: PLC0415 β€” lazy: _project lives with the class
return _hf._project(get(name, tenant_slug=tenant_slug),
frozenset(str(d) for d in drop))
def exists(name, tenant_slug=None):
"""True/False β€” and unlike the HF backend this is NEVER "True on uncertainty".
`core.store.exists` returns True on any error so a caller that would seed the store cannot
clobber a registry it merely failed to reach. That guard exists because a FILE HOST cannot
distinguish "absent" from "unreachable". Postgres can: an absent row is a fact, and an
unreachable server raises instead of answering.
"""
with _pool().connection() as con:
row = con.execute(
_q('SELECT 1 FROM {tbl} WHERE key = %s', 'store_kv', tenant_slug),
(str(name),)).fetchone()
return row is not None
def put(name, data, tenant_slug=None):
with _pool().connection() as con:
con.execute(
_q('INSERT INTO {tbl} (key, value) VALUES (%s, %s::jsonb) '
'ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, '
'updated_at = now(), rev = {bare}.rev + 1', 'store_kv', tenant_slug),
(str(name), json.dumps(data)))
return data
def update(name, fn, flush='sync', tenant_slug=None):
"""Read-modify-write inside ONE transaction, with the row LOCKED for the duration.
This is the operation the HF backend could not make safe. There, `update` read over HTTP,
applied `fn`, and uploaded β€” so two concurrent writers both read the old value and the second
upload silently discarded the first writer's change. `SELECT … FOR UPDATE` makes the second
writer WAIT and then apply `fn` to the first writer's result, which is what "read-modify-write"
was always supposed to mean.
`flush` is accepted and ignored (see the module docstring): there is no commit budget to
coalesce against, so 'async' has nothing to defer.
"""
with _pool().connection() as con:
with con.transaction():
row = con.execute(
_q('SELECT value FROM {tbl} WHERE key = %s FOR UPDATE',
'store_kv', tenant_slug),
(str(name),)).fetchone()
data = dict(row[0]) if row and isinstance(row[0], dict) else (row[0] if row else {})
result = fn(data)
data = result if result is not None else data
con.execute(
_q('INSERT INTO {tbl} (key, value) VALUES (%s, %s::jsonb) '
'ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, '
'updated_at = now(), rev = {bare}.rev + 1', 'store_kv', tenant_slug),
(str(name), json.dumps(data)))
return data
def upload_bytes(path_in_repo, data, message=None, tenant_slug=None):
with _pool().connection() as con:
con.execute(
_q('INSERT INTO {tbl} (path, bytes, message) VALUES (%s, %s, %s) '
'ON CONFLICT (path) DO UPDATE SET bytes = EXCLUDED.bytes, '
'message = EXCLUDED.message, updated_at = now()', 'store_blobs', tenant_slug),
(str(path_in_repo), bytes(data), message))
def download_bytes(path_in_repo, tenant_slug=None):
"""Raw bytes, or None when absent β€” the same contract as the HF backend: a missing attachment
degrades to "that file is gone", never to a broken page."""
with _pool().connection() as con:
row = con.execute(
_q('SELECT bytes FROM {tbl} WHERE path = %s', 'store_blobs', tenant_slug),
(str(path_in_repo),)).fetchone()
return bytes(row[0]) if row else None
def delete_path(path_in_repo, tenant_slug=None):
"""Absent is SUCCESS β€” deleting what is already gone is the goal (HF backend's contract)."""
with _pool().connection() as con:
con.execute(_q('DELETE FROM {tbl} WHERE path = %s', 'store_blobs', tenant_slug),
(str(path_in_repo),))
return True
def revision(name, tenant_slug=None):
"""`{'rev', 'updated_at', 'token'}` for ONE bucket β€” wave 29, item 20 / contract C6.
⭐ THIS BACKEND HAS MAINTAINED THE ANSWER SINCE X4 AND NEVER PUBLISHED IT. Every `put` and
`update` above already ends `updated_at = now(), rev = {bare}.rev + 1`; all this adds is the
read door. Which is why C6 insisted the HF side use these two column NAMES for its in-memory
counter: the wire shape does not change at the cutover, so the client keeps polling the same
endpoint and the token simply stops being per-process.
⭐ AND HERE THE TOKEN IS DURABLE, which is the real upgrade. `core.store`'s counter lives in
one process, so it cannot see a write by a second container and it restarts at zero β€” hence
the process stamp in its token. `rev` is a column: it survives a restart, and it is shared by
every process pointed at this schema. The prefix below is therefore a constant rather than a
per-process value, and a backend flip changes the token exactly once (one refetch, correct).
An absent key is `rev 0`, not an error: a bucket nobody has written yet has changed zero times,
which is a fact a client can hold a baseline against.
"""
with _pool().connection() as con:
row = con.execute(
_q('SELECT rev, updated_at FROM {tbl} WHERE key = %s', 'store_kv', tenant_slug),
(str(name),)).fetchone()
rev = int(row[0]) if row and row[0] is not None else 0
stamp = row[1] if row else None
# ⚠ EPOCH SECONDS ON BOTH BACKENDS. `core.store` records `time.time()`; this column is a
# `timestamptz`. Same key, two types, is how a consumer that works on one backend breaks on
# the other β€” the exact class this module's interface parity exists to prevent.
at = stamp.timestamp() if hasattr(stamp, 'timestamp') else None
return {'rev': rev, 'updated_at': at, 'token': f'pg:{rev}'}
def flush(name=None, timeout=30.0):
"""A no-op that returns True: every write above is already committed when it returns. Kept in
the interface because QA gates and shutdown hooks call it and must not branch on the backend."""
return True
def close():
"""Release the pool (tests, and a process that is shutting down cleanly)."""
with _LOCK:
if _POOL['pool'] is not None:
try:
_POOL['pool'].close()
finally:
_POOL['pool'] = None
_POOL['url'] = None
_POOL.pop('ok', None)
# ---------------------------------------------------------------------------------------------
# WAVE 20 (R1 / D-4) β€” THE TENANT-BOUND HANDLE. This is what made the cutover a small change.
#
# `core/store.py` grew a CLASS in wave 18 because a tenant can own its own dataset REPO, and
# `harness.runtime.TenantRuntime` carries one bound instance per tenant. Postgres isolates by
# SCHEMA instead (`t_<slug>`, see harness/pg/schema.sql's own argument for schema-over-RLS), so
# the two models meet here: one object, bound to one slug, exposing `core.store.Store`'s methods.
#
# ⚠ THE BINDING IS THE POINT. The module functions above default their schema from `AIOS_TENANT`,
# which is a PROCESS-wide answer to a PER-REQUEST question β€” correct for a single-tenant worker,
# wrong for the shared API that serves four tenants from one process. A handle carries its slug
# explicitly, so a write cannot land in another tenant's schema because some env var was right
# for the last request. That is the same property `TenantRuntime.store_handle` already gives the
# HF backend (EXIT-4b proof #3), reached a different way.
# ---------------------------------------------------------------------------------------------
class PgStore:
"""`core.store.Store`'s interface for ONE tenant's schema.
Deliberately a thin binder over the module functions rather than a reimplementation: every
behaviour argument (FOR UPDATE in `update`, absent-is-success in `delete_path`, `fresh`
ignored because a SELECT has no cache to bypass) is stated once, up there, and cannot drift
between the two entry points.
"""
def __init__(self, tenant_slug):
self.tenant_slug = str(tenant_slug or '').strip().lower() or 'royal-imports'
#: Kept so `Store`-shaped debugging (`repr`, telemetry, the admin panes) reads the same
#: on both backends. There is no repo here; the schema is the address.
self.repo = f'pg:{_schema_for(self.tenant_slug)}'
def __repr__(self):
return f'<PgStore {self.repo}>'
def available(self):
return available()
def get(self, name, fresh=False):
return get(name, fresh=fresh, tenant_slug=self.tenant_slug)
def get_projection(self, name, drop=()):
"""`Store.get_projection`'s contract on Postgres β€” same ANSWER, different economics.
⭐⭐ W32-T01, AND IT IS HERE FOR A REASON THE GATE CANNOT STATE. `get_projection` is a
METHOD, not a module function, so `verify_store_pg`'s IFACE list does not force it (that
list pins the ten module-level operations, and a public module function here would fail its
`extra:` check). But `harness.runtime.TenantRuntime.get_projection` resolves through
`store.handle()`, which returns THIS class when `STORE_BACKEND=pg` β€” so a method only the HF
store had would `AttributeError` at the cutover, on `/nav`, for every tenant at once. That
is exactly what `revision()`'s docstring means by *"a capability only the HF store had would
have 500'd at the cutover instead of at review"*, and this is the review.
⚠ **The saving does NOT transfer, and pretending otherwise would be the lie.** On HF the
whole tenant document is one JSON blob in memory and the cost is the 28.6 MB deep copy this
skips. Here `get` is a SELECT that has already materialised the row, so projecting after the
fact saves the copy of the dropped keys and nothing on the wire. It is correct, not fast.
A cheaper pg projection is a `jsonb` column list in the SELECT itself β€” a real optimisation,
for the day D-4 actually flips, and not something to build blind against a database that
does not exist yet (blocker B-3).
"""
import core.store as _hf # noqa: PLC0415 β€” lazy: _project lives with the class
return _hf._project(get(name, tenant_slug=self.tenant_slug),
frozenset(str(d) for d in drop))
def _read_strict(self, name):
"""Interface parity with the HF Store. There, `get` is lenient (swallows a transient
failure) and `_read_strict` opts out so a failed read ABORTS a read-modify-write instead
of merging into `{}`. Here `get` ALREADY raises β€” the leniency it opts out of does not
exist β€” so the strict read is the ordinary one, and saying so beats a second code path."""
return get(name, tenant_slug=self.tenant_slug)
def exists(self, name):
return exists(name, tenant_slug=self.tenant_slug)
def put(self, name, data):
return put(name, data, tenant_slug=self.tenant_slug)
def update(self, name, fn, flush='sync'):
return update(name, fn, flush=flush, tenant_slug=self.tenant_slug)
def revision(self, name):
return revision(name, tenant_slug=self.tenant_slug)
def upload_bytes(self, path_in_repo, data, message=None):
return upload_bytes(path_in_repo, data, message=message, tenant_slug=self.tenant_slug)
def download_bytes(self, path_in_repo):
return download_bytes(path_in_repo, tenant_slug=self.tenant_slug)
def delete_path(self, path_in_repo):
return delete_path(path_in_repo, tenant_slug=self.tenant_slug)
def flush(self, name=None, timeout=30.0):
return True
def _flush_now_at_exit(self):
"""The atexit hook `core.store` registers for its instances. Nothing is buffered here β€”
every write above committed before it returned β€” so this is honestly a no-op rather than
an unimplemented method that would raise during interpreter shutdown."""
return True