Postgres Backend (v2)¶
Popoto v2 keeps one model API with two native storage backends behind it (#759). Redis keeps today's hashes, index sets and Lua. Postgres stores each model in a typed table with native indexes, and it is where new capabilities land.
This page covers the first Postgres milestones: plain models (M1),
plain-field breadth (M1.1), the ranking and memory-state half of
Valor's slice (M2a), search (M2b), ContextAssembler (M2c),
the validity axis (M3), the co-occurrence graph and the remaining
recipes (M4), and the first of M5: record expiry (Meta.ttl) and
popoto.batch().
That means records, queries, Q objects, ordering, counting and atomic
increments for the field types listed below, including indexed, unique, tag,
relationship and collection fields, plus decay ranking, confidence
(partitioned too), read tracking, the write filter, ObservationProtocol and
composite_score, BM25 keyword search, pgvector embeddings, exact membership
filters, fusion and recall(), the assembler over all of them,
ValidityField with SupersessionProtocol, CoOccurrenceField with its
graph expansion, the remaining recipes and mixins on top, records that
expire, and batches that commit as one transaction. Models that use other
fields stay on Redis until their milestone. Popoto refuses them when you
declare them, so they never fail halfway through.
Selecting the backend¶
A backend is chosen per model, or once for the whole process:
import popoto
class Note(popoto.Model):
owner = popoto.KeyField()
slug = popoto.KeyField()
hits = popoto.IntField(default=0)
score = popoto.SortedField(type=float, partition_by="owner")
class Meta:
backend = "postgres" # this model only
export POPOTO_BACKEND=postgres # process default
export POPOTO_POSTGRES_URL=postgresql://db.internal:5432/agents # the DSN
export POPOTO_POSTGRES_SCHEMA=popoto # optional; default "popoto"
Which variable each component reads:
| Component | Reads | Never reads |
|---|---|---|
the library (popoto) |
POPOTO_BACKEND, POPOTO_POSTGRES_URL, POPOTO_POSTGRES_SCHEMA, POPOTO_SCHEMA_AUTO |
POSTGRES_URL, DATABASE_URL |
| the pytest conformance harness | POSTGRES_URL, for its throwaway popoto_test_<hex> schema (see Testing) |
— |
Popoto never picks Postgres up from a generic variable. Selecting Postgres
without POPOTO_POSTGRES_URL, or without the postgres extra installed,
raises BackendUnavailableError naming what is missing. You can also pass
the DSN directly:
popoto.backends.set_backend(PostgresBackend(dsn=..., schema=...)).
Laziness. import popoto never imports psycopg. Defining a
Meta.backend = "postgres" model never touches the network either: class
creation only checks the model's fields against a static capability table.
The connection, the version check, the DDL and the schema check all run inside
the model's first query or save. bind() itself only compiles the table
spec, so an outage at that first call is charged to the call that hit it: a
first save against an unreachable server counts as a dropped write, and a
first query does not.
Supported fields (M1, M1.1, M2a, M2b, M3, M4)¶
| popoto field | Column | Index |
|---|---|---|
KeyField / UniqueKeyField / AutoKeyField (type=T) |
T (default text) |
all key fields under one UNIQUE; a B-tree for each non-leading key field; UniqueKeyField also gets its own UNIQUE |
SortedField(type=T, partition_by=…) / SortedKeyField |
T: bigint, double precision, numeric, date, time or timestamptz |
B-tree (partition cols…, f, _pk COLLATE "C") |
IntField / FloatField / DecimalField / BooleanField |
bigint / double precision / numeric / boolean |
— |
StringField |
text |
— |
DatetimeField |
timestamptz plus <f>__utcoff integer (offset in seconds; NULL = naive) |
— |
Field(type=int/float/str/bool/Decimal/datetime) |
as above | — |
IndexedField(type=T) / UniqueField(type=T) (M1.1) |
T (a scalar type) |
B-tree on the column; UNIQUE for a unique field |
TagField (M1.1) |
text[], normalised (sorted, unique, str(tag); untagged is {}) |
GIN: __contains and __all are @>, __any is && |
Relationship(model=M) (M1.1) |
text holding the target's _pk |
B-tree (no foreign key: Redis enforces none, and references may be circular) |
ListField / DictField / SetField / TupleField, Field(type=list/dict/set/tuple) (M1.1) |
jsonb |
— |
ListField(max_length=N) (M1.1) |
jsonb, each element type-tagged as push() writes it on Redis |
— |
BytesField / DateField / TimeField (M1.1) |
bytea / date / time |
— |
Meta.indexes (M1.1) |
— | a composite B-tree per entry; UNIQUE when is_unique |
DecayingSortedField(partition_by=…, base_score_field=…) (M2a) |
double precision: the decay clock, in epoch seconds |
B-tree (partition cols…, f, _pk COLLATE "C") |
ConfidenceField (M2a; partition_by= M3) |
double precision for the attribute, plus the state <f>__conf (double precision), <f>__n, <f>__corr, <f>__contra (bigint) |
— |
BM25Field(source=…) (M2b) |
no column of its own: postings <table>__<f>__post (scope, term, _pk, tf) and lengths <table>__<f>__dl (_pk, scope, len) |
postings PRIMARY KEY (scope, term, _pk) plus a B-tree on _pk; lengths (scope) INCLUDE (len) |
EmbeddingField(source=…) (M2b) |
<f> bigint (the dimension count Redis stores), <f>__vec vector(d), <f>__model text, <f>__hash text, plus the narrow vector table <table>__<f>__vec (_pk, scope, v vector(d)), v stored PLAIN |
HNSW vector_cosine_ops on <f>__vec; a partial B-tree on rows with no vector, and a B-tree on <f>__model (the backfill's probe); (scope) on the narrow table |
ExistenceFilter / FrequencySketch (M2b) |
no column: <table>__<f>__tok (token, _pk) / <table>__<f>__cnt (token, count) |
PRIMARY KEY (token, _pk) / PRIMARY KEY (token) |
ContentField (M2b, pulled forward from M5) |
text holding the content itself (no $CF: reference, no file) |
— |
ValidityField (M3) |
double precision for the declared value, plus <f>__valid_from, <f>__invalid_at, <f>__ingested_at (double precision; 'Infinity' = open) and <f>__supersedes, <f>__superseded_by (text); companion <table>__<f>__open (digest, member) |
B-tree on <f>__valid_from and on <f>__invalid_at; the companion's member references _pk ON DELETE CASCADE |
CoOccurrenceField(symmetric=…, max_edges=…) (M4) |
no column: the edge table <table>__<f>__edge (src, dst, weight) |
PRIMARY KEY (src, dst) |
Every table also has:
_pk text PRIMARY KEY, which holds the sameClassName:key:…string that Redis uses as the hash key.Model.pkandredis_keyare therefore identical on both backends._created_atand_updated_at(timestamptz), which the engine maintains. They are Postgres-only._migrated_from jsonband_estimated_fields text[](M2b), the import contract for the one-off Redis-to-Postgres copy (#756). An importer records where a row came from and which values it inferred. Every native write (save,atomic_increment,push, and the embedding backfill) sets_migrated_fromtoNULL, so a re-load can guard withWHERE _migrated_from IS NOT NULLand never overwrite a row popoto has written since. The only engine write to_estimated_fieldsis the backfill's: it removes the embedding field's name, because the vector it writes is derived natively rather than imported.
DatetimeField round-trips the way it does on Redis (#521). An aware value
comes back with the same UTC offset as a fixed-offset timezone. A naive
value comes back naive. A naive value is stored and compared as UTC, which is
the same instant SortedField scores it as on Redis.
Collections are JSON, never msgpack. A collection field comes back
exactly as it comes back from Redis: the field's own type is restored at the
top level (a TupleField is a tuple, a SetField a set), nested values
behave as msgpack does (a nested tuple comes back a list; a tagged
Decimal/date/tuple element of a capped list comes back typed), and a
value msgpack cannot pack (a nested set or Decimal) raises TypeError on
both backends. What JSON lacks and msgpack has is tagged so it survives:
bytes, a non-finite float, and a dict with non-str keys.
Relationships stay lazy. The column holds the related record's key
string. filter()/all() return it as that string; get()/get_many()
resolve it to the related instance, exactly as the Redis read paths do.
Relationship.sample_related_keys is an id-only SELECT … WHERE f = $key
ORDER BY random() LIMIT n, with SRANDMEMBER's count contract (negative
counts repeat).
Uniqueness. A UniqueField, UniqueKeyField or unique Meta.indexes
tuple is checked in pre_save by a read through the backend, with the same
ModelException text as Redis and before any write. The UNIQUE index is the
authority behind it: a write that races past the read, or two conflicting
saves inside one transaction(), gets the same text from the index.
Memory state (M2a). A ConfidenceField keeps two things, as it does on
Redis: the model attribute (the hash value there, its own column here) and
the evidence state (the companion hash entry there, the four __ columns
here). State that is NULL is the seed Redis writes on save with HSETNX
(initial_confidence and three zeros), so a save never writes it and a
re-save never resets it. A model with AccessTrackerMixin gains
_access_count, _last_accessed, _staged_reads and _staged_at; staged
reads count only while _staged_at is within _staged_ttl_seconds, which is
what the Redis list's refreshed EXPIRE means. WriteFilterMixin's gate runs
above the seam as before; its priority tier is not stored (plan §5 M2).
RecallProposal keeps its pending proposals in one engine table per schema,
popoto_recall_proposal, created on first use like popoto_schema. No state
column is indexed: each index would make every update_confidence or
confirm_access a non-HOT update, and nothing filters or orders by one alone.
A partitioned ConfidenceField (M3) needs nothing more: its state is the
record's own columns, so its partition is the row's partition columns, and a
partition change that keeps the key keeps the state.
PredictionLedgerMixin keeps its ledger in Redis until M5 and is refused on a
Postgres model too, rather than issuing Redis commands for a record Redis does
not hold. EventStreamMixin is allowed where that one is refused because its
stream is a derived, append-only log of the record, never read back to
rebuild it: each entry is built from the row this save wrote and is sent only
after that write commits, so Redis never holds state that the Postgres row
contradicts. A ledger is the record's own state, and splitting it across two
stores would make it disagree with itself after a rollback.
Meta.ttl (M5) adds an engine column, _expires_at; see
Record expiry.
Fields that arrive later: GeoField and the rest (M5); an IndexedField on
a collection type is refused. A model
that uses one of them raises
BackendCapabilityError when you declare it with Meta.backend =
"postgres", or on first use when it takes the process default.
What the schema looks like¶
The model above compiles to:
CREATE TABLE "popoto"."note" (
"_pk" text PRIMARY KEY,
"hits" bigint, "owner" text, "score" double precision, "slug" text,
"_created_at" timestamptz NOT NULL DEFAULT now(),
"_updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "note__keys__idx" ON "popoto"."note" ("owner", "slug");
CREATE INDEX "note__slug__idx" ON "popoto"."note" ("slug");
CREATE INDEX "note__sort__score__idx" ON "popoto"."note" ("owner", "score", "_pk" COLLATE "C");
Migrations. Popoto records each table's fingerprint in
popoto.popoto_schema, along with the DDL, the popoto version that wrote it
and a schema-format version. The first use in each process creates a missing
table or applies an additive change (a new nullable column, a new index)
under pg_advisory_xact_lock: the schema's lock first, then the table's.
Every first use takes the schema lock before CREATE SCHEMA, a model table's
and an engine table's alike, so any number of processes can start on a schema
that does not exist yet without racing each other into pg_namespace
(test_engine_table_first_use_is_race_free_on_a_fresh_schema). No manual
step is needed. Anything else raises
SchemaDriftError with the diff instead of writing: a dropped or retyped
column, a key change, a schema written by a newer popoto, or a table popoto
did not create. Set POPOTO_SCHEMA_AUTO=0 to turn off even the automatic
create and additive changes.
Search (M2b)¶
A model's search fields keep their state in the same database as its
records, written by the record's own INSERT … ON CONFLICT statement as
data-modifying CTEs: one round trip, one transaction. A delete cascades to
the postings and the token rows. Nothing touches Redis or the filesystem.
class Memory(popoto.Model):
memory_id = popoto.AutoKeyField()
project_key = popoto.Field(type=str)
relevance = popoto.SortedField(type=float, partition_by="project_key")
content = popoto.StringField(default="")
lexical = BM25Field(source="content")
embedding = EmbeddingField(source="content") # provider: 1536 dimensions
bloom = ExistenceFilter(fingerprint_fn=lambda m: m.content)
seen = FrequencySketch(fingerprint_fn=lambda m: m.content)
class Meta:
backend = "postgres"
compiles to (beside the record table's own columns and indexes):
"embedding__vec" vector(1536), "embedding__model" text, "embedding__hash" text
CREATE INDEX "memory__hnsw__embedding__idx" ON "popoto"."memory"
USING hnsw ("embedding__vec" vector_cosine_ops);
CREATE TABLE "popoto"."memory__lexical__post" (
"scope" text NOT NULL, "term" text NOT NULL,
"_pk" text NOT NULL REFERENCES "popoto"."memory" ("_pk") ON DELETE CASCADE,
"tf" integer NOT NULL, PRIMARY KEY ("scope", "term", "_pk"));
CREATE TABLE "popoto"."memory__lexical__dl" (
"_pk" text PRIMARY KEY REFERENCES "popoto"."memory" ("_pk") ON DELETE CASCADE,
"scope" text NOT NULL, "len" integer NOT NULL);
CREATE TABLE "popoto"."memory__bloom__tok" (
"token" text NOT NULL,
"_pk" text NOT NULL REFERENCES "popoto"."memory" ("_pk") ON DELETE CASCADE,
PRIMARY KEY ("token", "_pk"));
CREATE TABLE "popoto"."memory__seen__cnt" (
"token" text PRIMARY KEY, "count" bigint NOT NULL);
CREATE TABLE "popoto"."memory__embedding__vec" (
"_pk" text PRIMARY KEY REFERENCES "popoto"."memory" ("_pk") ON DELETE CASCADE,
"scope" text NOT NULL, "v" vector(1536) NOT NULL);
ALTER TABLE "popoto"."memory__embedding__vec" ALTER COLUMN "v" SET STORAGE PLAIN;
tests/postgres/test_postgres_search.py::test_compiled_search_ddl_is_pinned
pins the full statement list.
The scope. A model's scope is the partition_by of its
DecayingSortedField (Valor's project_key), else of its first partitioned
SortedField. With neither, every record is in scope ''. A NULL scope
column is scope '' too, so it is never dropped. The scope leads the
postings key. A save that changes a scope column, including
save(update_fields=["project_key"]), moves the record's postings and its
narrow vector row in the same statement.
The postings, document length and narrow vector row always carry the scope of
the row that is stored after the save. A scope column the save writes (every
column on a full save, the listed ones with update_fields) takes the
instance's value; one it does not write keeps the stored value, read from the
record row inside the save's own statement, after the record lock. So
save(update_fields=["text"]) on an instance whose project_key was changed
but never saved, or was changed by another writer after this instance was
loaded, re-indexes the text in the scope the row actually holds. A scope part
whose type has no exact SQL spelling of str(value) (anything but str,
int and bool) is taken from the record's existing side rows, which already
hold the stored scope. Pinned: tests/postgres/test_postgres_search.py::test_a_partial_save_keeps_the_side_tables_in_the_stored_scope
and ::test_a_partial_save_takes_each_unwritten_scope_column_from_the_store.
Concurrent saves of one record. The data-modifying CTEs of a save share
one snapshot, so under READ COMMITTED a second save of the same record
whose statement started before the first one committed could not see the
rows the first one wrote, and would leave them behind. A save therefore
takes the record's advisory lock first, as its own statement in the same
message:
SELECT pg_advisory_xact_lock(hashtextextended('popoto:rec:<table>:<_pk>', 0)).
The rewrite then takes its snapshot after any earlier writer of that record
has committed, which also covers two racing first inserts. The backfill
takes the same locks, sorted by _pk. Pinned by
test_concurrent_saves_of_one_record_leave_no_stale_rows, which interleaves
two saves behind a held row lock in 8 runs, and by its control, which shows
the same interleaving goes stale without the lock.
One lock order for every writer. Every statement that writes a record
row takes that record's advisory lock first, not only a save: delete,
atomic_increment, a capped-list push, touch, update_confidence, the
access tracker's writes, on_context_used's FOR UPDATE and an
ExistenceFilter row. The order is any (model, field) lock (a
ValidityField's, M3), then the record-key locks sorted by _pk, then the row locks in _pk
order. A record's key lock is always the first lock taken on it, so a
transaction that runs update_confidence(x, pipeline=tx) and then
x.save(pipeline=tx) queues a concurrent x.save() behind it instead of
deadlocking with it. Transactions that each take several records in
different orders can still deadlock; that surfaces as
BackendRetryableError. Pinned by
test_a_confidence_update_then_save_cannot_deadlock_a_save and its control.
A record delete on a model with a symmetric CoOccurrenceField (M4) writes
rows in its partners' edge sets too (the reverse edges), so it takes the
partners' record-key locks with the deleted keys', in one sorted sequence,
before it touches a row. The lock statement runs twice: the first takes the
locks in order with the partners its snapshot saw, the second picks up a
partner linked while the first waited (none, normally; once the deleted keys
are held no new partner can appear, since a link takes both keys' locks).
Pinned by tests/postgres/test_postgres_graph.py::test_a_delete_cannot_deadlock_a_transaction_holding_a_partner_set
and its control, where a transaction holding a partner's set and then linking
into the deleted record deadlocks a delete that skips the partners' locks.
BM25¶
The tokens come from popoto's own tokenizer, the one Redis uses; Postgres
text search is never involved. BM25Field.search computes BM25_SEARCH_LUA's
formula operation for operation, in double precision:
idf = ln((N - df + 0.5) / (df + 0.5) + 1) and
tf · (k1 + 1) / (tf + k1 · (1 - b + b · dl / avgdl)), summed over the query
terms in query order, with k1 = 1.2 and b = 0.75. N, avgdl and df
are corpus-wide, as on Redis. Results come back in the same order (ties
by key, bytewise) and with Lua's %.14g score representation. The
statistics are counted live from the document-length table, so there is no
running avgdl to drift and recompute_stats has nothing to do.
get_idf, filter_selective_tokens and Query.keyword_search follow.
BM25Field.search(allowed_keys=) keeps only the best
max(limit, SCOPED_SEARCH_FETCH_CAP) records overall, which is the window
Redis's widening loop reaches before it stops.
Embeddings¶
EmbeddingField needs the pgvector extension. popoto never creates or
drops an extension, so run CREATE EXTENSION vector in the database (as a
role allowed to). Its schema must be on the connection's search_path; the
default, public, is. A model with an EmbeddingField raises
BackendCapabilityError naming the fix on its first use when either is
missing. The pgvector/pgvector:pg18 image ships the extension.
The dimension d is read from the field's provider at the model's first
use. Configure the provider (popoto.configure) before then. With no
provider the column is an unconstrained vector with no HNSW index, and
every search is exact.
A save embeds the source text exactly as on_save does on Redis: a provider
failure raises RuntimeError and nothing is written. The vector is stored
as float32, the precision of Redis's .npy files. Reads that hydrate records
never fetch it. A save that names the source in update_fields re-embeds,
as it re-indexes BM25.
The narrow vector table. Each vector is also kept in
<table>__<f>__vec (_pk, scope, v), written in the save statement and
cascaded on delete. In the record table a 1536-d vector (6 kB) is TOASTed,
so an exact scan reads two heaps and de-TOASTs every row in scope. The
narrow table stores v inline (STORAGE PLAIN, up to
1,900 dimensions, so the row fits an 8 kB page), and a scope's vectors are
one index range on scope plus their heap pages. The exact path reads it;
the HNSW index stays on the record table's column. The cost is a second
copy of every vector, about 6 kB a row at 1536-d.
The vector arm (_get_vector_scores, behind ContextAssembler's hybrid
path) returns cosine similarity 1 - (v <=> q), positive only, as Redis's
numpy path does. While at most Defaults.PG_VECTOR_EXACT_MAX (5,000) rows in
scope hold a vector, it scans them exactly, ordering by (v <=> q) + 0,
which no index can serve. Above that it uses the HNSW index with
SET LOCAL hnsw.ef_search = Defaults.PG_HNSW_EF_SEARCH and
hnsw.iterative_scan = relaxed_order. When HNSW returns fewer rows than
min(limit, rows with a vector), the recall guard re-runs the arm
exactly (on the narrow table), so a pathological query costs latency rather
than coming back empty.
EmbeddingField.load_embeddings reads the column (normalized as before, in
_pk order). garbage_collect and sweep_stale_tempfiles return 0 and
touch no file: the vector goes with its row.
Backfill. After a save whose own embedding succeeded, the engine embeds
up to Defaults.PG_BACKFILL_BATCH (4) rows of the same scope that have no
vector, or whose vector another model made. It runs after the commit,
outside any transaction(), and stops at
Defaults.PG_BACKFILL_BUDGET_SECONDS (1 s) of wall clock. A provider call
that outlives the budget is abandoned, so a slow provider cannot hold
save() past it. A row is written only while its source still hashes to the
text that was embedded and the row is still in the scope, so a concurrent
edit is never given a stale vector. It writes the narrow row in the same
statement, under the same record locks a save takes. Errors are logged and
swallowed. The busy flag is process-wide: while one backfill's provider
call is still out (abandoned at the budget but not yet returned), every
other model's backfill skips.
Membership¶
ExistenceFilter and FrequencySketch are exact on Postgres (a
documented strictness): might_exist has no false positives, and
get_frequency returns the true count. The token table holds each live
record's tokens and forgets a record when it is deleted. The count table
counts saves and never decrements, as the sketch never does.
fill_ratio reports the fill a bloom of the field's parameters would have
after the distinct tokens stored, 1 - e^(-k·n/m).
Fusion and recall()¶
Query.fuse is unchanged Python RRF. On Postgres the builder's filters
(including plain fields) scope the fused set through one SELECT, and the
survivors hydrate through one load.
Query.recall() is new and Postgres-only ([PG-only]; on a Redis-bound
model it raises BackendCapabilityError):
hits = Memory.query.recall(
"kubernetes upgrade",
scope="valor", # the partition_by value
filters={"agent_id": "a1"}, # KeyField / IndexedField equality
tags=["ops"], tags_mode="any", # the TagField: && ("any") or @> ("all")
weights={"bm25": 1.0, "vector": 0.4},
limit=10,
)
for memory, score in hits: ...
The BM25 and vector arms rank max(limit, Defaults.PG_RECALL_ARM_DEPTH)
deep and are fused by weighted RRF, Σ w / (60 + rank). A first, index-only
statement counts the scope's rows with a vector, which picks the vector
path. On the exact path one SQL statement then runs both arms and the
fusion, and returns the fused records' rows. On the HNSW path the vector
arm runs as its own statement, with the recall guard, first. That arm needs
statement-wide planner settings to keep the planner on the HNSW index, and
in a combined statement those settings would push the BM25 arm onto a full
index scan. Its ranking then joins the fused statement as a ranked list.
A record that leaves the scope between the HNSW statement and the fused one
is filtered out again in the fused statement, like every ranked list that
joins it.
Every scoping argument is optional, and they compose. BM25's statistics are
per scope by default (bm25_stats="scope"): on a shared database, one
agent's corpus must not skew another's IDF. bm25_stats="corpus" uses the
corpus-wide statistics BM25Field.search uses.
A model with one DecayingSortedField adds a third arm, weights["decay"]
(default 1.0): top_by_decay's ranking (DECAY_SCORE_LUA over the M2a
columns: the field's clock, its base_score_field, and <f>__conf
modulation when the field resolves a ConfidenceField), computed in the
fused statement itself over the same scope and filters. It adds no round
trip.
Query.top_by_relevance(scope=None, limit=10, *, as_of=None) ([PG-only], #758 D8)
returns [(instance, score)] ranked by decay × confidence in SQL, the score
top_by_decay ranks by. It replaces reading a decay sorted set with a raw
ZREVRANGE. scope is the decay field's partition_by value; None ranks
every partition together. On a Redis-bound model it raises
BackendCapabilityError; use top_by_decay there.
Both read through the validity gate (M3). On a model with a
ValidityField, top_by_relevance and recall leave out a record closed at
or before as_of and one that starts after it, as top_by_decay,
composite_score and the assembler do. as_of is epoch seconds and defaults
to now; Query.recall(..., as_of=) and top_by_relevance(..., as_of=) take
it, so as_of in the past returns the records that were valid then. In
recall the gate is ANDed onto the domain of every arm (BM25, vector,
decay), so a superseded record cannot enter through the lexical arm either.
Defaults.VALIDITY_GATING_ENABLED = False turns it off, and a model without a
ValidityField is unaffected.
composite_score(similarity_boost=…) works on Postgres too (M2b). The
mapping is one more arm, weight 1.0 and last, as its temporary set is in the
Redis ZUNIONSTORE. semantic_search(indexes=…) uses it, and on a model
with a ValidityField the validity mask applies to it as to every arm (M3;
pinned two-leg by tests/test_semantic_search.py::TestSemanticSearchWithIndexes). The order is the
same on both backends; with three or more summed arms a score can differ by
up to 2 ulp, because ZUNIONSTORE adds the smallest input set first while
Postgres adds the arms in order. co_occurrence_boost= is an arm too (M4):
weight 1.0, after the indexes and before similarity_boost, the position its
temporary set takes in the ZUNIONSTORE.
ContextAssembler (M2c)¶
ContextAssembler runs on a Postgres-bound model with no option of its own,
and returns the same ranked records as on Redis. It issues no Redis
command: tests/postgres/test_postgres_assembler.py patches the Redis
connection layer to record and refuse every command, then runs a Valor-shaped
model through assemble() in every mode with scopes, tags, a budget, the
gate, assess_quality and emit_trace, plus assess() and
on_context_used(), and asserts the record is empty.
What each stage runs on Postgres:
| Stage | Postgres |
|---|---|
| ExistenceFilter short-circuit | might_exist on the exact token table (M2b) |
| Hybrid / lexical pull | the same body as on Redis: BM25Field.search (keyword_search with corpus-wide statistics), the vector arm (vector_search), and fuse with _fusion_weights. The BM25 window is narrowed by the scope's indexed filters from one id-only SELECT, as filter_for_keys_set narrows it on Redis |
Zero-signal fallback, composite pull, assess probe |
composite_score: rank_composite, one SELECT (M2a) |
| Tag scoping | one id-only SELECT with && (any) / @> (all) |
Score proxy (assess_quality, emit_trace, assess) |
the partition's rank_decayed for a decay field, with the model's validity gate at now (M3: a record closed now or not yet started scores None and counts as stale, as on Redis, which runs DECAY_SCORE_LUA with the gate; pinned two-leg by test_the_score_proxy_gates_on_validity_at_now), the column for a plain sorted field |
| Post-effects | one transaction: the rows of the selected and the suppressed records locked in _pk order behind their record-key locks, one staged-read UPDATE, one confidence UPDATE for every suppressed candidate |
Why the hybrid path does not call recall(). recall() ranks each arm
inside the scope; the assembler ranks the vector arm across the whole model
and the BM25 arm inside the SCOPED_SEARCH_FETCH_CAP window, and fuse then
keeps the in-scope records. RRF sums those ranks, so the same keys in the same
order on both backends needs the same arms. The BM25 statistics are
corpus-wide on that path, never recall()'s per-scope default (architect
decision 3).
The parity evidence. The retrieval-quality fixture (200 records, 20
queries, retrieval_mode="auto") returns the same keys in the same order,
the same formatted output and the same token count on both legs, through the
lexical path, and through the hybrid path partitioned by agent_id with each
record's text suffixed (memory N) (test_postgres_assembler.py): with the
fixture's texts as written, several records share a text and so a vector, and
the hybrid path's parity then holds only up to the vector-arm tie row below. scripts/probe_assembler_parity.py builds
random corpora on both legs and compares assemble() (keys, formatted output
and token count, metadata, trace, quality, and the post-effects' confidence and
staged-read state), on_context_used() and assess(); its classes are in the
PR that introduced M2c, and test_postgres_assembler.py runs a 12-shape slice
of it in CI.
REDIS_URL=redis://localhost:6379/13 \
POPOTO_POSTGRES_URL=postgresql://localhost:5432/postgres \
python scripts/probe_assembler_parity.py --seeds 1 2 3 --shapes 100
The differences the probe classifies are rows elsewhere on this page, seen
through the assembler: the exact ExistenceFilter (a cue whose only record was
deleted or rewritten short-circuits on Postgres, not on Redis), a reload after
touch() (after an acted outcome the formatted relevance of that record
differs, and a token budget can then admit a different record), and the vector
arm's tie order (records with the same text have the same vector, which Redis
returns in file-listing order and Postgres by key).
An outage raises BackendUnavailableError from assemble(), as a Redis
outage raises ConnectionError; it is in
popoto.recipes.context_assembler.OUTAGE_ERRORS. That is the retrieval
path's rule, not every helper's: the quality helpers behind
assess_quality and assess() (score spread, feeling-of-knowing,
staleness) catch every exception and degrade, on both backends (unchanged
from Redis), so assess() during a rank_decayed outage returns a degraded
quality rather than raising.
Post-effects fail differently. On Postgres the post-effects are one
transaction (above), so they are all-or-nothing: a statement that fails drops
the staged reads and every suppression signal of the call, with one warning.
On Redis a TypeError or ValueError is swallowed per candidate and the
pipeline carries on with the rest. And Postgres stages reads only for a model
with AccessTrackerMixin (the _at_member check), where Redis calls each
record's on_read: a user-defined on_read on a model without the mixin runs
on Redis but not on Postgres. Neither is reachable with the shipped models.
Co-occurrence graph (M4)¶
CoOccurrenceField keeps, on Redis, one sorted set per source key; on
Postgres each field has one edge table, a row per directed edge:
CREATE TABLE "popoto"."memory__associations__edge" (
"src" text NOT NULL, "dst" text NOT NULL,
"weight" double precision NOT NULL, PRIMARY KEY ("src", "dst"));
A symmetric field writes both directions, as the Lua writes both sets, so the
two weights of a pair can differ exactly as they can on Redis (a prune or a
weaken_all touches one side). There is no foreign key: Redis links any two
key strings, records or not. Deleting a record removes its own edges and,
for a symmetric field, the reverse edge in every set it linked to, as CTEs of
the record's DELETE, behind those partners' record-key locks as well as its own (see "One lock order for every writer"). Every tie-break is COLLATE "C", the sorted set's
member order. Pinned: tests/postgres/test_postgres_graph.py::test_the_edge_table_ddl_is_pinned.
Writes. Each one is a statement behind the record-key advisory locks of
the sets it writes (src, and dst when symmetric), sorted: the backend's
one lock order. That lock is what makes a link's count-then-prune atomic, as
the script is: no committed state of a set ever holds more than max_edges
edges (test_concurrent_links_to_one_set_never_exceed_max_edges, and its
deterministic interleaving with a control that overflows without the lock).
| Call | Lua | Postgres |
|---|---|---|
link |
LINK_WITH_PRUNE_LUA per direction: an existing edge keeps its weight; a new one is added and, past max_edges, the lowest count - max_edges by (weight, member) are removed |
the same, in one statement: the prune is decided over the set as it would be after the insert, so a new edge that ranks lowest is never inserted |
strengthen |
min(old + delta, cap), a missing edge as 0, no prune |
INSERT … ON CONFLICT DO UPDATE with the same CASE |
unlink |
ZREM |
DELETE |
weaken_all |
every edge times factor; below 0.001, removed |
one statement: DELETE the pruned, UPDATE the rest |
The arithmetic is the Lua's, in double precision, and the replies are the
Redis ones: link returns the script's number reply, which Redis sends as an
integer (the weight truncated toward zero, so link(…, 0.5) returns 0.0 on
both); strengthen returns tostring(new) (%.14g). Where Postgres would
raise on a float overflow or underflow and C saturates, the statement guards
the one product that can (an underflow in weaken_all, whose edge is removed
either way), and an input past 1e300, infinite or NaN takes an exact path:
the same steps in Python floats, which are C doubles, in one transaction.
propagate is PROPAGATE_BFS_LUA. The Lua runs a FIFO queue with a
visited map; an entry expands through the node's top max_edges neighbours
by (weight, member) descending unless an earlier entry for the node was at
least as heavy, and a neighbour is reached with w * decay * min(edge, cap),
kept when that is at least the threshold. With a positive threshold every
weight is positive and the step is monotone, so a skipped entry is dominated
by an earlier, no-deeper one: each result is the heaviest walk of at most
depth hops whose weights stay at or above the threshold. That is one
WITH RECURSIVE statement, a layer per iteration:
WITH RECURSIVE
"_seed" AS (SELECT DISTINCT u AS pk FROM unnest($seeds::text[]) AS u),
"_walk" (pk, w, d) AS (
SELECT pk, 1::float8, 0 FROM "_seed"
UNION ALL
SELECT pk, w, d FROM ( -- each node's heaviest arrival
SELECT pk, w, d, row_number() OVER (PARTITION BY pk ORDER BY w DESC) AS rk
FROM (SELECT n.dst AS pk, safe_mul(k.w * $decay, least(n.weight, $cap)) AS w,
k.d + 1 AS d
FROM "_walk" AS k
CROSS JOIN LATERAL ( -- the top max_edges neighbours
SELECT dst, weight FROM <edges> WHERE src = k.pk
ORDER BY weight DESC, dst COLLATE "C" DESC LIMIT $max_edges) AS n
WHERE k.d < $depth) AS h
WHERE w >= $threshold AND w <> 'NaN') AS r
WHERE rk = 1)
SELECT pk, max(w) FROM "_walk"
WHERE d >= 1 AND pk NOT IN (SELECT pk FROM "_seed")
GROUP BY pk ORDER BY max(w) DESC, pk COLLATE "C"
safe_mul is the saturating product of the decay SQL (M2a).
That statement only runs while it expands at most
Defaults.PG_GRAPH_RECURSIVE_MAX_LAYERS (2) layers, i.e. ceil(depth) <= 2
(the default depth=2). A recursive CTE sees only the previous layer, so it
cannot apply the visited map: it re-expands every reached node on every layer
until depth runs out or the weights drop under the threshold. Up to two
layers that is exactly the pruned work (layer one expands the seeds, layer two
every first arrival); past that it grows with depth × fan-out where the Lua
stops. The #781 review measured a 400-node clique at depth=50, decay=0.99 at
24.9 s against Redis's 0.10 s, and at larger depths (or a decay near 1) the
statement ran into statement_timeout, which the outage contract reports as
BackendUnavailableError. A deeper call therefore runs one statement per
layer instead: each frontier node's top max_edges neighbours, the same
product and threshold, each node's heaviest arrival (GROUP BY), and
between layers the Lua's visited rule -- a node goes on only when it arrived
strictly heavier than at any earlier layer. A pruned arrival is dominated by
the earlier one, so the answer is the recursive statement's; the work is at
most the Lua's, and the loop stops after the last layer that improved a node
whatever depth is (the contraction guard keeps decay * cap < 1, so an
improving walk is a simple path: at most one layer per node reached). A
statement timeout is never what bounds a graph read. Measured on the review's
graphs (macOS arm64, PostgreSQL 18.6, Redis 8.10, load average 4.2-4.9,
median of 5 runs; 7 for the probe shapes):
| graph, call | Redis | Postgres |
|---|---|---|
400-node clique, depth=50, decay=0.99, threshold=0.01 |
77 ms | 300 ms |
same, depth=1000, threshold=1e-6 |
86 ms | 295 ms |
same, depth=1e6, decay=0.999 |
81 ms | 290 ms |
same, depth=1e6, decay=0.5, threshold=1e-280 |
84 ms | 288 ms |
same, depth=inf, decay=0.99 |
82 ms | 289 ms |
the review probe's five ≤12-node shapes at depth=1e9, decay=0.999999 |
0.21-0.32 ms | 1.15-1.55 ms |
50k edges, fan-out 10, depth=2 / 3 / 5, threshold=0.01 |
2.7 / 11 / 35 ms | 1.8 / 10 / 61 ms |
The deep calls run their layers on one pooled connection in one read
transaction, so a layer costs one round trip. Every answer is identical to Redis's. Pinned:
test_a_dense_clique_at_any_depth_answers_inside_the_statement_timeout
(a 40-node clique at depth=1e9 with the timeout lowered to 3 s; the health
record sees no failure), test_a_deep_propagate_prunes_as_the_visited_map_does
and test_the_three_bfs_paths_agree.
Outside that domain -- a threshold at or below 0 (or under 1e-290), a negative or
infinite decay_per_hop -- the step is not monotone and the visited map's
order decides the answer, so the backend replays the Lua's queue exactly in
Python, over the same neighbour lists fetched one layer per statement.
Scores come back through %.14g, as the script's tostring sends them.
get_linked is ZREVRANGEBYSCORE +inf <min> LIMIT 0 <limit>: one indexed
read of the source's rows, the stored weights unclamped (graph_expand at
depth 1), with ( for an exclusive minimum, limit=0 empty and a negative
limit everything.
recipes/graph_traversal.traverse runs unchanged on top: propagate, the
relationship walk (M1.1's sample_related_keys), and the confidence and
decay modulation (M2a/M2c). ContextAssembler's graph arm and
composite_score(co_occurrence_boost=) work on a Postgres model as on Redis.
The seeded probe. scripts/probe_graph_parity.py replays the same random
sequence of writes on both legs -- weights from typical to -inf, ties and the
cap; deltas from 1e-12 past the cap; factors 0, tiny and 1; record
deletes and import_state (NaN weights included) -- comparing every return,
then each key's full edge set, get_linked (limit=None and a NaN bound
included), propagate (depths up to 1e9 and inf, decays up to 0.999999)
on all three Postgres paths, export_state, composite_score with the boost
and traverse(). Its classes are in its docstring and the PR that introduced
M4; the documented ones are the M4 rows of the table above.
tests/postgres/test_postgres_graph.py runs a 25-shape slice in CI.
REDIS_URL=redis://localhost:6379/13 \
POPOTO_POSTGRES_URL=postgresql://localhost:5432/postgres \
python scripts/probe_graph_parity.py --seeds 1 2 3 --shapes 200
Recipes, mixins and the queue (M4)¶
The recipes reach storage through field and model methods (#630), so a
Postgres-bound model runs them unchanged. The few methods that keep state
outside a model's own hash on Redis are field_call adapters on Postgres
(popoto/backends/postgres/recipes.py), several of them backed by engine
tables created on first use, like popoto_recall_proposal:
| Method | Redis | Postgres |
|---|---|---|
Model.idle_seconds |
OBJECT IDLETIME: whole seconds since the key was last read or written |
whole seconds since the later of the row's last write (_updated_at) and, with AccessTrackerMixin, its last confirmed read (_last_accessed); None with no row, or an expired one (M5) |
SortedFieldMixin.count / members / score |
ZCARD / ZRANGE / ZSCORE on the partition's sorted set |
the partition's rows, in (value, _pk COLLATE "C") order, with ZRANGE's inclusive and negative ranks; score converts the stored value as the sorted set scores it |
counters.increment / read (model=) |
INCRBY / GET on a string |
popoto_counter (key, value): INSERT … ON CONFLICT DO UPDATE … RETURNING |
TombstoneStore (MemoryLifecycle's archive) |
$TOMB:{Model}:data hash + :index sorted set |
popoto_tombstone (model, member, entry, ts) |
TombstonePriorStore (the negative prior) |
$TOMBPRIOR:{Model}:* |
popoto_tombstone_prior (model, digest, burials, ts) + popoto_tombstone_stats |
NeverRecordMixin's audit log |
$NR:{Model}:counts hash + :drops capped list |
popoto_never_record_count (model, reason, count) + popoto_never_record_log (model, seq, entry), capped at NR_TOMBSTONE_LOG_MAX |
| The question queue's token bucket and propose lock | $QuestionBucket:{agent} (with a TTL) and SET NX PX |
popoto_question_bucket (agent, last_turn, expires_at) and popoto_lease (key, token, expires_at): an expired row reads as absent |
DefaultMemory's eviction counts and pages its partition through the sorted
reads above and records the eviction in popoto_counter, so a
Postgres-bound DefaultMemory issues no Redis command; MemoryService
(the Redis-only integration) does not see that counter.
MemoryLifecycle.tombstone() archives the record as restore() will decode
it: on Redis the raw hash bytes, on Postgres the stored row encoded the way a
Redis save would write it, so decode_popoto_model_hashmap restores it
unchanged.
The question queue. Delivery is _DELIVER_LUA as one statement behind
the agent's bucket advisory lock, so two deliveries for one agent serialize
and the budget is exact under any concurrency (the 8-thread hammer passes on
both legs):
SELECT pg_advisory_xact_lock(hashtextextended('popoto:qq:<schema>:<agent>', 0));
WITH b AS (SELECT last_turn FROM popoto_question_bucket
WHERE agent = $agent AND expires_at > <now>),
pick AS (SELECT t._pk, coalesce(t.ask_count, 0) + 1 AS n, k.ord
FROM unnest($candidates::text[]) WITH ORDINALITY AS k(pk, ord)
JOIN question_candidate AS t ON t._pk = k.pk
WHERE NOT EXISTS (SELECT 1 FROM b WHERE $turn < b.last_turn + $K)
AND t.status = ANY($deliverable)
AND (t.cooldown_until IS NULL OR NOT ($turn < t.cooldown_until))
ORDER BY k.ord LIMIT 1
FOR UPDATE OF t SKIP LOCKED),
upd AS (UPDATE question_candidate AS t
SET status = 'delivered', ask_count = pick.n, delivered_turn = $turn
FROM pick WHERE t._pk = pick._pk RETURNING pick.ord, pick.n),
bk AS (INSERT INTO popoto_question_bucket (agent, last_turn, expires_at)
SELECT $agent, $turn, <now> + $ttl FROM upd
ON CONFLICT (agent) DO UPDATE
SET last_turn = EXCLUDED.last_turn, expires_at = EXCLUDED.expires_at)
SELECT ord, n FROM upd;
The bucket is written only when a candidate was claimed, as the script
writes it, so a refused or empty delivery never spends the budget. The
statement waits on nothing but the bucket lock, which no record writer takes,
so it cannot join a deadlock cycle. The claim (_CLAIM_LUA) is an
UPDATE … WHERE status = ANY($allowed) AND <guard> IS NOT DISTINCT FROM … RETURNING
behind the candidate's record lock. Pinned by
tests/postgres/test_postgres_question_queue.py (a deterministic
interleaving of two deliveries in one turn, and its control without the
bucket lock, which delivers twice).
NeverRecordMixin refuses on Postgres exactly where it refuses on
Redis, in Model.save() before the backend is asked to write: a blocked save
returns False, no row is written, and the content-free audit entry goes to
the backend's tables. tests/test_never_record_firewall.py runs on both
legs; on the Postgres leg its "no trace anywhere" sweep covers every table of
the schema and Redis.
AppendOnlyMixin checks for the record through the backend. Inside a
Postgres transaction() the check runs on the transaction's connection, so
two saves of one key in one transaction refuse the second (on Redis the
"intra-pipeline" shape stays open, as its module docstring records).
ProvenanceJournal runs its pre-flight unchanged and then appends --
and, for a closing kind, closes the target's interval -- in one
transaction (SupersessionProtocol.save_and_invalidate, M3): its own, or
the caller's when the backend's unit of work is passed as pipeline=. The
close's outcome is known at the call, so AnnotationResult.target_closed is
the truth (on Redis a caller pipeline reports None, "unknown until you
execute", with a close_index to read after EXEC), and a close that fails
rolls the annotation back with it (on Redis the queued annotation is kept:
the M3 row below). Any other pipeline= object, a Redis pipeline included, is
refused with ValueError before anything is written.
AppendOnlyMixin.hard_delete removes the row (its open-claim pointer
cascades) and clears the <f>__supersedes / <f>__superseded_by columns of
the records that named it, the value side of the Redis chain hashes. The
reconciler's statement-vector cache is popoto_embedding_cache (model,
member, vector), beside the entries, and erase_entry drops it there.
JournalEntry composes EventStreamMixin, whose stream is a Redis structure
until M5: a Postgres-bound journal still XADDs its mutation log to Redis,
and the reconciler's StreamConsumer trigger reads it there. The XADD is
sent after the write commits: right after the save's own commit, or, inside a
caller's transaction() or a popoto.batch(), after that transaction's
COMMIT (PostgresUnitOfWork.after_commit). A rolled-back write sends none. Sending
is best-effort: with Redis unreachable the committed row stays and the
failure is logged. Pinned by tests/postgres/test_postgres_journal.py.
Not on Postgres yet. MemoryTelemetry's AssemblyEvent declares
Meta.ttl, refused until M5, so a Postgres-bound telemetry recorder fails
open (it records nothing). SubconsciousMemory(auditable_extraction=…) keeps
its decision log in Redis (extraction/decision_log.py is Redis-only, plan
§1), so on a Postgres-bound model it raises BackendCapabilityError at
construction.
The seeded probe, scripts/probe_queue_parity.py, replays random queue
sessions on both legs (proposals with dedup, use, delivery with and without
cues, answers, expiry, prune, regressed turns) and compares every return,
the stored candidates, the targets' confidence and the bucket;
test_postgres_question_queue.py runs a 20-shape slice in CI.
REDIS_URL=redis://localhost:6379/13 \
POPOTO_POSTGRES_URL=postgresql://localhost:5432/postgres \
python scripts/probe_queue_parity.py --seeds 1 2 3 --shapes 200
Topology and the outage contract¶
The deployment model is one central Postgres for every agent and machine.
Each process opens one psycopg_pool.ConnectionPool per DSN, and opens it
again after a fork. Its max_size is Defaults.PG_POOL_MAX_SIZE (4).
Popoto holds no session state. Each operation is a single message,
SET LOCAL statement_timeout = …; <statement>, which runs as one implicit
transaction. That makes it atomic and costs one round trip. It is also safe
behind PgBouncer in transaction mode, which you should put in front once
you have more than a few dozen clients. Advisory locks are only ever
pg_advisory_xact_lock.
Stale connections. A server restart, a failover or an idle reaper kills
the pool's backends while the server stays up. The pool checks each connection
when it hands it out (ConnectionPool.check_connection, one empty-query round
trip), so a dead one is replaced rather than used. If a connection dies while a
statement is running, the statement is retried once on a fresh connection only
when it cannot have committed: always for a read, and for a write only when the
server reported an error for it (such as AdminShutdown), because the server
rolls that statement's transaction back. A write whose reply was lost may have
committed, so it is not retried. It raises BackendUnavailableError and counts
as a dropped write. The count means "no confirmed reply", not "did not land":
a write that committed but lost its reply (for example the connection dying
while the server waits on a synchronous standby) is counted as dropped even
though the row is there. Check the row before replaying such a write. Statements inside a transaction() are never retried.
When Postgres is unreachable, or a connect or statement timeout fires
(Defaults.PG_CONNECT_TIMEOUT_SECONDS, Defaults.PG_STATEMENT_TIMEOUT_MS),
the call raises BackendUnavailableError. The backend's health record
tracks the outage:
backend = popoto.backends.get_backend(Note)
backend.health.as_dict()
# {'ok': False, 'last_ok_at': ..., 'consecutive_failures': 3,
# 'dropped_writes': 2, 'last_error': 'PoolTimeout: ...'}
The outage is logged at ERROR once per Defaults.PG_OUTAGE_LOG_WINDOW_SECONDS
(60 s), however many calls fail within that window. After a successful call
the record resets, and a recovery is logged at WARNING.
Transactions¶
backend = popoto.backends.get_backend(Note)
with backend.transaction() as uow: # one READ COMMITTED transaction
Note(owner="a", slug="1").save(pipeline=uow)
Note(owner="a", slug="2").save(pipeline=uow)
# both committed, or (on an exception) neither
bulk_create, bulk_update and bulk_delete on a Postgres model each run
inside one transaction(). A unique conflict anywhere in the batch rolls back
the whole batch. Deadlock and serialization failures are retried
automatically, up to Defaults.PG_TRANSACTION_RETRIES times, for single
statements, which then raise popoto.backends.BackendRetryableError. Inside a
transaction() -- including at its commit -- the whole unit rolls back and
BackendRetryableError is raised at once, chained from the driver error;
popoto does not retry a block it does not own:
from popoto.backends import BackendRetryableError
for attempt in range(3):
try:
with backend.transaction() as uow:
...
break
except BackendRetryableError:
continue # rolled back by a concurrent transaction; safe to rerun
After-commit hook. uow.after_commit(fn) registers a no-argument
callable to run once this transaction has committed. Callbacks run in
registration order after COMMIT returns. A rolled-back transaction drops
them, and so does a failure at COMMIT. A callback that raises is logged at
WARNING; the remaining callbacks still run, and the exception is not raised,
because the write has already committed. This hook is how a Postgres-bound
save runs its Redis side effects: the EventStreamMixin XADD and the
WriteFilterMixin priority tag. On Redis both are queued on the save's
pipeline, so a rolled-back transaction must not send them. (The priority tag
is a no-op off Redis, so today only the XADD sends anything.) Without a
caller transaction, the save has already committed by the time these effects
run, so they run immediately. Pinned by
test_after_commit_callbacks_run_only_after_commit and
tests/postgres/test_postgres_journal.py::test_the_mutation_stream_is_written_only_after_commit.
Before #759 M2a's patch these reached the caller as raw psycopg
DeadlockDetected / SerializationFailure. A statement whose completion is
unknown (SQLSTATE 40003) is not retryable -- it may have committed -- and is
reported as BackendUnavailableError.
popoto.batch() (M5)¶
The same code opens a batch on either backend:
pipe = popoto.batch()
Note(owner="a", slug="1").save(pipeline=pipe) # returns pipe, as on Redis
Note(owner="a", slug="2").save(pipeline=pipe)
pipe.execute() # both, or neither
batch() still returns a real redis.client.Pipeline (a subclass, so every
isinstance gate and every Redis byte is what it was). The first write from
a Postgres-bound model that is handed the batch opens one transaction() on
that model's backend, and that write and every later one runs inside it.
execute() commits it and returns []. reset(), leaving a with block,
or dropping the batch without execute() rolls it back, as an unexecuted
Redis pipeline sends nothing. Saves, deletes, increments, touch, the
confidence, read-tracking and ObservationProtocol writes, the
CoOccurrenceField edge writes (link, strengthen, unlink,
weaken_all), the AppendOnlyMixin guard and ProvenanceJournal's
append/supersede/retract all join it. Until execute(), no other
connection sees the writes, as with queued commands. A graph write handed
the batch runs in the batch's transaction, so it reuses the lock the batch
already holds on a record it saved; a second save of an append-only key in
one batch sees the first and is refused, as in a transaction().
Redis side effects follow the commit. A save's EventStreamMixin XADD
(and WriteFilterMixin priority tag, a no-op off Redis) is registered on
the batch's transaction with the after-commit hook (Transactions): it
is sent once execute() has committed, one entry per save, in save order,
and never for a batch that reset(), a with block or a failed
execute() rolled back. On Redis the same effects queue on the pipeline,
so both backends send them exactly when the writes land. These are the only
Redis bytes a Postgres batch causes, and they are sent outside it, after
the commit, so "one batch, one backend" below still holds. Pinned on both
legs by tests/test_batch.py.
Atomic, where Redis is not. If a statement fails inside the batch's
transaction (two saves in the batch that claim one unique value, say), the
call that issued it raises as usual, the transaction is aborted, and
execute() then raises popoto.backends.BackendError and writes nothing. A
Redis MULTI/EXEC applies the other queued commands. A save that is
refused before it sends anything -- pre_save finding a unique value
already held by a committed record, a field validation error -- raises at
the call and leaves the batch healthy, so if you catch it, execute()
commits the rest, as on Redis. batch(transaction=False) is still one
transaction on Postgres. A deadlock inside the batch raises
BackendRetryableError, as inside any transaction().
A lock this thread already holds. Until it commits, a batch holds each
record it wrote. A write from the same thread that would wait for one of
those records outside the batch -- a second, nested batch() or
transaction() that writes the same record, or a plain save() of it --
could never be granted, because the only thread that can release it is the
one waiting. Popoto refuses that write with BackendCapabilityError before
sending it, instead of hanging until PG_STATEMENT_TIMEOUT_MS; the batch
that holds the record is unharmed. Write through that batch, or execute()
it first. Another thread's write simply waits for the commit. Nested batches
that write different records work as on Redis.
One batch, one backend. A batch holding a Postgres transaction refuses a
Redis command, and a batch with Redis commands queued refuses a Postgres
write, with BackendCapabilityError, before anything is sent: a Redis
MULTI/EXEC and a Postgres transaction cannot commit atomically together,
and a batch that only looked atomic would be worse than a refusal. A batch is
also bound to the one Postgres backend it opened on. Use one batch per
backend. SupersessionProtocol's mutators refuse a batch on a Postgres
model, as they refuse any Redis pipeline; pass backend.transaction()'s
unit of work there. (ProvenanceJournal, which calls them, accepts a batch:
it hands them the batch's unit of work.)
Record expiry (M5)¶
class Session(popoto.Model):
token = popoto.KeyField()
user = popoto.StringField()
class Meta:
backend = "postgres"
ttl = 3600 # every save sets the record to expire in an hour
s = Session(token="t", user="u")
s._ttl = 60 # or an instance override, as on Redis
s.save()
A Meta.ttl model's table has one more engine column, _expires_at double
precision (epoch seconds; NULL never expires), with a partial B-tree on
the rows that set it. A model without Meta.ttl gets neither, and none of
the SQL below: its tables and plans are exactly what they were.
Writes. A save sets _expires_at from the instance, as the Redis save
issues EXPIRE/EXPIREAT: now + _ttl (the server's clock), else
int(_expire_at.timestamp()), else it leaves the stored value alone, as
HSET leaves a key's TTL alone. A partial (update_fields) save sets it
too. A zero or negative _ttl, or an _expire_at in the past, expires the
record at once. A save over an expired key that the reaper has not yet
deleted drops the expired row first, so the save writes a fresh record (new
side rows, the confidence seed) and returns the new-record count, as HSET
on a key Redis has expired creates a new hash.
Reads. Every read of the model sees a row only while _expires_at is
NULL or later than now: get, get_many, filter, all, count,
keys, exists, values=, load_fields, top_by_decay,
composite_score, top_by_relevance, BM25Field.search,
keyword_search, semantic_search, load_embeddings, recall,
might_exist, the BM25 corpus statistics, confidence and read-tracking
state, the validity reads and chain, idle_seconds, the AppendOnlyMixin
guard, and ContextAssembler through all of them. An expired row is gone to every reader the instant it expires, deleted
or not. Now is the server's statement_timestamp(), one clock for every
agent on the central database. A _ttl is added to that clock, so the app
machine's clock never moves it; only an _expire_at, an absolute instant
from the app, is exposed to skew between the app and the database server,
exactly as EXPIREAT is judged by the Redis server's clock. (Decay and the
validity as_of stay on the app's clock, as on Redis.) tests/postgres/test_postgres_ttl.py
exercises each of those reads against one expired record. A write addressed
to one record (atomic_increment, a capped push, touch,
update_confidence) treats an expired row as missing.
The reaper. No cron, no CLI, no manual job (#755 Q3). After a record
write on a Meta.ttl model commits (a save, delete, increment or capped
push, or a transaction()/batch() that wrote one), the backend deletes up
to Defaults.PG_REAPER_BATCH (20) expired rows of that table in a
transaction of its own; their postings, vectors, membership and validity
rows cascade. It runs at most once per Defaults.PG_REAPER_INTERVAL_SECONDS
(1 s) per table and process, except that a run which found a full batch
leaves the next write due at once, so a backlog drains a batch per write. It
takes the backend's lock order without ever waiting: each candidate's
record-key lock is a try-lock (a record a writer holds is skipped), rows are
locked SKIP LOCKED in _pk order and re-checked (a save that refreshed the
TTL keeps its row), and lock_timeout is
Defaults.PG_REAPER_LOCK_TIMEOUT_MS (50 ms), below deadlock_timeout, so
the reaper gives up before a caller could be picked as a deadlock victim. A
busy pool, a lock it would wait on, or an outage skips the run; it never
fails the write that triggered it and does not touch the health record.
Reads never reap, by decision. A read stays a single read-only
statement: it takes no row locks, works on a read-only role or a replica,
and its plan does not change with the backlog's size. Correctness does not
need the reaper, since every read filters expired rows out. The cost is that
a workload that stops writing to a Meta.ttl table stops draining it: the
expired rows stay until the next write, and the reads that filter them with
an anti-join (the BM25 corpus statistics, the membership reads) pay for each
one. The backlog is therefore bounded by write traffic: at most the rows
that expired since the table's last write, and each later write drains a
batch of 20 at once. A table that is read for long stretches without writes
and holds many short-lived rows can be drained by any write to it, such as
saving and deleting one record.
Cost, measured as the p50 of 300 saves (Apple M1 Max, PostgreSQL 18.6 on
localhost, load 5-7, three runs each): a model without Meta.ttl
0.33-0.35 ms; a Meta.ttl model with the reaper idle 0.37-0.39 ms; the
same with the reaper forced on every write and nothing due 0.66-0.79 ms
(what the 1 s interval saves); a save that reaps a full batch of 20 rows
1.07-1.09 ms (100 rows: 2.2-3.5 ms, which is why the batch is 20).
Reading a remaining TTL. backend.ttl_remaining(spec, ids) answers what
redis.ttl(key) answers: -2 for no live record, -1 for no expiry, else
the seconds left, rounded as Redis rounds its milliseconds.
The seeded probe. scripts/probe_ttl_parity.py runs random sequences of
saves (every kind of TTL above, full and partial), deletes and batches on
both backends, compares every outcome and every read (get, exists,
filter, a range filter, count, the remaining TTL), sleeps past the short
expiries and compares again, then writes to the expired records. On the
Postgres side alone it also freezes the clock at, just before and just after
each expiry instant and checks visibility against an exact model of the
sequence. Its documented classes are the TTL rows in the divergence table
below; tests/postgres/test_postgres_ttl.py runs a 40-shape slice.
REDIS_URL=redis://localhost:6379/10 \
POPOTO_POSTGRES_URL=postgresql://localhost:5432/postgres \
python scripts/probe_ttl_parity.py --seeds 1 2 3 --shapes 120
Ranking and memory state (M2a)¶
Each operation is one statement. top_by_decay is rank_decayed (below)
followed by one load of the ranked rows, in rank order; a model with
AccessTrackerMixin then stages the reads in one more UPDATE.
rank_decayed is DECAY_SCORE_LUA as a SELECT, operation for
operation in double precision, so the platform's pow behind power()
gives the Lua's bits:
SELECT t."_pk",
((CASE WHEN b < 0 THEN -1 ELSE 1 END) * abs(b)
* power(greatest(($now - t.f) / 86400, 0.01), -$rate))
-- with confidence modulation:
* power(greatest(greatest(($now - t.f) / 86400, 0.01), 1),
-($rate * power(2, $s2 * ($c0 - greatest(0, least(1, coalesce(t.c__conf, $c0)))))
- $rate)) AS "_score"
FROM popoto.<model> AS t
WHERE t.f IS NOT NULL AND <partition filters>
ORDER BY ("_score" = 'NaN'), "_score" DESC, "_pk" COLLATE "C"
LIMIT $n
b is the base-score column (1.0 when there is none, or it is NULL, a
string or a boolean, as the script's HGET + cmsgpack rule gives; a
numeric converts through the same strtod the script's tonumber uses),
$s2 is twice Defaults.DECAY_CONFIDENCE_MODULATION_STRENGTH, and $c0 the
confidence field's initial_confidence. Only the partition filters scope the
scan, as on Redis, where they pick the sorted set.
Postgres raises value out of range where C's pow and * overflow to
inf or underflow to 0 (power(0.01, -155), 1e-300 * 1e-300; the #631
POC's boundary rows). Rows whose inputs sit in a box where no step can leave
the double range -- every realistic one -- take the expression above; any
other row, and every row when the rate or strength is extreme, takes a
correlated subquery that computes the same steps with each power, * and
- clamped, so the result is inf or 0, with its sign, where the Lua's is.
Inside a band of 0.003 in log space at each end of the double range (a
result within 0.25% of DBL_MAX, or between 2.47e-324 and 2.48e-324) the
clamp saturates where the exact value is still finite; that is the only place
the two legs can differ. Scores come back rounded through %.14g, which is
what the Lua's tostring replies with.
update_confidence is CAPPED_BAYESIAN_UPDATE_LUA as one UPDATE:
UPDATE popoto.<model> SET
c__conf = greatest(0, least(1, coalesce(c__conf, $c0)
+ ($signal - coalesce(c__conf, $c0)) / (least(coalesce(c__n, 0) + 1, $cap) + 1))),
c__n = coalesce(c__n, 0) + 1,
c__corr = coalesce(c__corr, 0) + <1 if $signal >= 0.5>,
c__contra = coalesce(c__contra, 0) + <1 if $signal < 0.5>
WHERE "_pk" = $pk
RETURNING c__conf, c__n, c__corr, c__contra
The arithmetic is the script's, so the stored confidence is bit-identical to
the Redis one; the returned value is rounded through %.14g as the script's
reply is. No row means the record does not exist: the field layer raises the
same TypeError as on Redis (with a pipeline, the update is skipped, as the
queued script skips it).
composite_score is one SELECT over the model's rows: each index is an
arm scoring the records its Redis sorted set would hold (the decay arm the
partition's clocks, the confidence arm every record, the access arm every
record read at least once), a record is ranked when any arm holds it, and the
aggregate runs over the arms that do -- SUM in the indexes' order (a NaN
step, inf + -inf, becomes 0 as ZUNIONSTORE makes it), MAX/MIN
ignoring the rest. Ties come back in descending key order, as ZREVRANGE
gives; min_score applies before temperature, and post_filter after it,
as on Redis. Two differences stay below the 1e-9 tolerance: Redis's decay arm
holds the script's %.14g-rounded scores where Postgres uses the full
double, and Redis adds three or more arms in its own set order rather than
the indexes', which can move a sum by an ulp.
The seeded probe. scripts/probe_memory_parity.py builds the same random
corpus on both backends -- ages from fresh to centuries, the future, shared
timestamps and pathological clocks; base scores of every type the script reads,
from 5e-324 to 1e305; signal sequences; partitions; staged and confirmed
reads -- and compares rank_decayed, top_by_decay, confidence,
read tracking and composite_score between the legs. Its classes and the
two documented NaN rows are in the PR that introduced M2a;
tests/postgres/test_memory_probe.py runs a 40-shape slice of it in CI.
REDIS_URL=redis://localhost:6379/10 \
POPOTO_POSTGRES_URL=postgresql://localhost:5432/postgres \
python scripts/probe_memory_parity.py --seeds 1 2 3 --shapes 200
ObservationProtocol.on_context_used applies all five outcomes for a
batch in one transaction: the batch's rows are locked FOR UPDATE in _pk
order first (the plan's §6 lock order), then each instance's effects run in
that order -- touch for acted, staged reads confirmed (acted, used) or
discarded (the rest), the confidence signal (acted, contradicted), and its
proposal resolved. A deadlock or serialization failure retries the whole
batch up to Defaults.PG_TRANSACTION_RETRIES times and then raises
BackendRetryableError; inside a transaction() you pass as pipeline it
raises that error at once, for you to retry.
Documented divergences¶
Redis behaviour does not change to match Postgres in v2 M1 (plan gate (a)).
Where a test pins one of these, it pins both behaviours explicitly or carries a
redis_only(reason=...) mark.
Query results¶
Each row is pinned on both conformance legs: rows (i)–(xi) by a test in
tests/test_backend_parity_edges.py, rows (xii)–(xx) (M1.1) in
tests/test_backend_parity_fields.py. In rows (i)–(iv), (vi), (vii), (xii),
(xiv), (xv) and (xviii)–(xx) Redis's result is a bug in its query layer,
tracked in #771. The examples use code/group (KeyField), rank
(SortedField), note/hits (unindexed) and at (an unindexed
DatetimeField); the M1.1 rows use IndexedFields named by type (s str,
i int, f float, dec Decimal, dte date), a DateField day, a
ListField lst, a capped ListField(max_length=3) cap, a
TupleField pair and a Relationship author/owner.
| Example | Redis (unchanged) | Postgres | Correct | |
|---|---|---|---|---|
(i) An unindexed field inside a composed Q, or a Q beside a kwarg |
filter(Q(hits=5) & Q(group="g1")) |
the plain leaf is dropped: every g1 row |
rows with hits = 5 in g1 |
Postgres |
| (i′) The same, negated | filter(~Q(note="x")) |
always empty: the leaf returns every key and its equality filter runs after the set algebra | NOT, with NULL counted as "not equal" |
Postgres |
(ii) values= with an unindexed-field filter outside the projection |
filter(group="g1", note="x", values=("code",)) |
[]: the filter runs on dicts that lack note |
the matching rows | Postgres |
(iii) Two lookups bounding the same side of one SortedField |
filter(rank__gt=10, rank__gte=1) |
only the last lookup for that side applies: every row | both apply (AND): [] |
Postgres |
(iv) values= + order_by over a column holding None |
filter(rank__gte=0, values=("code", "hits"), order_by="hits") |
TypeError: '<' not supported… |
rows, NULL sorted as the type's zero (as instances sort on both) |
Postgres |
(v) A bad SortedField range bound |
filter(rank__gte=None), filter(rank__lte="abc"), filter(rank__lte="nan") |
None: ResponseError: min or max is not a float. A string the server's strtod refuses ("nan", "1_0", "3 ", "abc", a non-ASCII digit) raises the same ResponseError (on a Decimal field Python's own ValueError, which converts first) |
a refused string raises QueryException("min or max is not a float"): the same text, a different class. None returns []. The strings Redis accepts parse identically ("+3", "3.", "1e1", "inf"; Redis also takes a leading space, hex and "" as 0, which Valkey refuses, and Postgres follows Redis). The ZRANGEBYSCORE exclusive prefix ("(3") is not supported: it raises. |
Redis (failing loudly is the better answer) |
(vi) KeyField __contains |
filter(code__contains="0") |
matches nothing (the lookup is accepted but not implemented) | LIKE '%0%' |
Postgres |
(vii) KeyField __isnull=False |
filter(group__isnull=False) |
only some non-null records match, depending on the key's position and value (a second key matches none; values such as "10", or containing _ or %, are missed) |
IS NOT NULL |
Postgres |
(viii) Equality on a DatetimeField with a naive value against a stored aware one |
filter(at=datetime(2024, 1, 5, 12)) |
compared in Python: naive never equals aware | compared as instants, naive taken as UTC (the rule sorted fields use, #519) | Postgres |
(ix) Equality on KeyField(type=Decimal) |
filter(code=Decimal("1.50")) against a stored 1.5; filter(code=2) against 2.0 |
key strings are compared, so both match nothing | numeric values are compared, so both match |
Postgres |
(x) Saving Decimal("1.5") and then Decimal("1.50") as KeyField(type=Decimal) |
create(code=Decimal("1.50")) after 1.5 |
two records ("1.5" and "1.50") |
unique violation: the values are equal | Postgres |
(xi) KeyField(type=float) -0.0 against a stored 0.0 |
filter(code=-0.0) |
[]: "-0.0" != "0.0" |
[0.0] |
Postgres |
| (xii) Chained relationship lookup | filter(author__country="uk") |
AttributeError: filter_query gets bytes keys back and calls .db_key on them |
resolves the related model's query and matches its keys | Postgres |
| (xiii) Equality on a collection field with another collection type | filter(pair=[1, "x"]) on a TupleField holding (1, "x") |
Python equality after hydration: a tuple never equals a list, so [] |
compares the stored JSON documents, so the list matches (a SetField compares as a set) |
Postgres is the more useful; neither is wrong by contract |
(xiv) IndexedField numeric equality across value forms |
filter(i=1.0) on 1; filter(f=1) or f="1" on 1.0; filter(dec=Decimal("1.5")) on 1.50; filter(i=True) on 1; i__in=[1, 2.0] |
compares the filter value's key string ("1.0" ≠ "1"), so []. Save coerces i=1.0 to 1, so a record saved with i=1.0 cannot be found by filter(i=1.0) |
compares numbers, as Python equality does (1 == 1.0, Decimal("1.5") == Decimal("1.50")) |
Postgres |
(xv) __startswith/__endswith on a row holding None |
filter(s__startswith="No"), s__endswith="ne", s__startswith="", f__startswith="N" |
the None set is named …:None, so the glob matches rows holding None |
LIKE never matches NULL |
Postgres |
(xvi) Pattern lookup on a float in 1e15 <= abs(x) < 1e16, or a Decimal Python writes in exponent form |
filter(f__startswith="1000") on 1e15 |
matches the key string 1000000000000000.0 |
no match: Postgres renders 1e+15 (and numeric never uses exponent form) |
Redis (the key string is the lookup's contract; outside this band Postgres renders it exactly) |
(xvii) order_by on a collection field |
filter(…, order_by="lst") |
sorts as Python does ([] < [1, 2] < [2]); TypeError for mixed or dict elements |
BackendCapabilityError: jsonb orders by length first ([2] < [1, 2]), so Postgres refuses rather than return a different order. Sort in Python. |
Redis, where it does not raise |
(xviii) order_by on a date column holding None (IndexedField(type=date), DateField) |
filter(…, order_by="day") |
TypeError: function missing required argument 'year': None sorts as the type's zero, date() |
NULL first (last for -day) |
Postgres |
(xix) order_by on a Relationship |
filter(…, order_by="owner") |
AttributeError: … '_meta' |
ordered by the stored key string, NULL first |
Postgres |
(xx) A capped ListField on a lazy read |
filter(…), all(); filter(…, values=("name", "cap")) |
cap is None (only get loads the separate list key); values= omits cap and logs quarantined field 'cap' per row |
the stored list, as get returns on both |
Postgres |
Numeric key fields otherwise follow Redis's key-string rules on both backends
(KeyField(type=int) matches 1 and "1" but not 1.0; an integer beyond
bigint matches nothing), and a SortedKeyField matches by score (2.0,
"01" and " 1" all find 2 and 1). Redis's SortedKeyField __in
ignores the filter and returns every record (#771); Postgres filters, by
score, with mixed numeric types allowed (pinned:
test_sorted_int_key_in_with_mixed_types). SortedKeyField equality with
True, False or None raises ResponseError: min or max is not a float on
Redis; Postgres matches the score 1/0, and None matches nothing (pinned:
test_sorted_key_equality_with_bool_or_none_is_a_documented_divergence).
A lone unindexed-field Q (filter(Q(hits=5))), one lower plus one upper
bound on a sorted field, and values= that projects the filtered field all
agree on both backends.
M1.1 cases that agree on both backends, and are pinned as such: a string
filter on an IndexedField of type bool, date or datetime that is the
stored value's key string (b="True", dte="2026-01-01",
dtm="2026-01-01T12:00:00.000000Z"; "true" matches neither); an aware
time filter on a TimeField, which matches nothing; __startswith /
__endswith on a non-text IndexedField, matched against the key string
(1.0, True, 2026-01-01, 1.50, …T12:00:00.000000Z) except row (xvi);
and __in with mixed numeric types (i__in=[1, Decimal("2")]), each element
cast to the column's type.
Records and other behaviour¶
| Behaviour | Redis (unchanged) | Postgres |
|---|---|---|
save(migrate_key=True) that changes a key field |
rewrites the key and its index entries | raises BackendCapabilityError (v2). Create the new record and delete the old one. |
| Unit-of-work / bulk failure | MULTI/EXEC applies the other queued commands |
the whole transaction rolls back |
\x00 in a text value |
stored | ValueError naming the field |
atomic_increment past 2**63 on an IntField |
Lua goes to float | NumericValueOutOfRange |
A Redis pipeline= handed to a Postgres model |
queued | the write runs immediately, and the pipeline comes back untouched for the caller to execute. A popoto.batch() is the exception since M5: the write joins the batch's Postgres transaction (see popoto.batch()) |
atomic_increment of an IntField by a non-integral delta (hits=5, +1.5) |
stores the float sum 6.5 and returns it truncated, 6 |
stores and returns the sum cast to bigint, rounded half away from zero: 7. Neither is well defined; pass an int delta. |
Order of results with no order_by, no Meta.order_by and no sorted-field filter |
set order (arbitrary) | _pk in bytewise (COLLATE "C") order |
An invalid order_by= / values= on a query that matches nothing |
returns [] before validating |
raises the same QueryException either way |
save(update_fields=…) on a record that does not exist yet |
writes a partial hash that stays out of the class set, so queries do not see it | inserts the row (unlisted columns NULL), so queries see it |
UniqueKeyField / UniqueField / unique Meta.indexes conflict |
checked by a read in pre_save before the write |
the same read, through the backend, plus a UNIQUE index inside the write as the authority (it also catches two conflicting saves in one transaction()); same ModelException text either way (tests/postgres/test_postgres_fields.py) |
An aware time in a TimeField / SortedField(type=time) (M1.1) |
stored with its offset (isoformat()) |
ValueError naming the field: a time column holds wall-clock time only. Use a DatetimeField when the offset matters. Pinned: tests/postgres/test_postgres_fields.py::test_an_aware_time_is_refused |
push() on a capped ListField whose record was deleted (M1.1) |
LPUSH recreates an orphan list key |
raises ModelException (UPDATE finds no row). After a successful push() the in-memory list is the stored list, not a local prepend. Pinned: test_push_on_a_record_that_no_longer_exists_raises |
load_raw_hash, Query.keys(catchall=/clean=) |
Redis debug and inspection APIs | raise BackendCapabilityError |
Model.idle_seconds (M4) |
OBJECT IDLETIME: any read or write resets it (an HGETALL included) |
whole seconds since the row's last write or, with AccessTrackerMixin, its last confirmed read; an unconfirmed read does not reset it. Pinned: tests/postgres/test_postgres_recipes.py::test_idle_seconds_counts_a_confirmed_read |
MemoryLifecycle with a KeyField tier (M4) |
promotion is a key migration (save(migrate_key=True)) |
refused when you build it: MemoryLifecycle(...) raises BackendCapabilityError naming IndexedField, because every promotion would be a key migration, which v2 refuses. Declare the tier as an IndexedField on Postgres; promotion is then a plain save. Pinned: test_a_key_tier_lifecycle_is_refused_at_construction_on_postgres, test_lifecycle_promotes_a_non_key_tier, and tests/test_memory_lifecycle.py, whose Postgres leg runs every test against IndexedField-tier twins of its models |
| A question-queue delivery when another transaction holds a candidate's row (M4) | the script runs after the other write and sees it | FOR UPDATE SKIP LOCKED: that candidate is passed over for the next, as one another worker claimed would be. Pinned: test_postgres_question_queue.py::test_a_candidate_another_writer_holds_is_skipped |
| A proposal that duplicates two or more open candidates (M4) | folds into the first in QuestionCandidate.query.filter(agent_id=…)'s order: set order |
the first in _pk order (the "order of results" row above, seen through dedup). Pinned on both legs by tests/test_question_queue.py::TestPropose::test_a_proposal_duplicating_two_candidates_folds_into_the_first, and counted as the queue probe's dedup_order class |
DefaultMemory's eviction counter (M4) |
a Redis string MemoryService.status() reads |
a popoto_counter row: a Postgres-bound DefaultMemory needs no Redis, and the Redis-only MemoryService does not report it |
ProvenanceJournal with a caller pipeline= (M4) |
a Redis pipeline: the annotation and close are queued, target_closed is None and close_index names the close in execute()'s results |
the backend's unit of work only (anything else raises ValueError): the annotation and close run inside it, target_closed is known at the call, close_index is None. Pinned: test_postgres_journal.py::test_a_caller_unit_of_work_carries_the_annotation_and_the_close |
EventStreamMixin on a Postgres-bound model (JournalEntry, M4) |
XADD in the save's pipeline, so it commits or is discarded with the write |
XADD to Redis after the Postgres write commits (after the caller's transaction() commits, when there is one), never for a rolled-back write; best-effort, so with Redis down the row commits and no entry is sent; until the stream moves in M5 |
AppendOnlyMixin: two saves of one key in one unit of work (M4) |
both pass the guard (the documented intra-pipeline shape) | the second is refused (the guard reads inside the transaction). Pinned: test_postgres_recipes.py::test_append_only_sees_its_own_transaction |
async_get/async_filter/async_count/… |
native redis.asyncio |
run the sync call in a worker thread (the async driver arrives in M5) |
ExistenceFilter.might_exist (M2b) |
a bloom filter: false positives are possible, and a deleted record stays "seen" | exact: no false positives, and a deleted record is forgotten (plan §1.1). Pinned on both legs: test_existence_filter.py::TestMembershipExactness |
FrequencySketch.get_frequency (M2b) |
a count-min sketch: never under, may be over | the exact count of saves (never decremented, like the sketch). Pinned: same class |
ExistenceFilter.fill_ratio (M2b) |
the fraction of set bits | an estimate, 1 - e^(-k·n/m) for the n distinct tokens stored |
save(update_fields=[…]) naming a BM25Field's or EmbeddingField's source, or only a scope column (M2b) |
only the listed fields' hooks run, so the BM25 index keeps the old text or scope, and the vector (and its hash) the old text | re-indexes and re-embeds on the source; moves the postings and the narrow vector row on a scope change. Pinned: test_update_fields_naming_the_source_reindexes, test_update_fields_naming_the_source_re_embeds |
save(update_fields=[…]) naming a partitioned sorted field (DecayingSortedField, SortedField(partition_by=…)) but not its partition column, after an unsaved change to that column (M2b, #774 review) |
the field's hook reads the partition from the instance, so the member moves to the instance's partition sorted set while the hash keeps the old value: filter(agent="B").top_by_decay() finds a record whose agent is "A" (#771) |
the partition is the stored column, so the record stays where the row says; the BM25 postings, document length and narrow vector row follow the stored row too. Pinned: test_backend_parity_memory.py::test_a_partial_save_with_an_unsaved_partition_is_a_documented_divergence |
EmbeddingField storage (M2b) |
a .npy file per record plus _index.json, and an in-process matrix cache |
the vector(d) column. No file, no cache, and garbage_collect / sweep_stale_tempfiles return 0. The tests that assert files are redis_only |
| Vector-arm ties and precision (M2b) | equal similarities come back in directory-listing order; numpy float32 dot products | ties by key, bytewise; pgvector's <=>. Both are float32 accumulations in a different order, so similarities differ by an amount that grows with the dimension. Measured maximum absolute difference over 10,000 vector-query pairs per dimension (clustered Gaussian vectors, PostgreSQL 18.6, pgvector 0.8.7): 2.5e-7 at 2-d, 2.3e-7 at 8-d, 5.3e-7 at 64-d, 8.9e-7 at 256-d, 1.2e-6 at 768-d, 1.4e-6 at 1024-d, 1.7e-6 at 1536-d (the #774 review measured 1.56e-6) and 2.3e-6 at 3072-d. So 1e-6 holds only up to about 256 dimensions; above that, near-ties can order differently |
ContentField (M2b) |
a $CF: reference in the hash, with the content in a file store |
the content itself in a text column |
BM25Field.recompute_stats (M2b) |
corrects the running avgdl's floating drift |
a no-op: N and avgdl are counted live |
The $BM25: / $EF: / $FS: keys (M2b) |
the index, readable through the raw client | not used: postings, length and token tables. The tests that read the keys are redis_only |
A reload after touch() (M2a) |
touch moves only the sorted-set score, so the hash, and a reload, keep the save-time value |
the clock is the field's column, so a reload sees the touched time. Pinned: test_a_reload_after_touch_is_a_documented_divergence |
DecayingSortedField.rank_decayed(zset_key, …) (M2a, TD-40) |
ranks that sorted set | raises BackendCapabilityError naming top_by_decay, the backend-neutral call |
composite_score({"priority": …}) (M2a) |
ranks by the WriteFilter priority set | raises BackendCapabilityError: the priority tier is a no-op on Postgres (plan §5 M2) |
composite_score(similarity_boost=…, co_occurrence_boost=…) (M2b, M4) |
injects each boost as an arm | an arm on both. A key with no record cannot take a top-K slot on Postgres, where on Redis it takes one and is then dropped at hydration, so Redis can return fewer records (never in another order). Pinned: test_backend_parity_memory.py::test_composite_co_occurrence_boost_is_an_arm_on_both_backends; the graph probe's composite_orphan_slot class |
A NaN edge weight (link(…, initial_weight=nan) on a new edge, a NaN strengthen or weaken_all result) (M4) |
ZADD refuses it: ResponseError: value is not a valid float. A symmetric write whose second script fails keeps the first script's write |
ValueError with the same text, and the whole write rolls back. Pinned: test_postgres_graph.py::test_a_nan_weight_raises_value_error |
A NaN weight in CoOccurrenceField.import_state (one that survives the max_edges truncation) (M4) |
DELETE runs, then ZADD refuses the NaN: ResponseError: value is not a valid float, and the record's edge set is left empty |
refused before any write: ValueError with the same text, and the edge set is unchanged. No NaN edge is stored on either (stored, Postgres would rank it above every weight and least(NaN, cap) is the cap). Pinned on both legs: test_co_occurrence_field.py::TestImportStateAndErrorParity::test_a_nan_import_is_refused_a_documented_divergence; the graph probe's nan_import_keeps_set class |
CoOccurrenceField.get_linked(…, limit=None) (M4) |
redis-py refuses it client-side: DataError: ``start`` and ``num`` must both be specified (before any NaN-bound check) |
ValueError with the same text, in the same order. Pinned on both legs: test_get_linked_limit_none_a_documented_divergence; the graph probe's limit_none_error_type class |
A NaN min_weight in CoOccurrenceField.get_linked (M4) |
ResponseError: min or max is not a float |
ValueError with the same text. Pinned on both legs: test_get_linked_nan_min_weight_a_documented_divergence; the probe's nan_error_type class |
The order of propagate()'s dict, and so of equal weights in graph_traversal.traverse() (M4) |
Lua table iteration order | weight descending, then key bytewise. The dict and its weights are equal on both; traverse() sorts by weight, so only ties can be listed in another order (the graph probe's traverse_tie_order class) |
link()'s reply for a weight at or past 2**63 in magnitude (M4) |
the server's C (long long) cast of the Lua number: -inf and anything under -2**63 reply -2**63 on arm64 and x86-64; above 2**63 (only with a cap past it) arm64 saturates to 2**63 - 1, x86-64 replies -2**63 |
the arm64 values |
CoOccurrenceField.strengthen() on a model with EventStreamMixin (M4) |
appends a strengthen entry to the model's Redis stream |
no entry: the event stream is Redis-only until M5 |
| Where a NaN decay score ranks (M2a) | NaN (0 * inf: a -inf clock with above-prior confidence) makes the script's comparator inconsistent (x > nan is always false), so table.sort places it arbitrarily and can misorder real scores around it |
real scores sorted, NaN last; every member's score is the same on both. Pinned: test_where_a_nan_score_ranks_is_a_documented_divergence |
A NaN decay score in composite_score (M2a) |
rank_decayed replies nan (0 * inf: a -inf clock with above-prior confidence) and the composite's ZADD refuses it: ResponseError: value is not a valid float |
that arm scores 0 for the record, the value ZUNIONSTORE gives a NaN product. Pinned: test_a_nan_decay_score_in_composite_is_a_documented_divergence |
| The confirmed access log (M2a) | a capped list of read timestamps ($AT:…:access_log) |
not kept: access_count and last_accessed are. It is read only by export_state, which arrives with transfer/ in M5 |
update_confidence(…, pipeline=uow) with a Postgres transaction() (M2a) |
(a Redis pipeline queues the update and returns None) |
the update runs inside the transaction, so its value is returned and the attribute synced |
A model with a CyclicDecayField or PredictionLedgerMixin (M2a) |
supported | refused at declaration until M5, so ObservationProtocol's cycle, auto-discharge and ledger-resolution effects have no Postgres model to act on yet (its supersession effect runs from M3; CoOccurrenceField is stored from M4) |
execute_supersede(mode="open") naming a member with no record (M3) |
ZADD NX indexes the member anyway |
writes nothing: the interval is the record's row. Only a direct execute_supersede call can ask for it. Pinned: tests/postgres/test_postgres_validity.py::test_mode_open_on_a_member_with_no_record_writes_nothing |
| An open-claim pointer naming a record that does not exist (M3) | storable (a manual SET, or a partial import_state); supersede reads it as "no incumbent" |
unrepresentable: the pointer table's foreign key refuses it, and deleting a record cascades to its pointers. import_state for a record that is not stored raises ValidityMemberAbsentError (a ValidityError, so a ValueError) chained from the driver's ForeignKeyViolation; Redis's import_state never raises there. Pinned: test_a_pointer_cannot_name_a_record_that_does_not_exist |
save_and_supersede / save_and_invalidate whose close fails (M3) |
MULTI/EXEC keeps the successor's save, and the typed error's text carries redis-py's Command # N (...) of pipeline caused error: prefix |
the whole unit rolls back, so the successor is not saved either; same exception type, and the text is the bare reply line |
A NaN valid_from on save (M3) |
the script's ZADD refuses it: ResponseError: … value is not a valid float from the pipelined EVALSHA, after MULTI/EXEC has written the record's hash, so the record exists with no interval |
refused before anything is written: ModelException("value is not a valid float") -- the same text, popoto's save error (as row (v) of the query table). Stored, a NaN start would sort above every float and hide the record from every gate. Pinned: tests/test_validity_parity.py::TestNanInstants::test_a_nan_valid_from_on_save_is_refused |
A NaN instant in supersede / invalidate / execute_supersede (M3) |
ResponseError: value is not a valid float script: …. With only valid_from NaN (a real close instant), SUPERSEDE_LUA's validation phase lets it through and the successor's ZADD fails in the mutation phase, after the incumbent was closed and chained, with the pointer still naming it: half-written state, issue #778 |
ValueError("value is not a valid float (<instant> is NaN)"): the same text, a different class, raised before the first write, so nothing is written. Pinned: TestNanInstants::test_a_nan_at_is_refused_and_writes_nothing and ::test_a_nan_valid_from_alone_in_execute_supersede |
A NaN as_of / validity__as_of (M3) |
the range reads (filter, resolve_*_keys, the composite mask) raise ResponseError: min or max is not a float; the decay ranking's gate excludes nothing |
QueryException with the same text (as row (v) of the query table); the decay ranking excludes nothing |
SupersessionProtocol.supersede/invalidate with the backend's transaction() as pipeline (M3) |
(a Redis pipeline queues the script; the closed key is unknown until execute()) |
runs inside the transaction: the closed key is returned and a typed error raised at the call. A Redis pipeline is refused with ValueError by supersede, invalidate and save_and_* alike: it cannot carry a Postgres write. Pinned: test_a_redis_pipeline_is_refused_by_supersede_and_invalidate |
_ttl / _expire_at on a model without Meta.ttl (M5) |
EXPIRE/EXPIREAT on the hash |
BackendCapabilityError before anything is written: only a Meta.ttl model has _expires_at and the read filter, so a model that never expires keeps plans that never check. Declare Meta.ttl; an instance can opt out with _ttl = None. Pinned on both legs: test_backend_parity_ttl.py::test_an_instance_ttl_without_meta_ttl_is_a_documented_divergence, and tests/postgres/test_postgres_ttl.py::test_an_instance_ttl_needs_meta_ttl |
A _ttl that is not a whole number (1.5) (M5) |
MULTI/EXEC writes the hash, then EXPIRE fails: ResponseError: value is not an integer or out of range, and the record stays with its old TTL, or none |
ModelException with the same text, before anything is written. Pinned: test_backend_parity_ttl.py::test_a_fractional_ttl_is_a_documented_divergence |
count() / keys() after a record expires (M5) |
count the class or index set, which keeps the expired member until a hydrating read (get, filter, all) purges it or clean_indexes runs |
count live rows only. Probe class count_orphans. Pinned: test_backend_parity_ttl.py::test_count_after_expiry_is_a_documented_divergence |
| Rankings, search and membership after a record expires (M5) | the sorted sets, BM25 postings, vector file and bloom keep the member: top_by_decay(n=…) can give a slot to it and return fewer than n after hydration drops it, BM25's N/avgdl/df count it, and might_exist stays True |
the record is in none of them from the instant it expires: n live records, statistics over live documents, might_exist False once no live record holds the token. Pinned: test_backend_parity_ttl.py::test_ranking_after_expiry_is_a_documented_divergence, and each read in tests/postgres/test_postgres_ttl.py::test_every_public_read_misses_an_expired_record |
| State keyed by an expired record (M5): its confidence entry, validity intervals and open-claim pointers, staged reads | kept in their own keys until something cleans them; ConfidenceField.update_confidence refuses (its script checks the hash) |
gone with the row: reads see the seed / no interval / no pointer, and chain stops at it as at a hard delete. update_confidence refuses on both. Pinned on both legs: test_backend_parity_ttl.py::test_state_keyed_by_an_expired_record_is_a_documented_divergence; the validity half in tests/postgres/test_postgres_ttl.py::test_validity_reads_miss_an_expired_record |
| A save over an expired key (M5) | HSET creates a new hash, but the expired record's companion state (confidence entry, BM25 postings, interval) is still there for the new one to inherit |
the expired row and its side rows are deleted first: a fresh record, confidence at the seed. Pinned on both legs: test_backend_parity_ttl.py::test_a_save_over_an_expired_key_is_a_documented_divergence; the postings in tests/postgres/test_postgres_ttl.py::test_a_save_over_an_expired_key_writes_a_fresh_record |
atomic_increment through an instance whose record expired (or was deleted) under it (M5) |
the script HSETs the field onto a fresh key: a one-field hash with no TTL, outside the class set |
ModelException (… no longer exists), as for any missing row. Probe class increment_after_expiry. Pinned: test_backend_parity_ttl.py::test_increment_after_expiry_is_a_documented_divergence |
| The instant of expiry (M5) | a key is expired once the server's millisecond clock is past its expiry, so it is still there at that exact millisecond | a row is expired once _expires_at <= now (microseconds), so ttl=0 is gone even under a frozen clock. Observable only with the frozen test clock: two real clocks never land on one instant |
Removing Meta.ttl from a model whose table has _expires_at (M5) |
the next save simply stops issuing EXPIRE; the key keeps the TTL it had |
SchemaDriftError (a column the model no longer declares): drop the column by hand, or keep Meta.ttl and set _ttl = None per instance. Pinned: test_backend_parity_ttl.py::test_removing_meta_ttl_is_a_documented_divergence |
A failed statement inside popoto.batch() (M5) |
MULTI/EXEC applies the other queued commands |
the whole batch rolls back; execute() raises BackendError. Pinned: test_backend_parity_ttl.py::test_a_failed_batch_is_a_documented_divergence |
A popoto.batch() used for both Redis and Postgres writes (M5) |
one store: a raw command and a model save share one MULTI/EXEC |
refused with BackendCapabilityError before the second backend's first command, in either order. Pinned on both legs: test_backend_parity_ttl.py::test_a_batch_of_raw_commands_and_model_writes_is_a_documented_divergence; the reverse order in tests/postgres/test_postgres_ttl.py::test_a_batch_refuses_to_mix_backends |
A write that waits on a record an open batch or transaction() on the same thread holds (M5): a nested batch writing the same record, or a plain save of a record saved in an open batch |
a queued command holds no lock: both apply, in execution order | BackendCapabilityError before anything is sent (it could never be granted); the holding batch is unharmed. Another thread waits for the commit. Pinned: tests/postgres/test_postgres_ttl.py::test_nested_batches_writing_one_record_are_refused_at_once, ::test_a_write_outside_the_batch_to_a_record_in_it_is_refused |
Validity and supersession (M3)¶
A ValidityField keeps the six Redis keys' state on the record's row and in
one companion table:
| Redis key | Postgres |
|---|---|
…:valid_from / …:invalid_at / …:ingested_at sorted sets |
<f>__valid_from / <f>__invalid_at / <f>__ingested_at, double precision (NULL = absent from that index; 'Infinity' = open) |
…:chain:fwd / …:chain:rev hashes |
<f>__superseded_by / <f>__supersedes on the record |
…:open:{digest} strings |
<table>__<f>__open (digest PRIMARY KEY, member), member referencing _pk ON DELETE CASCADE |
Why not tstzrange. The plan proposed one tstzrange column. A
timestamptz keeps microseconds, so the Redis score 1700000000.1234567
comes back 1700000000.123457, two scores one ulp apart become one instant,
and the gate's invalid_at <= as_of flips for a close one ulp after as_of
(pinned: tests/postgres/test_postgres_validity.py::test_timestamptz_would_not_hold_the_redis_score,
tests/test_validity_parity.py::TestExclusionRule::test_the_as_of_bound_is_bit_exact).
The second reason is a close at the record's own start, which the script
allows (its check is close < start): tstzrange(t, t) is the canonical
empty range and keeps neither bound, so the record's start and its recorded
close are both lost (and lower > upper raises). A range can tell "no
invalid_at recorded" from "invalid_at is +inf" (upper_inf('[t,)') is
true, upper_inf('[t,infinity)') false), so that is not a reason; the columns
spell it NULL vs 'Infinity'. So the interval follows M2a's clock decision:
double precision epoch seconds, bit-identical to the score.
The exclusion rule every gate applies -- top_by_decay's ranking,
composite_score's mask, ValidityField.resolve_excluded_keys, the
assembler -- is invalid_at <= as_of OR valid_from > as_of, with an absent end
never excluding, as one WHERE term on the row. filter(validity__as_of=t)
and resolve_valid_keys are the whitelist, valid_from <= t AND invalid_at >
t, both ends present; __current=False is the members of either index that
are not valid now. The #631 POC's 17-row table (tests/test_validity_parity.py)
holds on both backends.
supersede is SUPERSEDE_LUA phase for phase, in one transaction:
pg_advisory_xact_lock(hashtext('popoto:validity:<schema>.<table>.<f>'))-- one lock per model and field, the Redis single thread;- resolve the incumbent: the named one, else the identity's pointer;
- the record-key locks of the successor and the incumbent, in
_pkbyte order, thenSELECT … ORDER BY _pk COLLATE "C" FOR UPDATEon both rows; - validate, raising the typed error built from the script's reply line (and
so with the same text) and writing nothing: a successor that does not
exist, an asserted incumbent that does not exist (a pointer-resolved one
is "no incumbent"), a close before the incumbent's start, an asserted
valid_fromthat disagrees with the stored one; - mutate: close the incumbent if it is open (idempotent), both chain links,
open the successor
NX(an absent end filled, a closed record never reopened), repoint the pointer.
| Reply line | Exception (a ValueError) |
|---|---|
POPOTO_VALIDITY_MEMBER_ABSENT successor <key> |
ValidityMemberAbsentError |
POPOTO_VALIDITY_MEMBER_ABSENT incumbent <key> |
ValidityMemberAbsentError |
POPOTO_VALIDITY_CLOSE_BEFORE_START |
ValidityCloseBeforeStartError |
POPOTO_VALIDITY_VALID_FROM_CONFLICT <stored> <requested> (numbers as Lua's %.14g) |
ValidityValidFromConflictError |
A save opens the interval inside its upsert, the script's mode 'open', and a
declared valid_from that disagrees with the stored start fails the upsert's
guard, so nothing is written (behind the same pre_save_validate check as on
Redis). chain is one WITH RECURSIVE with the Redis walk's stop rules: a
missing link, a record already visited, a link naming a record with no
valid_from.
Lock order. Validity writers follow the backend's one lock order (see
"One lock order for every writer" above): the (model, field) lock, then the
record-key locks sorted by _pk, then the row locks. supersede takes them
in that order; save_and_supersede / save_and_invalidate take all of the
supersede's locks before the save (otherwise the save would hold the
successor's key and row while a concurrent supersede naming that record holds
the field lock and waits for them -- pinned:
test_save_and_supersede_of_an_existing_record_takes_the_supersede_lock_order,
which deadlocks every round with the pre-lock removed); import_state takes
the field lock as a pointer writer; and ObservationProtocol.on_context_used
takes the field lock before locking its batch, with a contradicted record's
successor locked as part of the batch. So two supersedes never interleave --
including the crossing chains that deadlocked the #631 POC (d1 → X
superseded by Y while d2 → Y is superseded by X; TestCrossingChains
forces the overlap ten times). A plain save does not take the field lock: it
meets a supersede on the record's key lock, and its upsert re-reads the row
it waited on. The residual is a caller's own transaction() that locks a
record and then supersedes while another supersede waits for that record:
Postgres detects the cycle and aborts one side. Usually the other, earlier
waiter is the victim and its owned transaction retries, so the caller
completes; when the caller is the victim it gets BackendRetryableError
(pinned: test_a_cross_operation_deadlock_is_a_retryable_error).
The seeded probe. scripts/probe_validity_parity.py runs the same random
sequences of saves (declaring and re-declaring starts), supersedes,
invalidations, direct execute_supersede calls in every mode, save_and_*
and deletes on both backends, comparing every return value and exception
(type and text), the intervals, links, pointers and chains, and every gated
read at the interval ends, ±inf, 1e308 and NaN. Its documented classes are
the M3 rows of the table above; tests/postgres/test_validity_probe.py runs a
25-shape slice in CI.
REDIS_URL=redis://localhost:6379/10 \
POPOTO_POSTGRES_URL=postgresql://localhost:5432/postgres \
python scripts/probe_validity_parity.py --seeds 1 2 3 --shapes 200
Performance (M1, M2a and M3 exit criteria)¶
scripts/bench_backend_seam.py measures the public API on both backends. It
seeds 2,000 records, runs ANALYZE, and then makes three runs of 300
iterations per operation, with the two backends interleaved within each run.
The M1 targets are Model.save() p50 at most 2x Redis and filter +
hydration p50 at most 1x Redis. M2a adds rank_decayed with a base score and
confidence modulation over all 2,000 records (top 10): Postgres p50 at most
1x Redis, DECAY_SCORE_LUA against one SELECT; top_by_decay with
hydration is measured beside it. M3 adds the same ranking with a validity gate
(a tenth of the records superseded, a twentieth not yet started): Postgres
p50 at most 1x Redis. The PRs that introduced each milestone
record the measured numbers and the environment they were taken on.
REDIS_URL=redis://localhost:6379/14 \
POPOTO_POSTGRES_URL=postgresql://localhost:5432/postgres \
python scripts/bench_backend_seam.py
recall() (M2b exit criterion)¶
scripts/bench_recall.py seeds a 20,000-row, 1536-dimension corpus through
Model.save(): five projects (60 / 20 / 10 / 5 / 5 %), documents of 40-80
tokens from a 30,000-term Zipf vocabulary, and clustered synthetic vectors.
It runs VACUUM ANALYZE, then makes three runs of 200
recall(q, scope=p, limit=10) calls at a 5% and a 60% scope, and times each
arm alone. The targets are at most 15 ms p95 at 5% scope and at most 60 ms
p95 at 60% scope. The 5% scope (1,000 vectors) takes the exact path, over
the narrow vector table; before that table, reading 1,000 TOASTed 6 kB
vectors from the record table was most of the time, and the 5% p95 sat
at 13-25 ms. The 60% scope (12,000 vectors) takes HNSW. Each run prints the
load average.
Measured for #774 (Apple M1 Max, PostgreSQL 18.6 with shared_buffers
128 MB, pgvector 0.8.7, one 20k corpus, six invocations of three runs each;
the machine was shared, load average 4.7-7.5 throughout):
| Scope | Path | p50 (typical) | p95 per run | Runs within the bar |
|---|---|---|---|---|
| 5% | exact, narrow table | 5.6-5.9 ms (11.5-13.4 ms in the slow windows) | 6.5-10.8 ms in 13 runs; 16.4-20.2 ms in 5 | 13 of 18 (bar 15 ms) |
| 60% | HNSW | 9.9-17.8 ms | 16.8-46.3 ms | 18 of 18 (bar 60 ms) |
The five slow 5% runs fell in windows where the whole machine slowed: the same index-only count statement went from 0.4 ms to 1.0 ms p50 and every arm doubled together. Run alone in one window, the exact arm on the record table (TOAST) took p50 7.9 ms, p95 9.1 ms, and on the narrow table p50 2.9 ms, p95 3.6 ms.