Files
vectorize-io__hindsight/hindsight-api-slim/tests/test_migration_entity_maintenance_queue.py
T
Nicolò Boschi 681a79e6b4 fix(graph-maintenance): drive the entity prune off a queue, not a bank-wide sweep (#3222) (#3409)
* fix(graph-maintenance): drive the entity prune off a queue, not a bank-wide sweep (#3222)

The graph_maintenance job's Pass 2/3 were two bank-wide statements re-evaluated
on every invocation, whether or not anything had changed: the orphan-entity
prune probed once per entity in the bank, and the stale-cooccurrence prune
evaluated an INTERSECT per cooccurrence row in the bank. Their cost tracked the
size of the bank rather than the size of the delete, so past a few million rows
neither could finish inside asyncpg's 60s command timeout. The job then failed
on every run with a bare TimeoutError, forever, on exactly the banks that most
needed it — and, because db_utils treats a timeout as transient, re-ran the
doomed statement nine times per attempt, holding a worker slot for ~10 minutes
each time.

Both prunes are now driven by `entity_maintenance_queue`, filled inside the
deleting transaction the way `graph_maintenance_queue` already is for the relink
pass. A run claims a bounded batch of candidate entities, prunes what is
genuinely dead, and commits — so the cost is O(delta), and the work already done
survives whatever stops the run.

Measured on a dense fixture (100k entities, 1.5M unit_entities, 2.86M
cooccurrences, hub entities holding 150-400 postings):

  bank-wide orphan prune          9.6s      → batch of 50:   15ms
  bank-wide cooccurrence prune    >11 min   → batch of 50:   2.0s
                                  (cancelled; ~1.7ms per pair over 2.86M pairs)

Also:

* A wall-clock budget for the whole job. Both passes commit per batch, so
  exhausting it is not a failure — the run reports `queues_drained: false`,
  logs it, and chains a follow-up (under a real queue; a synchronous backend
  would recurse instead of schedule). Large backlogs converge over runs.
* The scoping predicate is a UNION of the two endpoint columns, not
  `entity_id_1 = ANY(...) OR entity_id_2 = ANY(...)` — that OR is the #3387
  shape and cannot be driven from either index.
* Every site that removes units or replaces entity postings now enqueues
  candidates: document delete, single and bulk memory delete, curation
  edit/invalidate, document re-ingest, and the delta-retain chunk cascade.
* The migration seeds the queue with every existing entity, so garbage a bank
  accumulated while its sweep was failing is still reclaimed — incrementally,
  a bounded batch per run, instead of in one statement that cannot finish.

* fix(graph-maintenance): compose the queue-scoped prune with the set-based staleness check

Rebase reconciliation with #3408, which landed the same statement while this
was in review.

staleness against a set of live pairs built once, instead of a correlated
INTERSECT re-scanned per row — removing the (rows judged) x (hub degree)
product. That is the better predicate, and it composes with the queue scoping
rather than competing with it: `live` is now seeded from the *claimed
candidates'* units instead of the whole bank's. Correctness holds because every
pair being judged has a candidate as an endpoint, so any unit still grounding
one of those pairs references a candidate and is in the seeded set.

Measured on the dense fixture (100k entities, 1.5M unit_entities, 2.86M
cooccurrences):

  bank-wide, set-based (#3408 as merged)   did not finish in 10 min
  batch of 50, per-pair INTERSECT (mine)   2.0 s
  batch of 50, composed                    16-65 ms

The batch-size rationale is updated to the new numbers; 50 still holds, now
with three orders of magnitude of margin instead of one. #3367's hub/bank-
scoping regression test is kept, adapted to seed candidates.

Also re-chains the migration onto d9c1a7b4e2f6, which took the same parent on
main and would otherwise leave two alembic heads.

* fix(graph-maintenance): restore the review fixes without the birth-time enqueue

Drops the "queue every entity at creation" change and keeps the rest of the
review round (dataclass pass results, the Oracle IN-list chunking on the by-unit
enqueue, the per-site enqueue tests, the migration-seed test, the budget's
follow-up-chain test, the stale-comment sweep).

The birth-time enqueue existed to reclaim an entity created in retain's Phase 1
whose Phase-2 link never landed. It is not worth what it costs: such a row is a
single entry in the registry with no postings and no cooccurrences, and #2662
exists because the retry is *supposed* to adopt it — Phase 2 reasserts resolved
parents under FOR KEY SHARE precisely so a pruner cannot delete one out from
under it. Pointing the pruner at every freshly created entity leans on that race
for a leak that is one row wide. #3408 landing the set-based predicate is what
made the trade obviously bad: the expensive half of this job was never those
rows.

Entities created but never linked are therefore no longer proactively reclaimed.
The migration's one-time seed still clears the population a bank has already
accumulated.

* fix(graph-maintenance): don't backfill the entity queue on upgrade

The migration seeded one queue row per existing entity so a bank could reclaim
what it stranded while its bank-wide sweep was failing. That is the wrong trade:
the INSERT runs inside a migration at API startup, so a large deployment pays a
slow upgrade writing a row per entity, and then a prune check for every one of
them — a self-inflicted backlog to collect rows that cost the bank nothing.

The queue now starts empty and fills from real deletes. Historical strays stay
until something touches them; they are single registry rows with no postings and
no cooccurrences.

The migration test pins the two properties that are easy to lose later: the
upgrade enqueues nothing, and the composite key collapses overlapping deletes
into one row (which is also what the #3034 locking upsert conflicts on).
2026-08-12 16:14:19 +02:00

124 lines
4.5 KiB
Python

"""Tests for migration c4f7a91b2d38 (entity_maintenance_queue).
Two properties the prune depends on and which are easy to lose in a later edit:
the composite primary key (it is what collapses overlapping deletes into one
row, and what the #3034 locking upsert conflicts on), and that the upgrade does
NOT backfill existing entities — a backfill would write a row per entity inside
a migration that runs at API startup and charge a prune check for each.
Uses a dedicated pg0 instance so the test controls which migrations have run.
"""
import asyncio
import uuid
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic")
# The revision immediately before the one under test.
_PRE_REVISION = "d9c1a7b4e2f6"
_REVISION = "c4f7a91b2d38"
def _alembic_cfg(db_url: str) -> Config:
cfg = Config()
cfg.set_main_option("script_location", _SCRIPT_LOCATION)
cfg.set_main_option("sqlalchemy.url", db_url)
cfg.set_main_option("prepend_sys_path", ".")
cfg.set_main_option("path_separator", "os")
return cfg
def _upgrade(db_url: str, revision: str) -> None:
command.upgrade(_alembic_cfg(db_url), revision)
def _reset_public_schema(db_url: str) -> None:
engine = create_engine(db_url, isolation_level="AUTOCOMMIT")
try:
with engine.connect() as conn:
conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
finally:
engine.dispose()
@pytest.fixture(scope="module")
def pre_queue_db_url() -> str:
"""A dedicated database migrated to the revision just before the queue."""
from hindsight_api.pg0 import EmbeddedPostgres
pg0 = EmbeddedPostgres(name="hindsight-entity-queue-test", port=5563)
loop = asyncio.new_event_loop()
try:
url = loop.run_until_complete(pg0.ensure_running())
finally:
loop.close()
_reset_public_schema(url)
_upgrade(url, _PRE_REVISION)
return url
def test_upgrade_creates_an_empty_deduping_queue(pre_queue_db_url: str) -> None:
"""The upgrade adds the queue but enqueues nothing, and the key dedupes.
A backfill here is tempting — it would reclaim what a bank stranded while its
sweep was failing — but it costs one row per entity written during startup
migration plus a prune check each, to collect rows that cost the bank
nothing. New deletes fill the queue; historical strays stay.
"""
db_url = pre_queue_db_url
engine = create_engine(db_url)
bank_id = f"bank_{uuid.uuid4().hex[:8]}"
try:
with engine.begin() as conn:
conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": bank_id})
for name in ("referenced", "stranded"):
conn.execute(
text("INSERT INTO entities (bank_id, canonical_name) VALUES (:b, :n)"),
{"b": bank_id, "n": name},
)
_upgrade(db_url, _REVISION)
with engine.connect() as conn:
queued = conn.execute(
text("SELECT count(*) FROM entity_maintenance_queue WHERE bank_id = :b"), {"b": bank_id}
).scalar_one()
assert queued == 0, "the upgrade must not backfill existing entities"
entity_id = conn.execute(
text("SELECT id FROM entities WHERE bank_id = :b LIMIT 1"), {"b": bank_id}
).scalar_one()
# The composite key is what makes overlapping deletes collapse to one row.
with engine.begin() as conn:
for _ in range(2):
conn.execute(
text(
"INSERT INTO entity_maintenance_queue (bank_id, entity_id) VALUES (:b, :e) "
"ON CONFLICT (bank_id, entity_id) DO UPDATE "
"SET enqueued_at = entity_maintenance_queue.enqueued_at"
),
{"b": bank_id, "e": entity_id},
)
with engine.connect() as conn:
assert (
conn.execute(
text("SELECT count(*) FROM entity_maintenance_queue WHERE bank_id = :b"), {"b": bank_id}
).scalar_one()
== 1
)
finally:
with engine.begin() as conn:
conn.execute(text("DELETE FROM entity_maintenance_queue WHERE bank_id = :b"), {"b": bank_id})
conn.execute(text("DELETE FROM banks WHERE bank_id = :b"), {"b": bank_id})
engine.dispose()