popoto.backends.postgres.ttl¶
popoto.backends.postgres.ttl
¶
Record expiry on Postgres: Meta.ttl (#759 M5, #755 Q3).
On Redis a TTL is EXPIRE/EXPIREAT on the record's hash: the server
drops the hash when it expires, and the index entries that pointed at it stay
behind until a hydrating read purges them (Model._purge_orphan_keys) or
clean_indexes runs. Postgres has no per-row expiry, so a model that
declares Meta.ttl gets three things here, and a model that does not gets
none of them -- its table, its plans and its writes are exactly what they
were (plan §3, "_expires_at only with Meta.ttl"):
- A column.
_expires_at double precision(epoch seconds,NULL= never), the clock decision M2a made for every engine clock, with a partial B-tree on the rows that carry one. A save writes it from the instance's_ttl(now + ttl, server clock) or_expire_at(int(_expire_at.timestamp()), the value Redis'sEXPIREATgets), and leaves it alone when the instance carries neither -- asHSETleaves a key's TTL alone. - A read filter. Every read of the model --
load,select,count,exists, ranking, search,recall, the assembler's arms, membership, validity -- sees a row only while_expires_atisNULLor later than now. An expired row is gone to every reader the instant it expires, whether or not anything has deleted it yet. - An automatic reaper. After a record write on the model commits, at most
Defaults.PG_REAPER_BATCHexpired rows are deleted in a transaction of their own (their side rows cascade), at most once perDefaults.PG_REAPER_INTERVAL_SECONDSper table and process unless the last run found a backlog. No cron, no CLI, no manual job.
Now is the server's statement_timestamp(): one central database serves
every agent and machine (architect decision 1), so expiry is judged by one
clock, as Redis judges it by its own. :func:frozen_clock pins it to a value
for tests and the parity probe (the only controllable clock; it changes the
SQL's clock term, nothing else).
EXPIRES_COL = '_expires_at'
module-attribute
¶
The engine column holding a row's expiry instant (epoch seconds).
Reaper
¶
Per-backend reaper state: when each table may next be reaped.
Source code in src/popoto/backends/postgres/ttl.py
now_sql()
¶
The SQL term for now: the server's statement timestamp in epoch
seconds, or the frozen instant while :func:frozen_clock is active.
statement_timestamp() is fixed for a statement (so a read and the
expiry it compares against agree within it) and, unlike now(),
advances between the statements of one long transaction().
Source code in src/popoto/backends/postgres/ttl.py
clock()
¶
Now on the Python side (the reaper's schedule): the frozen instant,
else time.time().
frozen_clock(at)
¶
Pin now to at (epoch seconds) for every TTL decision -- writes
computing now + ttl, every read filter, the reaper and its schedule --
for the duration of the block. Process-wide; for tests and the parity
probe. Nesting restores the outer value.
Source code in src/popoto/backends/postgres/ttl.py
ttl_columns(spec)
¶
_expires_at for a Meta.ttl model, nothing otherwise. Role
aux: an engine column, never hydrated into a field.
Source code in src/popoto/backends/postgres/ttl.py
ttl_indexes(spec, index_name)
¶
A partial B-tree on _expires_at over the rows that carry one: the
reaper's <= now probe and the expired-key anti-join of the side
tables both read it, and a row that never expires costs it nothing.
Source code in src/popoto/backends/postgres/ttl.py
live_sql(ts, alias='')
¶
The predicate a row passes while it has not expired, or "" for a
model without Meta.ttl. alias qualifies the column ("t").
Source code in src/popoto/backends/postgres/ttl.py
expired_pks_sql(ts)
¶
A subquery listing the expired rows' keys, for the side tables
(postings, lengths, vectors, membership rows) that have no
_expires_at of their own: "_pk" NOT IN <this>. "" for a model
without Meta.ttl. Served by the partial index, and small, because the
reaper keeps it so.
Source code in src/popoto/backends/postgres/ttl.py
and_live(ts, where_sql, alias='')
¶
where_sql (" WHERE …" or "") with the read filter ANDed
on.
Source code in src/popoto/backends/postgres/ttl.py
expiry_sql(spec, obj, expiry)
¶
(value SQL, params) for _expires_at on this save, or None
to leave the stored expiry alone (no TTL on the instance: HSET leaves
a key's TTL alone too).
expiry (the protocol's per-call override) wins; otherwise the
instance's _ttl, then _expire_at -- the order the Redis save path
issues EXPIRE/EXPIREAT in. A TTL on a model without Meta.ttl
raises :class:BackendCapabilityError: that model's table has no
_expires_at and its reads no filter (plan §3).
Source code in src/popoto/backends/postgres/ttl.py
purge_expired_sql(ts)
¶
The statement a save of a TTL model runs (after the record's key lock,
before its upsert): drop the row under this key if it has expired, so
the save writes a fresh record -- as HSET on a key Redis has expired
creates a new hash -- rather than reviving the expired row's unwritten
columns, side rows and engine state. "" without Meta.ttl. Takes
the key as its one parameter; the side rows cascade.
Source code in src/popoto/backends/postgres/ttl.py
reap(backend, ts, *, force=False)
¶
Run the reaper for ts if it is due (or force): delete up to
Defaults.PG_REAPER_BATCH expired rows in a transaction of its own,
on a pooled connection it waits at most
Defaults.PG_REAPER_LOCK_TIMEOUT_MS for. Returns the deleted keys.
Called after a write has committed, never inside a caller's transaction, and it never raises: a busy pool, a lock it would have to wait for, or an outage skips this run (logged), and the next write tries again. It does not touch the backend's health record -- a reaper run is not the caller's write.
Source code in src/popoto/backends/postgres/ttl.py
ttl_remaining(backend, spec, keys)
¶
Redis's TTL reply for each key: -2 for no live record (absent
or expired), -1 for a record with no expiry, else the seconds left,
rounded as Redis rounds its milliseconds ((ms + 500) // 1000). For
tests and tools that read a remaining TTL on both backends.