File size: 20,350 Bytes
bf8519f 609fb78 bf8519f dcdb685 bf8519f 609fb78 bf8519f dcdb685 bf8519f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 | """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
|