Skip to content

Commit 15fa186

Browse files
committed
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.
1 parent 7915b91 commit 15fa186

10 files changed

Lines changed: 281 additions & 59 deletions

File tree

hindsight-api-slim/hindsight_api/engine/db/ops.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ def document_serialization_sql(table: str, alias: str) -> str:
8181
def graph_maintenance_bank_serialization_sql(table: str, alias: str) -> str:
8282
"""SQL predicate serialising ``graph_maintenance`` claims per bank (#3230).
8383
84-
Every graph_maintenance run is the same bank-wide sweep — the payload carries
85-
only ``bank_id``, and ``run_graph_maintenance_job`` drains the whole queue —
86-
so a second concurrent run for one bank adds no work. It is worse than
84+
Every graph_maintenance run for a bank is interchangeable — the payload
85+
carries only ``bank_id``, and ``run_graph_maintenance_job`` drains that bank's
86+
queues — so a second concurrent run for one bank adds no work. It is worse than
8787
useless: ``claim_graph_maintenance_batch`` locks queue rows ``FOR UPDATE``
8888
*without* ``SKIP LOCKED`` (it is written assuming a single runner per bank),
8989
so the runs convoy on each other's row locks while each holds a worker slot.

hindsight-api-slim/hindsight_api/engine/graph_maintenance.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@
3636
3737
That follow-up run is *deferred*, not parallel: ``claim_tasks`` will not claim a
3838
graph_maintenance row for a bank that already has one in flight (#3230). Two
39-
concurrent runs would do no extra work anyway — each is this same bank-wide
40-
sweep — while convoying on each other's row locks and holding a worker slot
41-
each.
39+
concurrent runs would do no extra work anyway — each drains the same two
40+
bank-scoped queues — while convoying on each other's row locks and holding a
41+
worker slot each.
4242
"""
4343

