mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
681a79e6b4
* 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).
124 lines
4.5 KiB
Python
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()
|