Skip to content

Commit 1acdf7b

Browse files
committed
fix(pgvector): bound the get_recent scan when the filter is not pushdown-safe
PgVectorCollection.get_recent pushed the ORDER BY into SQL but passed limit=None to _scroll whenever _requires_local_filter(where) was true, so a filter like {"$or": [{"wing": "w1"}, {"wing": "w2"}]} dragged the whole table across the wire to keep `limit` rows. Layer 1 never hits this (it passes {"wing": ...} or None, both pushdown-safe), but get_recent is public API and this is the shape a caller reaches for first. The predicate still cannot ride along, but the ordering can, so instead of one LIMIT-less scan the local-filter branch now walks the table newest-first a SQL page at a time and stops at the first page that completes the answer. On the common shape (a filter most rows match) that is a single page. Details: - Page stability. ORDER BY metadata->>field DESC NULLS LAST, id is a total order because id is the primary key, so OFFSET paging is well defined, matching the guarantee the existing ORDER BY id paging in scroll_rows relies on. Rows are deduped by id so a concurrent insert that shifts a row across a page boundary cannot return it twice. A concurrent delete can still skip one row, which is inherent to OFFSET paging and unchanged from the pre-existing paged get. - Cap. The walk stops after 50,000 rows, so a filter matching almost nothing in a huge table returns fewer than `limit` rows rather than reading the table. Because the walk is newest-first, what it returns is still the newest matching rows within the newest 50,000 records. The pushdown branch keeps its unchanged SQL and stays exact. - Projection. The post-filter reads only metadata, and this branch can scan far more rows than it returns, so the document column is projected out unless the caller asked for it. Because the cap makes the non-pushdown path approximate where it used to be exact, supports_recency_order is now spelled out rather than left to read as a blanket promise: it covers the filters the backend can evaluate in storage, which is every filter Layer 1 uses, and a backend that bounds its non-pushdown walk must document the bound. Also fixes two things the same diff introduced: - BaseCollection.get_recent returned padding values for projections the caller excluded, so include=["metadatas"] answered with a list of empty strings for documents where pgvector answers with []. Worse, include=["documents"] meant metadatas never came back, every sort key collapsed to the same value and the sort silently did nothing. metadatas are now always fetched because the sort reads order_field out of them, and only returned when requested. - The docstrings claimed the inexact branch re-sorted locally after filtering, which it never did, and asserted filed_at is always UTC. It is not: diary_ingest writes datetime.now(timezone.utc).isoformat() and every other writer uses datetime.now().isoformat(), so on a host off UTC the two sort against each other skewed by the local offset. That predates this change (Layer 1 has compared filed_at as text since #1630) and standardising the writers needs its own migration, so the docs now name the limitation instead of denying it. The list of places where the SQL order and recency_sort_key disagree also now includes database collation, which the Python test double cannot emulate. Tests: the reviewer's exact scenario (800 rows, an $or filter, limit=5) asserting every scroll carries a SQL LIMIT and the rows requested stay well under the table; a selective filter that has to page three times, checking OFFSET advances and the order stays newest-first; the cap stopping a filter that matches nothing; a row shifted across a page boundary by a concurrent insert, which returns a duplicate without the id dedupe; the document projection; and the base-class include projection.
1 parent fa60c79 commit 1acdf7b

5 files changed

Lines changed: 390 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1313
- **`sweep` books a failed `stat` as a failure again.** The non-regular-file gate added in 3.7.1 reads `stat.S_ISREG(f.stat().st_mode)` inside a `try`, and its `except OSError` printed `SKIP` and moved on. A dangling symlink, a symlink loop and a file unlinked between `rglob` and the gate all raise there, and before the gate existed every one of them reached `sweep()` and was booked in `failures` — so `sweep` went from reporting a transcript it could not read to reporting success. A failed probe is now an error, not a benign file type: it is logged, printed as `WARNING`, and appended to `failures`, while a probe that succeeds and says "not regular" still skips silently. (#2221)
1414
- **`mempalace init` no longer tracebacks on a directory it cannot enter.** `_parse_gradle`'s `is_file()` gate sat in front of the `try` that the parser's own `except OSError` provides, so a manifest under a directory with `r` but no `x` raised `PermissionError` out of a call that used to answer "no manifest name". The gate moved inside that `try`, and `_collect_manifest_names` stats through `os.path.isfile`, which reports rather than raises. (#2221)
1515
- **`split` no longer blocks on a FIFO at its own output name, nor writes through a broken link.** The type gate in `main` covers the files the glob listed; `split_file` builds its output names itself, so a pre-existing named pipe at one of them wedged `write_text` in the kernel waiting for a reader. The check asks about the link itself rather than its target, because a dangling symlink at an output name reads as "nothing there" and `write_text` would create the target — landing a chunk outside the output directory. Output names that are anything but a regular file are now skipped with a `SKIP` line. (#2221)
16-
- **Layer 1 wake-up fetches the most recent drawers instead of an arbitrary scan window.** `BaseCollection.get_recent()` returns the newest N by `filed_at`; pgvector overrides it with an `ORDER BY ... DESC LIMIT n` pushdown (capability token `supports_recency_order`), and backends without pushdown keep the previous scan-and-sort behavior. On palaces larger than the 2,000-drawer scan cap, wake-up no longer leads with the oldest backfill. (#1630)
16+
- **Layer 1 wake-up fetches the most recent drawers instead of an arbitrary scan window.** `BaseCollection.get_recent()` returns the newest N by `filed_at`; pgvector overrides it with an `ORDER BY ... DESC LIMIT n` pushdown (capability token `supports_recency_order`), and backends without pushdown keep the previous scan-and-sort behavior. On palaces larger than the 2,000-drawer scan cap, wake-up no longer leads with the oldest backfill. Ordering is on the stored `filed_at` text, the same comparison Layer 1 has used since #1630. (#1630)
1717

1818
---
1919

mempalace/backends/base.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,16 @@ def recency_sort_key(meta: Optional[dict], order_field: str = "filed_at") -> tup
362362
last instead of raising on a str/None comparison. Backends that implement
363363
:meth:`BaseCollection.get_recent` with a local sort MUST use this key so
364364
every backend orders identically.
365+
366+
This compares the timestamps as *text*, which is chronological only while
367+
every value shares one offset representation. It is not a new assumption:
368+
Layer 1 has sorted ``filed_at`` as text since #1630 and this key just
369+
names the behaviour. It is also not currently true of ``filed_at`` --
370+
``diary_ingest`` writes ``datetime.now(timezone.utc).isoformat()``
371+
(``...+00:00``) while every other writer uses ``datetime.now().isoformat()``
372+
(naive local), so on a host that is not on UTC the two sort against each
373+
other skewed by the local offset. Standardising the writers is a separate
374+
change; it needs a migration for palaces that already hold both forms.
365375
"""
366376
value = (meta or {}).get(order_field)
367377
if not isinstance(value, str) or not value:
@@ -540,24 +550,48 @@ def get_recent(
540550
541551
``order_field`` names a metadata key holding an ISO-8601 timestamp
542552
string (``filed_at`` for drawers). Ordering is descending on that
543-
string, which is chronological for ISO-8601. Records whose value is
544-
missing, empty, or not a string sort last.
553+
string; see :func:`recency_sort_key` for what text ordering promises.
554+
Records whose value is missing, empty, or not a string sort last.
545555
546556
The default implementation pages through :meth:`get` in storage order
547557
up to ``limit`` records and sorts that window locally. That is exact
548-
for a collection no larger than ``limit`` and *approximate* above it:
549-
the window is whatever the backend hands back first, so the genuinely
550-
newest records can fall outside it (issue #1630's known limitation for
551-
Layer 1 wake-up). Backends able to push the ordering into storage MUST
552-
override this and advertise the ``supports_recency_order`` capability
553-
token, which promises exactness at any collection size.
558+
when the collection holds no more than ``limit`` records matching
559+
``where``, and *approximate* above that: the window is whatever the
560+
backend hands back first, so the genuinely newest records can fall
561+
outside it (issue #1630's known limitation for Layer 1 wake-up).
562+
Backends able to push the ordering into storage MUST override this and
563+
advertise the ``supports_recency_order`` capability token. What that
564+
token promises, exactly:
565+
566+
* The returned records really are the top ``limit`` under the ordering
567+
above, at any collection size, **whenever the backend can also
568+
evaluate ``where`` in storage** (including ``where=None``).
569+
* It says nothing about whether that text ordering matches wall-clock
570+
order. That is a property of what the writers store, not of the
571+
backend.
572+
* A filter the backend cannot push into storage has to be evaluated
573+
record by record, so a backend MAY bound how far it walks and return
574+
fewer than ``limit`` records rather than read the whole collection.
575+
A backend that bounds it MUST document the bound on its override.
576+
pgvector does; see :meth:`PgVectorCollection._scroll_recent_local`.
554577
555578
Callers that need the guarantee should check the token rather than
556-
assume it; the default is always available so no backend breaks.
579+
assume it, and should read it as covering the filters the backend can
580+
push down. The default is always available so no backend breaks.
581+
582+
``include`` follows the same contract as :meth:`get`: projections the
583+
caller did not ask for come back empty. ``metadatas`` is fetched
584+
regardless because the sort reads ``order_field`` from it, but it is
585+
only *returned* when requested.
557586
"""
558587
if limit <= 0:
559588
return GetResult.empty()
560589
include = ["documents", "metadatas"] if include is None else list(include)
590+
want_documents = "documents" in include
591+
want_metadatas = "metadatas" in include
592+
# The local sort needs order_field, so metadatas always come back from
593+
# the backend even when the caller projected them out of the result.
594+
fetch_include = include if want_metadatas else [*include, "metadatas"]
561595

562596
ids: list[str] = []
563597
documents: list[str] = []
@@ -566,7 +600,7 @@ def get_recent(
566600
fetched = 0
567601
page_size = min(500, limit)
568602
while fetched < limit:
569-
kwargs: dict = {"include": include, "limit": page_size, "offset": offset}
603+
kwargs: dict = {"include": fetch_include, "limit": page_size, "offset": offset}
570604
if where:
571605
kwargs["where"] = where
572606
batch = self.get(**kwargs)
@@ -591,8 +625,8 @@ def get_recent(
591625
order = _recency_order(metadatas, order_field)[:limit]
592626
return GetResult(
593627
ids=[ids[i] for i in order],
594-
documents=[documents[i] for i in order],
595-
metadatas=[metadatas[i] for i in order],
628+
documents=[documents[i] for i in order] if want_documents else [],
629+
metadatas=[metadatas[i] for i in order] if want_metadatas else [],
596630
embeddings=None,
597631
)
598632

mempalace/backends/pgvector.py

Lines changed: 137 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@
7272
{"$eq", "$ne", "$in", "$nin", "$and", "$or", "$contains", "$gt", "$gte", "$lt", "$lte"}
7373
)
7474
_PUSHDOWN_OPERATORS = frozenset({"$eq", "$ne", "$in", "$nin", "$and"})
75+
# Bounds for the local-post-filter branch of ``get_recent``. The pushdown
76+
# branch needs none — SQL does ORDER BY ... LIMIT n and returns exactly n
77+
# rows. The post-filter branch cannot push the predicate, so it walks the
78+
# table newest-first in pages and stops as soon as ``limit`` rows match.
79+
# The page size trades round trips against rows on the wire; the row cap
80+
# bounds the pathological case (a filter that matches nothing in a large
81+
# table) so one call can never walk unboundedly.
82+
_RECENT_SCAN_PAGE_MIN = 500
83+
_RECENT_SCAN_PAGE_MAX = 5000
84+
_RECENT_SCAN_ROW_CAP = 50_000
7585

7686

7787
def _utcnow() -> str:
@@ -1211,35 +1221,145 @@ def get(
12111221
embeddings=[row["embedding"] or [] for row in rows] if spec.embeddings else None,
12121222
)
12131223

1224+
def _scroll_recent_local(self, *, where, limit, order_field, with_embedding, with_document):
1225+
"""Newest-first paged scan with the filter applied in Python.
1226+
1227+
Used when ``where`` is not exactly expressible as ``metadata @> ...``
1228+
($or, comparisons, ...). The predicate cannot ride along, but the
1229+
*ordering* still can, so instead of dragging the whole table across
1230+
the wire to keep ``limit`` rows we walk it newest-first one SQL page
1231+
at a time and stop at the first page that completes the answer. On
1232+
the common shape (a filter most rows match) that is a single page.
1233+
1234+
Page stability: ``ORDER BY metadata->>field DESC NULLS LAST, id`` is a
1235+
total order because ``id`` is the primary key, so OFFSET paging is
1236+
well defined — the same guarantee the existing ``ORDER BY id`` paging
1237+
in :meth:`_PgVectorClient.scroll_rows` relies on. Concurrent writes
1238+
can still shift rows across a page boundary: an insert of a newer row
1239+
pushes one row down and would hand it back twice, which the ``seen``
1240+
set drops, and a delete pulls one row up and can skip it. That skip
1241+
window is inherent to OFFSET paging and is unchanged from the
1242+
pre-existing paged ``get``; a keyset cursor would close it and is a
1243+
separate change.
1244+
1245+
Bounded, not exhaustive: the walk stops after ``_RECENT_SCAN_ROW_CAP``
1246+
rows, so a filter that matches almost nothing in a huge table returns
1247+
fewer than ``limit`` rows rather than reading the table. Because the
1248+
walk is newest-first, what it does return is still the newest matching
1249+
rows within the newest ``_RECENT_SCAN_ROW_CAP`` records — a much
1250+
tighter approximation than the base class's storage-order window, but
1251+
an approximation, unlike the pushdown branch which is exact at any
1252+
table size.
1253+
"""
1254+
# ``limit`` is ``int`` in the contract; the ``None`` the caller's
1255+
# guard tolerates means "no bound", which on this branch is the cap.
1256+
target = _RECENT_SCAN_ROW_CAP if limit is None else int(limit)
1257+
page_size = max(_RECENT_SCAN_PAGE_MIN, min(target, _RECENT_SCAN_PAGE_MAX))
1258+
matched: list[dict] = []
1259+
seen: set[str] = set()
1260+
offset = 0
1261+
scanned = 0
1262+
while len(matched) < target and scanned < _RECENT_SCAN_ROW_CAP:
1263+
want = min(page_size, _RECENT_SCAN_ROW_CAP - scanned)
1264+
page = self._scroll(
1265+
where=None,
1266+
with_embedding=with_embedding,
1267+
with_document=with_document,
1268+
limit=want,
1269+
offset=offset or None,
1270+
order_field=order_field,
1271+
)
1272+
if not page:
1273+
break
1274+
scanned += len(page)
1275+
offset += len(page)
1276+
for row in page:
1277+
if row["id"] in seen:
1278+
continue
1279+
seen.add(row["id"])
1280+
if _matches_where(row["metadata"], where):
1281+
matched.append(row)
1282+
if len(matched) >= target:
1283+
break
1284+
if len(page) < want:
1285+
break # short page — end of table
1286+
return matched[:target]
1287+
12141288
def get_recent(self, *, limit, where=None, order_field="filed_at", include=None):
12151289
"""Newest-first fetch with the ordering pushed into SQL.
12161290
12171291
The base implementation scans a window in storage order and sorts it
12181292
locally, so on a collection larger than ``limit`` the genuinely newest
12191293
records can be missing from the window entirely (#1630). Postgres can
12201294
do the whole thing: ``ORDER BY metadata->>'filed_at' DESC ... LIMIT n``
1221-
is exact at any table size, which is why this backend advertises
1222-
``supports_recency_order``.
1223-
1224-
Filters that ``metadata @> ...`` cannot express exactly fall back to
1225-
the local post-filter path (same correctness contract as ``get``), and
1226-
there the SQL order is still applied before the filter, so the result
1227-
is the newest ``limit`` rows *that match* only when the pushdown was
1228-
exact. The inexact case re-sorts locally after filtering.
1295+
picks the true top ``limit`` under that ordering at any table size,
1296+
which is why this backend advertises ``supports_recency_order``.
1297+
1298+
Filters that ``metadata @> ...`` cannot express exactly ($or,
1299+
comparisons, ...) keep the local post-filter contract that ``get``
1300+
uses, but they do *not* fetch the table to do it: the ordering is
1301+
still pushed into SQL and :meth:`_scroll_recent_local` walks the
1302+
result newest-first a page at a time, stopping as soon as ``limit``
1303+
rows match.
1304+
1305+
**This is the one case where ``supports_recency_order`` is weaker than
1306+
it sounds.** That walk is capped, so a non-pushdown filter matching
1307+
very little in a very large table returns fewer than ``limit`` records
1308+
rather than reading the table. The token covers the filters this
1309+
backend can push down, which is every filter Layer 1 uses (``None`` or
1310+
``{"wing": ...}``) and everything built from ``$eq``/``$ne``/``$in``/
1311+
``$nin``/``$and``. See :meth:`_scroll_recent_local` for the bound and
1312+
for what the capped answer still guarantees.
1313+
1314+
``limit=None`` is outside the contract (the signature is ``int``). The
1315+
pushdown branch treats it as unbounded; the local branch cannot, and
1316+
treats it as the scan cap.
1317+
1318+
Ordering is on the JSON *text* of ``order_field``, matching the text
1319+
ordering :func:`recency_sort_key` already applies in Layer 1. See that
1320+
function for what text ordering does and does not promise; this method
1321+
inherits those limits rather than introducing them. At least three
1322+
places where the SQL order and ``recency_sort_key`` differ, none of
1323+
them reachable through anything that writes ``filed_at``:
1324+
1325+
* a JSON value that is not a string sorts by its text form here but
1326+
sorts last there;
1327+
* an empty string sorts above SQL NULL here but ties with a missing
1328+
key there;
1329+
* both ``metadata->>%s`` and the ``id`` tiebreak sort under the
1330+
database collation, while ``recency_sort_key`` sorts by Python
1331+
codepoint. Under a collation such as ``en_US.UTF-8`` punctuation is
1332+
weighted differently, so the two can disagree on timestamps that
1333+
differ only in punctuation (``+00:00`` against ``Z``). The test
1334+
double emulates the ordering in Python and so cannot catch this.
12291335
"""
12301336
if limit is not None and limit <= 0:
12311337
return GetResult.empty()
12321338
_validate_where(where)
12331339
spec = _IncludeSpec.resolve(include, default_distances=False)
1234-
local_filter = _requires_local_filter(where)
1235-
rows = self._scroll(
1236-
where=None if local_filter else where,
1237-
with_embedding=spec.embeddings,
1238-
limit=None if local_filter else limit,
1239-
order_field=order_field,
1240-
)
1241-
if local_filter:
1242-
rows = [row for row in rows if _matches_where(row["metadata"], where)][:limit]
1340+
if _requires_local_filter(where):
1341+
rows = self._scroll_recent_local(
1342+
where=where,
1343+
limit=limit,
1344+
order_field=order_field,
1345+
with_embedding=spec.embeddings,
1346+
# The post-filter reads only ``metadata``, and this branch can
1347+
# scan far more rows than it returns, so project the document
1348+
# text out unless the caller actually asked for it (#1840's
1349+
# wire-byte win). The pushdown branch below is left alone: it
1350+
# fetches ``limit`` rows, so the projection is worth little
1351+
# there and not worth changing that path's SQL for. (It would
1352+
# still pay off for ``limit=None``, which is outside the
1353+
# contract; fold it in when that path grows a real caller.)
1354+
with_document=spec.documents,
1355+
)
1356+
else:
1357+
rows = self._scroll(
1358+
where=where,
1359+
with_embedding=spec.embeddings,
1360+
limit=limit,
1361+
order_field=order_field,
1362+
)
12431363
return GetResult(
12441364
ids=[row["id"] for row in rows],
12451365
documents=[row["document"] for row in rows] if spec.documents else [],

tests/test_backends.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,58 @@ def test_base_get_recent_default_passes_where_and_zero_limit():
556556
assert _recent(col, limit=0).ids == []
557557

558558

559+
def test_base_get_recent_default_honours_include_projection():
560+
"""Unrequested projections come back empty, as they do from ``get``.
561+
562+
``metadatas`` is fetched from the backend regardless because the local
563+
sort reads ``order_field`` out of it, but it is only returned when the
564+
caller asked for it. Without that, a backend without recency pushdown
565+
would answer ``include=["metadatas"]`` with a list of padding strings
566+
while pgvector answers with ``[]``.
567+
"""
568+
569+
class _ProjectingCollection:
570+
"""Honours ``include`` the way the real backends do."""
571+
572+
def __init__(self):
573+
self.calls = []
574+
575+
def get(self, *, include=None, limit=None, offset=None, **kwargs):
576+
self.calls.append(list(include or []))
577+
if offset:
578+
return GetResult(ids=[], documents=[], metadatas=[])
579+
keys = set(include or [])
580+
return GetResult(
581+
ids=["a", "b"],
582+
documents=["older", "newer"] if "documents" in keys else [],
583+
metadatas=(
584+
[
585+
{"filed_at": "2024-01-01T00:00:00Z"},
586+
{"filed_at": "2026-01-01T00:00:00Z"},
587+
]
588+
if "metadatas" in keys
589+
else []
590+
),
591+
)
592+
593+
col = _ProjectingCollection()
594+
page = _recent(col, limit=5, include=["metadatas"])
595+
assert page.ids == ["b", "a"]
596+
assert page.documents == []
597+
assert page.metadatas == [
598+
{"filed_at": "2026-01-01T00:00:00Z"},
599+
{"filed_at": "2024-01-01T00:00:00Z"},
600+
]
601+
602+
col = _ProjectingCollection()
603+
page = _recent(col, limit=5, include=["documents"])
604+
# metadatas are fetched anyway so the sort has order_field to read...
605+
assert "metadatas" in col.calls[0]
606+
# ...which is why the newest document leads, but they are not returned.
607+
assert page.documents == ["newer", "older"]
608+
assert page.metadatas == []
609+
610+
559611
def test_base_get_recent_default_accepts_dict_shaped_get():
560612
"""Collections still returning Chroma-shaped dicts page correctly."""
561613

0 commit comments

Comments
 (0)