4444
from __future__ import annotations
@@ -229,8 +229,8 @@ async def run_graph_maintenance_job(
229229
relink = await store.relink_pass(
230230
backend=backend, fq_table=fq_table, bank_id=bank_id, config=config, deadline=deadline
231231
)
232-
result.relink_units_processed = relink.get("relink_units_processed", 0)
233-
result.relink_links_added = relink.get("relink_links_added", 0)
232+
result.relink_units_processed = relink.units_processed
233+
result.relink_links_added = relink.links_added
234234

235235
# --- Pass 2: entity prune ---
236236
# Same shape as Pass 1 and owned by the store for the same reason: a
@@ -241,10 +241,10 @@ async def run_graph_maintenance_job(
241241
# and this is a no-op. Runs after the relink pass so the remaining budget
242242
# is whatever Pass 1 left.
243243
prune = await store.entity_prune_pass(backend=backend, fq_table=fq_table, bank_id=bank_id, deadline=deadline)
244-
result.entities_examined = prune.get("entities_examined", 0)
245-
result.orphan_entities_pruned = prune.get("orphan_entities_pruned", 0)
246-
result.stale_cooccurrences_pruned = prune.get("stale_cooccurrences_pruned", 0)
247-
result.queues_drained = relink.get("relink_queue_exhausted", True) and prune.get("entity_queue_exhausted", True)
244+
result.entities_examined = prune.entities_examined
245+
result.orphan_entities_pruned = prune.orphan_entities_pruned
246+
result.stale_cooccurrences_pruned = prune.stale_cooccurrences_pruned
247+
result.queues_drained = relink.queue_exhausted and prune.queue_exhausted
248248

249249
elapsed = time.time() - job_start
250250
if not result.queues_drained:

hindsight-api-slim/hindsight_api/engine/memories/base.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,36 @@ def build_fact_records(
359359
return records
360360

361361

362+
@dataclass
363+
class RelinkPassResult:
364+
"""What one relink drain got through.
365+
366+
``queue_exhausted`` is False when the pass stopped on its deadline (or the
367+
runaway-iteration cap) with rows still queued — not a failure, since every
368+
batch commits before the next is claimed, but the caller needs to know the
369+
queue is not empty so it can arrange for the rest to be picked up.
370+
"""
371+
372+
units_processed: int = 0
373+
links_added: int = 0
374+
queue_exhausted: bool = True
375+
376+
377+
@dataclass
378+
class EntityPrunePassResult:
379+
"""What one entity-prune drain got through.
380+
381+
``entities_examined`` counts candidates claimed, not rows deleted: most
382+
candidates turn out to be alive and are kept, which is the pass working as
383+
intended rather than wasted effort.
384+
"""
385+
386+
entities_examined: int = 0
387+
orphan_entities_pruned: int = 0
388+
stale_cooccurrences_pruned: int = 0
389+
queue_exhausted: bool = True
390+
391+
362392
class MemoriesExtension(Extension, ABC):
363393
"""Storage + retrieval for memory units and their links, behind one interface.
364394
@@ -1157,9 +1187,11 @@ async def enqueue_relink_victims(
11571187
"""
11581188
return 0
11591189

1160-
async def relink_pass(self, *, backend, fq_table, bank_id: str, config, deadline: float | None = None) -> dict:
1161-
"""Top up links for queued victims. ``{}`` when there is nothing to relink."""
1162-
return {}
1190+
async def relink_pass(
1191+
self, *, backend, fq_table, bank_id: str, config, deadline: float | None = None
1192+
) -> "RelinkPassResult":
1193+
"""Top up links for queued victims. All-zero when there is nothing to relink."""
1194+
return RelinkPassResult()
11631195

11641196
async def enqueue_entity_prune_candidates(self, *, conn, fq_table, bank_id: str, affected_unit_ids: list) -> int:
11651197
"""Queue the entities ``affected_unit_ids`` reference as prune candidates.
@@ -1169,12 +1201,14 @@ async def enqueue_entity_prune_candidates(self, *, conn, fq_table, bank_id: str,
11691201
"""
11701202
return 0
11711203

1172-
async def entity_prune_pass(self, *, backend, fq_table, bank_id: str, deadline: float | None = None) -> dict:
1204+
async def entity_prune_pass(
1205+
self, *, backend, fq_table, bank_id: str, deadline: float | None = None
1206+
) -> "EntityPrunePassResult":
11731207
"""Prune queued candidate entities and the co-occurrences they stranded.
11741208
1175-
``{}`` when the store keeps no entity postings and so queues nothing.
1209+
All-zero when the store keeps no entity postings and so queues nothing.
11761210
"""
1177-
return {}
1211+
return EntityPrunePassResult()
11781212

11791213

11801214
__all__ = [
@@ -1194,10 +1228,12 @@ async def entity_prune_pass(self, *, backend, fq_table, bank_id: str, deadline:
11941228
"META_UPDATED_AT",
11951229
"CausalEdgeRecord",
11961230
"DeletePredicate",
1231+
"EntityPrunePassResult",
11971232
"FactRecord",
11981233
"MemoriesExtension",
11991234
"MemoryPatch",
12001235
"MemoryTxn",
1236+
"RelinkPassResult",
12011237
"ScanPage",
12021238
"StoredMemory",
12031239
"build_fact_records",

hindsight-api-slim/hindsight_api/engine/memories/pg/graph.py

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
_normalize_datetime,
4343
compute_semantic_links_ann,
4444
)
45+
from ..base import EntityPrunePassResult, RelinkPassResult
4546

4647
logger = logging.getLogger(__name__)
4748

@@ -87,6 +88,11 @@
8788
# contention window a concurrent retain opens.
8889
_PRUNE_BATCH_MAX_RETRIES = 3
8990

91+
# Unit ids per candidate-lookup round-trip when enqueueing. Bounded by Oracle's
92+
# 1000-element IN-list limit (ops_oracle expands ``= ANY(...)`` into a literal
93+
# list), which a bulk delete would otherwise blow straight through.
94+
_ENQUEUE_LOOKUP_CHUNK = 500
95+
9096
# Cap at 10k edges — the UI can't usefully render more, and uncapped queries
9197
# on highly-connected graphs (e.g. 1000 nodes with 500k+ edges) are too slow.
9298
_GRAPH_MAX_EDGES = 10000
@@ -557,7 +563,7 @@ async def relink_pass(
557563
bank_id: str,
558564
config: Any,
559565
deadline: float | None = None,
560-
) -> dict:
566+
) -> RelinkPassResult:
561567
"""Drain ``graph_maintenance_queue`` for ``bank_id``, topping up lost links.
562568
563569
Per-iteration loop: claim → top up → commit. We rely on at most one job per
@@ -583,10 +589,8 @@ async def relink_pass(
583589
keeps the work already done and leaves the rest queued for the next run.
584590
585591
Returns:
586-
``{"relink_units_processed": int, "relink_links_added": int,
587-
"relink_queue_exhausted": bool}``. ``relink_queue_exhausted`` is False
588-
when the deadline (or the iteration cap) stopped the drain with rows
589-
still queued.
592+
A :class:`RelinkPassResult`. ``queue_exhausted`` is False when the
593+
deadline (or the iteration cap) stopped the drain with rows still queued.
590594
"""
591595
del config # accepted for symmetry with stores that tune their own relinking
592596
ops = backend.ops
@@ -628,11 +632,11 @@ async def relink_pass(
628632
drained = False
629633
break
630634

631-
return {
632-
"relink_units_processed": units_processed,
633-
"relink_links_added": links_added,
634-
"relink_queue_exhausted": drained,
635-
}
635+
return RelinkPassResult(
636+
units_processed=units_processed,
637+
links_added=links_added,
638+
queue_exhausted=drained,
639+
)
636640

637641

638642
async def _relink_batch(
@@ -786,13 +790,23 @@ async def enqueue_entity_prune_candidates(
786790
return 0
787791

788792
ops = _ops_for(conn)
789-
return await ops.enqueue_entity_maintenance(
790-
conn,
791-
fq_table("entity_maintenance_queue"),
792-
fq_table("unit_entities"),
793-
bank_id,
794-
_as_uuids(list(affected_unit_ids)),
795-
)
793+
queue_table = fq_table("entity_maintenance_queue")
794+
ue_table = fq_table("unit_entities")
795+
unit_uuids = _as_uuids(list(affected_unit_ids))
796+
797+
# Chunked because a bulk delete can hand in thousands of unit ids and the
798+
# lookup binds them with `= ANY(...)`, which ops_oracle expands into a
799+
# literal IN list — Oracle caps those at 1000 elements.
800+
enqueued = 0
801+
for start in range(0, len(unit_uuids), _ENQUEUE_LOOKUP_CHUNK):
802+
enqueued += await ops.enqueue_entity_maintenance(
803+
conn,
804+
queue_table,
805+
ue_table,
806+
bank_id,
807+
unit_uuids[start : start + _ENQUEUE_LOOKUP_CHUNK],
808+
)
809+
return enqueued
796810

797811

798812
@dataclass
@@ -810,7 +824,7 @@ async def entity_prune_pass(
810824
fq_table: Callable[[str], str],
811825
bank_id: str,
812826
deadline: float | None = None,
813-
) -> dict:
827+
) -> EntityPrunePassResult:
814828
"""Drain ``entity_maintenance_queue`` for ``bank_id``, pruning what died.
815829
816830
Per-iteration loop: claim → prune → commit, mirroring :func:`relink_pass`.
@@ -845,10 +859,8 @@ async def entity_prune_pass(
845859
``None`` drains to empty.
846860
847861
Returns:
848-
``{"entities_examined": int, "orphan_entities_pruned": int,
849-
"stale_cooccurrences_pruned": int, "entity_queue_exhausted": bool}``.
850-
``entity_queue_exhausted`` is False when the deadline stopped the drain
851-
with rows still queued.
862+
An :class:`EntityPrunePassResult`. ``queue_exhausted`` is False when the
863+
deadline stopped the drain with rows still queued.
852864
"""
853865
from ...db_utils import retry_with_backoff
854866
from ...memory_engine import acquire_with_retry
@@ -921,12 +933,12 @@ async def _run_batch() -> _PruneBatch:
921933
orphans_pruned += batch.orphan_entities_pruned
922934
stale_pruned += batch.stale_cooccurrences_pruned
923935

924-
return {
925-
"entities_examined": examined,
926-
"orphan_entities_pruned": orphans_pruned,
927-
"stale_cooccurrences_pruned": stale_pruned,
928-
"entity_queue_exhausted": drained,
929-
}
936+
return EntityPrunePassResult(
937+
entities_examined=examined,
938+
orphan_entities_pruned=orphans_pruned,
939+
stale_cooccurrences_pruned=stale_pruned,
940+
queue_exhausted=drained,
941+
)
930942

931943

932944
__all__ = [

hindsight-api-slim/hindsight_api/engine/memories/postgres.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,15 @@
2323
from datetime import datetime
2424
from typing import Any
2525

26-
from .base import DeletePredicate, MemoriesExtension, MemoryPatch, ScanPage, StoredMemory
26+
from .base import (
27+
DeletePredicate,
28+
EntityPrunePassResult,
29+
MemoriesExtension,
30+
MemoryPatch,
31+
RelinkPassResult,
32+
ScanPage,
33+
StoredMemory,
34+
)
2735
from .pg import counts, curation, graph, reads, writes
2836

2937

@@ -496,7 +504,9 @@ async def enqueue_relink_victims(
496504
include_affected_units=include_affected_units,
497505
)
498506

499-
async def relink_pass(self, *, backend, fq_table, bank_id: str, config, deadline: float | None = None) -> dict:
507+
async def relink_pass(
508+
self, *, backend, fq_table, bank_id: str, config, deadline: float | None = None
509+
) -> RelinkPassResult:
500510
return await graph.relink_pass(
501511
backend=backend, fq_table=fq_table, bank_id=bank_id, config=config, deadline=deadline
502512
)
@@ -509,7 +519,9 @@ async def enqueue_entity_prune_candidates(self, *, conn, fq_table, bank_id: str,
509519
affected_unit_ids=affected_unit_ids,
510520
)
511521

512-
async def entity_prune_pass(self, *, backend, fq_table, bank_id: str, deadline: float | None = None) -> dict:
522+
async def entity_prune_pass(
523+
self, *, backend, fq_table, bank_id: str, deadline: float | None = None
524+
) -> EntityPrunePassResult:
513525
return await graph.entity_prune_pass(backend=backend, fq_table=fq_table, bank_id=bank_id, deadline=deadline)
514526

515527

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8775,9 +8775,15 @@ def _parse_edit_date(value: str | None) -> datetime | None:
87758775
phase2_committed = True
87768776
finally:
87778777
# Entities were resolved (and possibly autocommitted) in Phase 1 but the edit did not
8778-
# durably apply (row concurrently invalidated → live2 None, or Phase 2 raised): those
8779-
# entities may now be orphans, and the edit's own relink-victim enqueue never ran. Force
8780-
# a bank-wide graph-maintenance sweep to reclaim them.
8778+
# durably apply (row concurrently invalidated → live2 None, or Phase 2 raised), so the
8779+
# edit's own enqueues rolled back with it. Kick a job for whatever else the bank has
8780+
# queued; it short-circuits when there is nothing.
8781+
#
8782+
# A Phase-1 entity that never got its posting is deliberately NOT chased here. The
8783+
# prune is queue-driven (#3222) and reads its candidates out of unit_entities, so such
8784+
# a row is invisible to it — and that is the safe direction: #2662 is precisely the
8785+
# race where pruning a just-resolved, not-yet-linked parent turned a retry into silent
8786+
# memory loss, and the retry is meant to adopt that row rather than re-create it.
87818787
if entities_resolved and not (edit_applied and phase2_committed):
87828788
try:
87838789
await self.submit_async_graph_maintenance(

hindsight-api-slim/tests/test_db_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def test_backoff_delay_is_jittered_and_bounded():
8383
"""Equal-jitter backoff stays in [ceil/2, ceil] and never exceeds max_delay.
8484
8585
The jitter exists so concurrent deadlock retriers don't wake in lock-step
86-
and re-collide (see run_graph_maintenance_job's Pass 2/3 sweep). It must
86+
and re-collide (see the entity-prune batch in run_graph_maintenance_job). It must
8787
still keep a floor (no hot-spin) and honour the max_delay cap once the
8888
exponential term saturates.
8989
"""

0 commit comments

Comments
 (0)