@pentatonic-ai/ai-agent-sdk 0.10.30 → 0.10.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/memory-engine-v2/compat/server.py +27 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +40 -12
- package/packages/memory-engine-v2/extractor-sync/server.py +26 -4
- package/packages/memory-engine-v2/fusion_drive/merge.py +10 -3
- package/packages/memory-engine-v2/org-model/migrations/011_event_distillations_time_index.sql +26 -0
- package/packages/memory-engine-v2/scripts/cap_provenance.py +115 -0
- package/packages/memory-engine-v2/scripts/prune_distillation_queue.py +96 -0
package/dist/index.cjs
CHANGED
|
@@ -878,7 +878,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
|
|
|
878
878
|
}
|
|
879
879
|
|
|
880
880
|
// src/telemetry.js
|
|
881
|
-
var VERSION = "0.10.
|
|
881
|
+
var VERSION = "0.10.32";
|
|
882
882
|
var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
|
|
883
883
|
function machineId() {
|
|
884
884
|
const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
|
package/dist/index.js
CHANGED
|
@@ -847,7 +847,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
|
|
|
847
847
|
}
|
|
848
848
|
|
|
849
849
|
// src/telemetry.js
|
|
850
|
-
var VERSION = "0.10.
|
|
850
|
+
var VERSION = "0.10.32";
|
|
851
851
|
var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
|
|
852
852
|
function machineId() {
|
|
853
853
|
const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.32",
|
|
4
4
|
"description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -57,6 +57,8 @@ NV_EMBED_URL = os.environ.get("NV_EMBED_URL", "http://nv-embed:8041/v1/embedding
|
|
|
57
57
|
NV_EMBED_API_KEY = os.environ.get("NV_EMBED_API_KEY", "")
|
|
58
58
|
NV_EMBED_PROVIDER = os.environ.get("NV_EMBED_PROVIDER", "openai") # 'openai' | 'pentatonic-gateway'
|
|
59
59
|
EMBED_DIM = int(os.environ.get("EMBED_DIM", "4096"))
|
|
60
|
+
# Window (minutes) over which /health/deep computes the distillation drain rate.
|
|
61
|
+
HEALTH_DRAIN_WINDOW_MIN = int(os.environ.get("HEALTH_DRAIN_WINDOW_MIN", "5"))
|
|
60
62
|
|
|
61
63
|
COLLECTION_NAME = "evidence"
|
|
62
64
|
|
|
@@ -665,6 +667,31 @@ async def health_deep():
|
|
|
665
667
|
except Exception as e:
|
|
666
668
|
result["stores"]["org_model"] = {"status": "error", "error": str(e)}
|
|
667
669
|
|
|
670
|
+
# distillation drain rate — events distilled per minute over a recent
|
|
671
|
+
# window, by producer. Surfaces whether the async extractor fleet is keeping
|
|
672
|
+
# up with ingest (pair with distillation_pending in the counts above). Cheap:
|
|
673
|
+
# served by idx_event_distillations_producer_time (producer, distilled_at).
|
|
674
|
+
try:
|
|
675
|
+
async with _pool.connection() as conn:
|
|
676
|
+
async with conn.cursor() as cur:
|
|
677
|
+
await cur.execute(
|
|
678
|
+
"SELECT producer, count(*) AS n FROM event_distillations "
|
|
679
|
+
"WHERE distilled_at > now() - make_interval(mins => %s) "
|
|
680
|
+
"GROUP BY producer",
|
|
681
|
+
(HEALTH_DRAIN_WINDOW_MIN,),
|
|
682
|
+
)
|
|
683
|
+
rows = await cur.fetchall()
|
|
684
|
+
by_producer = {r["producer"]: r["n"] for r in rows}
|
|
685
|
+
total = sum(by_producer.values())
|
|
686
|
+
result["distillation"] = {
|
|
687
|
+
"window_min": HEALTH_DRAIN_WINDOW_MIN,
|
|
688
|
+
"drain_per_min": round(total / HEALTH_DRAIN_WINDOW_MIN, 1),
|
|
689
|
+
"drained_in_window": total,
|
|
690
|
+
"by_producer": by_producer,
|
|
691
|
+
}
|
|
692
|
+
except Exception as e:
|
|
693
|
+
result["distillation"] = {"status": "error", "error": str(e)}
|
|
694
|
+
|
|
668
695
|
# vector-index
|
|
669
696
|
try:
|
|
670
697
|
info = await _qdrant.get_collection(COLLECTION_NAME)
|
|
@@ -153,6 +153,42 @@ MAX_INFLIGHT_BATCHES = int(os.environ.get("MAX_INFLIGHT_BATCHES", "4"))
|
|
|
153
153
|
# continuous stream of calls instead of per-batch bursts. None until amain runs.
|
|
154
154
|
_STUDENT_SEM: "asyncio.Semaphore | None" = None
|
|
155
155
|
_TEACHER_SEM: "asyncio.Semaphore | None" = None
|
|
156
|
+
|
|
157
|
+
# Cap on provenance_event_ids array length per node (entity/fact/relationship).
|
|
158
|
+
# UNBOUNDED accretion was the drain-throughput bottleneck: hot nodes re-mentioned
|
|
159
|
+
# thousands of times grew arrays to 50k+ event IDs (~2MB TOASTed), so every
|
|
160
|
+
# subsequent upsert read+rewrote the whole array off disk (16s DataFileRead
|
|
161
|
+
# measured; entities TOAST hit 233GB, relationships 66GB — 2026-07-08). Keep only
|
|
162
|
+
# the most-recent N distinct IDs — provenance drives corroboration counts +
|
|
163
|
+
# citations, and the avg node has ~6 (only a handful of super-nodes exceed this),
|
|
164
|
+
# so 200 preserves all real provenance while bounding the pathological tail.
|
|
165
|
+
# Capping the STORED array is self-healing: the first capped write shrinks a
|
|
166
|
+
# bloated node to N, then its upserts stay cheap.
|
|
167
|
+
PROVENANCE_MAX_EVENT_IDS = int(os.environ.get("PROVENANCE_MAX_EVENT_IDS", "200"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _cap_prov(existing: str, incoming: str) -> str:
|
|
171
|
+
"""SQL expression: merge the `existing` and `incoming` text[] provenance
|
|
172
|
+
arrays, dedupe, and keep the PROVENANCE_MAX_EVENT_IDS most-recent IDs (by
|
|
173
|
+
position — appended IDs are newest), re-ordered oldest→newest to preserve the
|
|
174
|
+
append convention readers see. Drop-in replacement for the old unbounded
|
|
175
|
+
`ARRAY(SELECT DISTINCT UNNEST(existing || incoming))`. `incoming` may carry a
|
|
176
|
+
single %s placeholder; it stays in the same param position."""
|
|
177
|
+
return (
|
|
178
|
+
"(SELECT COALESCE(array_agg(ev ORDER BY ord), ARRAY[]::text[]) "
|
|
179
|
+
"FROM (SELECT ev, max(ord) AS ord "
|
|
180
|
+
f"FROM unnest(({existing}) || ({incoming})) WITH ORDINALITY AS _pu(ev, ord) "
|
|
181
|
+
f"GROUP BY ev ORDER BY max(ord) DESC LIMIT {PROVENANCE_MAX_EVENT_IDS}) _pk)"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Precomputed capped-provenance SQL fragments for each upsert site (built once).
|
|
186
|
+
_PROV_ENT_UPDATE = _cap_prov("provenance_event_ids", "%s::text[]")
|
|
187
|
+
_PROV_ENT_CONFLICT = _cap_prov("entities.provenance_event_ids", "EXCLUDED.provenance_event_ids")
|
|
188
|
+
_PROV_FACT = _cap_prov("facts.provenance_event_ids", "EXCLUDED.provenance_event_ids")
|
|
189
|
+
_PROV_REL = _cap_prov("relationships.provenance_event_ids", "EXCLUDED.provenance_event_ids")
|
|
190
|
+
|
|
191
|
+
|
|
156
192
|
# Fraction of student-passing events ALSO sent to the teacher — NOT a quality
|
|
157
193
|
# lever, this is the monitoring + active-learning sample (drift detection and
|
|
158
194
|
# fresh teacher-gold on live traffic). Default conservative-ish 5%.
|
|
@@ -1910,7 +1946,7 @@ def upsert_entities(
|
|
|
1910
1946
|
"""
|
|
1911
1947
|
UPDATE entities SET
|
|
1912
1948
|
aliases = ARRAY(SELECT DISTINCT UNNEST(aliases || %s::text[])),
|
|
1913
|
-
provenance_event_ids =
|
|
1949
|
+
provenance_event_ids = """ + _PROV_ENT_UPDATE + """,
|
|
1914
1950
|
-- Merge the org-domain hard key (no-op for the `{}` default
|
|
1915
1951
|
-- on non-orgs / unmatched orgs; never clobbers an existing key).
|
|
1916
1952
|
attributes = entities.attributes || %s::jsonb,
|
|
@@ -1986,9 +2022,7 @@ def upsert_entities(
|
|
|
1986
2022
|
aliases = (
|
|
1987
2023
|
SELECT ARRAY(SELECT DISTINCT UNNEST(entities.aliases || EXCLUDED.aliases))
|
|
1988
2024
|
),
|
|
1989
|
-
provenance_event_ids =
|
|
1990
|
-
SELECT ARRAY(SELECT DISTINCT UNNEST(entities.provenance_event_ids || EXCLUDED.provenance_event_ids))
|
|
1991
|
-
),
|
|
2025
|
+
provenance_event_ids = """ + _PROV_ENT_CONFLICT + """,
|
|
1992
2026
|
-- merge the org-domain hard key (no-op for `{}`)
|
|
1993
2027
|
attributes = entities.attributes || EXCLUDED.attributes,
|
|
1994
2028
|
-- re-corroboration can only RAISE salience, never lower it
|
|
@@ -2107,11 +2141,7 @@ def upsert_facts(
|
|
|
2107
2141
|
COALESCE(%s, NOW()), %s, %s
|
|
2108
2142
|
)
|
|
2109
2143
|
ON CONFLICT (id) DO UPDATE SET
|
|
2110
|
-
provenance_event_ids =
|
|
2111
|
-
SELECT ARRAY(SELECT DISTINCT UNNEST(
|
|
2112
|
-
facts.provenance_event_ids || EXCLUDED.provenance_event_ids
|
|
2113
|
-
))
|
|
2114
|
-
),
|
|
2144
|
+
provenance_event_ids = """ + _PROV_FACT + """,
|
|
2115
2145
|
salience = GREATEST(facts.salience, EXCLUDED.salience),
|
|
2116
2146
|
-- Confidence bumps with each additional independent
|
|
2117
2147
|
-- source. The cardinality of the merged provenance
|
|
@@ -2222,9 +2252,7 @@ def upsert_relationships(
|
|
|
2222
2252
|
-- distillation + the SEE-490 backfill on merged-entity edges.
|
|
2223
2253
|
ON CONFLICT (arena, from_entity_id, to_entity_id, relationship_type) DO UPDATE SET
|
|
2224
2254
|
weight = relationships.weight + EXCLUDED.weight,
|
|
2225
|
-
provenance_event_ids =
|
|
2226
|
-
SELECT ARRAY(SELECT DISTINCT UNNEST(relationships.provenance_event_ids || EXCLUDED.provenance_event_ids))
|
|
2227
|
-
),
|
|
2255
|
+
provenance_event_ids = """ + _PROV_REL + """,
|
|
2228
2256
|
attributes = relationships.attributes || EXCLUDED.attributes,
|
|
2229
2257
|
last_seen = GREATEST(relationships.last_seen, EXCLUDED.last_seen),
|
|
2230
2258
|
first_seen = LEAST(relationships.first_seen, EXCLUDED.first_seen)
|
|
@@ -49,6 +49,30 @@ log = logging.getLogger("extractor-sync")
|
|
|
49
49
|
|
|
50
50
|
PG_DSN = os.environ.get("PG_DSN", "postgresql://pme:local-dev-pw@org-model:5432/org_model")
|
|
51
51
|
|
|
52
|
+
# Cap on provenance_event_ids array length. Unbounded accretion bloated the
|
|
53
|
+
# entities table to 249GB (233GB TOAST) — hot nodes grew 50k+-element arrays so
|
|
54
|
+
# every upsert read+rewrote ~2MB off disk (16s DataFileRead, 2026-07-08). Keep
|
|
55
|
+
# only the most-recent N distinct IDs. Byte-identical intent to extractor-async's
|
|
56
|
+
# _cap_prov (kept as a local copy per the sibling-service duplication convention,
|
|
57
|
+
# like confidence.py / source_time.py).
|
|
58
|
+
PROVENANCE_MAX_EVENT_IDS = int(os.environ.get("PROVENANCE_MAX_EVENT_IDS", "200"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cap_prov(existing: str, incoming: str) -> str:
|
|
62
|
+
"""Merge `existing`||`incoming` text[] provenance, dedupe, keep the
|
|
63
|
+
PROVENANCE_MAX_EVENT_IDS most-recent IDs (append order = newest last).
|
|
64
|
+
Drop-in for the old unbounded `ARRAY(SELECT DISTINCT UNNEST(...))`."""
|
|
65
|
+
return (
|
|
66
|
+
"(SELECT COALESCE(array_agg(ev ORDER BY ord), ARRAY[]::text[]) "
|
|
67
|
+
"FROM (SELECT ev, max(ord) AS ord "
|
|
68
|
+
f"FROM unnest(({existing}) || ({incoming})) WITH ORDINALITY AS _pu(ev, ord) "
|
|
69
|
+
f"GROUP BY ev ORDER BY max(ord) DESC LIMIT {PROVENANCE_MAX_EVENT_IDS}) _pk)"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_PROV_ENT_UPDATE = _cap_prov("provenance_event_ids", "%s::text[]")
|
|
74
|
+
_PROV_ENT_CONFLICT = _cap_prov("entities.provenance_event_ids", "EXCLUDED.provenance_event_ids")
|
|
75
|
+
|
|
52
76
|
# Connection pool. Sized for high-concurrency bursts: with TES at
|
|
53
77
|
# shardCount=8 and BATCH_SIZE=50 in the DO drain, compat's parallel
|
|
54
78
|
# asyncio.gather in /store-batch can fan ~400 simultaneous _extract
|
|
@@ -534,7 +558,7 @@ async def _upsert_entities(
|
|
|
534
558
|
"""
|
|
535
559
|
UPDATE entities SET
|
|
536
560
|
aliases = ARRAY(SELECT DISTINCT UNNEST(aliases || %s::text[])),
|
|
537
|
-
provenance_event_ids =
|
|
561
|
+
provenance_event_ids = """ + _PROV_ENT_UPDATE + """,
|
|
538
562
|
-- Widen the seen-window with this event's SOURCE time,
|
|
539
563
|
-- not NOW(): newest evidence = newest source time.
|
|
540
564
|
last_seen = GREATEST(last_seen, COALESCE(%s, NOW())),
|
|
@@ -574,9 +598,7 @@ async def _upsert_entities(
|
|
|
574
598
|
aliases = (
|
|
575
599
|
SELECT ARRAY(SELECT DISTINCT UNNEST(entities.aliases || EXCLUDED.aliases))
|
|
576
600
|
),
|
|
577
|
-
provenance_event_ids =
|
|
578
|
-
SELECT ARRAY(SELECT DISTINCT UNNEST(entities.provenance_event_ids || EXCLUDED.provenance_event_ids))
|
|
579
|
-
),
|
|
601
|
+
provenance_event_ids = """ + _PROV_ENT_CONFLICT + """,
|
|
580
602
|
-- merge the email/domain hard key (no-op for the `{}` default
|
|
581
603
|
-- on entity types that don't stamp it); never clobbers.
|
|
582
604
|
attributes = entities.attributes || EXCLUDED.attributes,
|
|
@@ -17,6 +17,13 @@ from __future__ import annotations
|
|
|
17
17
|
from dataclasses import dataclass, field
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
+
# Defensive bound on merged provenance arrays. A merge of many fragmented
|
|
21
|
+
# entities could otherwise union thousands of provenance IDs into the master,
|
|
22
|
+
# re-bloating the entities/relationships TOAST that the extractor cap fixes
|
|
23
|
+
# (see extractor-async/worker.py PROVENANCE_MAX_EVENT_IDS). Kept a literal here
|
|
24
|
+
# to preserve this module's no-env/no-clock purity; must match the extractor.
|
|
25
|
+
PROVENANCE_MAX_EVENT_IDS = 200
|
|
26
|
+
|
|
20
27
|
|
|
21
28
|
# ── entity fusion plan ───────────────────────────────────────────────
|
|
22
29
|
@dataclass
|
|
@@ -75,7 +82,7 @@ def build_entity_merge_plan(
|
|
|
75
82
|
provenance = _union(
|
|
76
83
|
master.get("provenance_event_ids", []),
|
|
77
84
|
*[l.get("provenance_event_ids", []) for l in losers],
|
|
78
|
-
)
|
|
85
|
+
)[:PROVENANCE_MAX_EVENT_IDS]
|
|
79
86
|
|
|
80
87
|
plan = EntityMergePlan(
|
|
81
88
|
arena=arena,
|
|
@@ -112,7 +119,7 @@ def build_entity_merge_plan(
|
|
|
112
119
|
"drop": r["id"],
|
|
113
120
|
"summed_weight": round(keep.get("weight", 1.0) + r.get("weight", 1.0), 4),
|
|
114
121
|
"provenance": _union(keep.get("provenance_event_ids", []),
|
|
115
|
-
r.get("provenance_event_ids", [])),
|
|
122
|
+
r.get("provenance_event_ids", []))[:PROVENANCE_MAX_EVENT_IDS],
|
|
116
123
|
})
|
|
117
124
|
else:
|
|
118
125
|
by_key[key] = r
|
|
@@ -147,7 +154,7 @@ def build_fact_merge_plan(*, arena: str, dup_facts: list[dict]) -> dict | None:
|
|
|
147
154
|
)
|
|
148
155
|
master, losers = ranked[0], ranked[1:]
|
|
149
156
|
provenance = _union(master.get("provenance_event_ids", []),
|
|
150
|
-
*[l.get("provenance_event_ids", []) for l in losers])
|
|
157
|
+
*[l.get("provenance_event_ids", []) for l in losers])[:PROVENANCE_MAX_EVENT_IDS]
|
|
151
158
|
return {
|
|
152
159
|
"arena": arena,
|
|
153
160
|
"master_id": master["id"],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- 011_event_distillations_time_index.sql — time-ordered index for drain-rate
|
|
2
|
+
-- queries over the distillation ledger.
|
|
3
|
+
--
|
|
4
|
+
-- /health/deep surfaces the distillation drain rate (events distilled per
|
|
5
|
+
-- minute, by producer) so we can see whether the async extractor fleet is
|
|
6
|
+
-- keeping up with ingest. That query is a recent-window scan across ALL
|
|
7
|
+
-- producers:
|
|
8
|
+
--
|
|
9
|
+
-- SELECT producer, count(*) FROM event_distillations
|
|
10
|
+
-- WHERE distilled_at > now() - interval '5 minutes' GROUP BY producer;
|
|
11
|
+
--
|
|
12
|
+
-- The existing idx_event_distillations_producer_time is (producer, distilled_at)
|
|
13
|
+
-- — producer-leading, so it cannot serve a cross-producer time-range scan, and
|
|
14
|
+
-- the planner falls back to a Seq Scan (measured 244ms at 420k rows). The ledger
|
|
15
|
+
-- is an append-only, unbounded log growing ~80k rows/day, so that scan degrades
|
|
16
|
+
-- linearly. This time-leading index makes the drain query an index range scan
|
|
17
|
+
-- (producer trails so the GROUP BY is covered) and keeps it flat as the ledger
|
|
18
|
+
-- grows. Also serves any other "recent distillations" time-range query.
|
|
19
|
+
--
|
|
20
|
+
-- On an already-initialised prod DB (migrations mount at
|
|
21
|
+
-- /docker-entrypoint-initdb.d and only run on a fresh volume) apply this
|
|
22
|
+
-- manually with CREATE INDEX CONCURRENTLY to avoid locking the ledger against
|
|
23
|
+
-- live writes.
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_event_distillations_distilled_at
|
|
26
|
+
ON event_distillations (distilled_at DESC, producer);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cap_provenance.py — one-time truncation of over-long provenance_event_ids
|
|
3
|
+
arrays on the graph PG (org_model).
|
|
4
|
+
|
|
5
|
+
Unbounded provenance accretion bloated `entities` to 249GB (233GB TOAST) and
|
|
6
|
+
`relationships` to 72GB (66GB TOAST): a handful of hot nodes grew arrays to
|
|
7
|
+
50k+ event IDs (~2MB TOASTed), so every upsert read+rewrote the whole array off
|
|
8
|
+
disk — a 16s DataFileRead per update, which was THE distillation drain-throughput
|
|
9
|
+
bottleneck (2026-07-08). The extractor cap (worker.py / server.py
|
|
10
|
+
PROVENANCE_MAX_EVENT_IDS) stops NEW growth; this script shrinks the arrays that
|
|
11
|
+
are ALREADY over the cap so their next upsert is cheap immediately, rather than
|
|
12
|
+
waiting for each hot node to be re-mentioned.
|
|
13
|
+
|
|
14
|
+
It keeps the most-recent CAP distinct IDs (same recency-preserving expression as
|
|
15
|
+
the extractor write path). Runs in bounded batches with a commit per batch so it
|
|
16
|
+
never holds a long lock against live distillation, and is fully idempotent
|
|
17
|
+
(re-running skips already-capped rows via the cardinality guard).
|
|
18
|
+
|
|
19
|
+
DRY-RUN by default (reports how many rows would be capped, writes nothing).
|
|
20
|
+
Pass --apply to write.
|
|
21
|
+
|
|
22
|
+
# in the extractor-async container (has psycopg + PG_DSN):
|
|
23
|
+
python3 cap_provenance.py --table entities --apply
|
|
24
|
+
python3 cap_provenance.py --table relationships --apply
|
|
25
|
+
|
|
26
|
+
Disk reclaim: truncation turns the old 2MB array versions into dead TOAST
|
|
27
|
+
tuples. autovacuum frees them for REUSE (so the table stops growing) but does
|
|
28
|
+
NOT shrink the file. To return the ~299GB to disk, run VACUUM FULL (locks) or
|
|
29
|
+
pg_repack (online) in a maintenance window — non-urgent; performance recovers
|
|
30
|
+
as soon as the live arrays are small.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
import psycopg
|
|
40
|
+
|
|
41
|
+
# Same recency-preserving cap as the extractor write path (worker.py._cap_prov):
|
|
42
|
+
# dedupe existing, keep the CAP most-recent by position, re-order oldest→newest.
|
|
43
|
+
_CAP_SQL = (
|
|
44
|
+
"(SELECT COALESCE(array_agg(ev ORDER BY ord), ARRAY[]::text[]) "
|
|
45
|
+
"FROM (SELECT ev, max(ord) AS ord "
|
|
46
|
+
"FROM unnest(provenance_event_ids) WITH ORDINALITY AS _pu(ev, ord) "
|
|
47
|
+
"GROUP BY ev ORDER BY max(ord) DESC LIMIT %(cap)s) _pk)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
ALLOWED_TABLES = {"entities", "facts", "relationships"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> int:
|
|
54
|
+
ap = argparse.ArgumentParser()
|
|
55
|
+
ap.add_argument("--table", required=True, choices=sorted(ALLOWED_TABLES))
|
|
56
|
+
ap.add_argument("--cap", type=int, default=int(os.environ.get("PROVENANCE_MAX_EVENT_IDS", "200")))
|
|
57
|
+
ap.add_argument("--batch", type=int, default=200, help="ids updated per commit")
|
|
58
|
+
ap.add_argument("--apply", action="store_true", help="write (default: dry-run)")
|
|
59
|
+
args = ap.parse_args()
|
|
60
|
+
|
|
61
|
+
# choices= already guards this; belt-and-braces since the table name is
|
|
62
|
+
# interpolated into DDL-ish SQL (can't be a bound parameter).
|
|
63
|
+
if args.table not in ALLOWED_TABLES:
|
|
64
|
+
print(f"refusing unknown table {args.table!r}", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
tbl = args.table
|
|
67
|
+
|
|
68
|
+
dsn = os.environ["PG_DSN"]
|
|
69
|
+
with psycopg.connect(dsn, autocommit=True) as conn:
|
|
70
|
+
over = conn.execute(
|
|
71
|
+
f"SELECT count(*) FROM {tbl} WHERE cardinality(provenance_event_ids) > %s",
|
|
72
|
+
(args.cap,),
|
|
73
|
+
).fetchone()[0]
|
|
74
|
+
mx = conn.execute(
|
|
75
|
+
f"SELECT COALESCE(max(cardinality(provenance_event_ids)), 0) FROM {tbl}"
|
|
76
|
+
).fetchone()[0]
|
|
77
|
+
print(f"{tbl}: {over} row(s) over cap={args.cap} (current max cardinality={mx})")
|
|
78
|
+
if not args.apply:
|
|
79
|
+
print("dry-run — pass --apply to truncate")
|
|
80
|
+
return 0
|
|
81
|
+
if over == 0:
|
|
82
|
+
print("nothing to do")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
ids = [
|
|
86
|
+
r[0]
|
|
87
|
+
for r in conn.execute(
|
|
88
|
+
f"SELECT id FROM {tbl} WHERE cardinality(provenance_event_ids) > %s",
|
|
89
|
+
(args.cap,),
|
|
90
|
+
).fetchall()
|
|
91
|
+
]
|
|
92
|
+
total = len(ids)
|
|
93
|
+
done = 0
|
|
94
|
+
t0 = time.monotonic()
|
|
95
|
+
for i in range(0, total, args.batch):
|
|
96
|
+
chunk = ids[i : i + args.batch]
|
|
97
|
+
# Re-check cardinality in the WHERE so a concurrent write that already
|
|
98
|
+
# shrank a row is skipped (idempotent, no wasted rewrite).
|
|
99
|
+
conn.execute(
|
|
100
|
+
f"UPDATE {tbl} SET provenance_event_ids = {_CAP_SQL} "
|
|
101
|
+
f"WHERE id = ANY(%(ids)s) AND cardinality(provenance_event_ids) > %(cap)s",
|
|
102
|
+
{"ids": chunk, "cap": args.cap},
|
|
103
|
+
)
|
|
104
|
+
done += len(chunk)
|
|
105
|
+
print(f" capped {done}/{total} ({time.monotonic()-t0:.0f}s)", flush=True)
|
|
106
|
+
|
|
107
|
+
mx2 = conn.execute(
|
|
108
|
+
f"SELECT COALESCE(max(cardinality(provenance_event_ids)), 0) FROM {tbl}"
|
|
109
|
+
).fetchone()[0]
|
|
110
|
+
print(f"done: {total} row(s) capped; max cardinality now {mx2} (<= {args.cap})")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""prune_distillation_queue.py — batched prune of old terminal-state rows from
|
|
3
|
+
distillation_queue.
|
|
4
|
+
|
|
5
|
+
The queue accumulates one row per distillation ATTEMPT and never prunes: done
|
|
6
|
+
rows pile up (992k of ~1.08M rows measured 2026-07-09), and the redistill
|
|
7
|
+
gardener inserts a fresh row per re-distill (no unique constraint on event_id).
|
|
8
|
+
This bloats idx_distillation_status, so once the workers got fast (after the
|
|
9
|
+
provenance cap) every `claim_next_batch` scan and every mark-done UPDATE started
|
|
10
|
+
taking 10-12s — the queue became the next drain bottleneck.
|
|
11
|
+
|
|
12
|
+
A `done` row's ONLY consumer is the ingest idempotency guard in
|
|
13
|
+
_enqueue_distillation (`NOT EXISTS ... status IN ('pending','claimed','done')`),
|
|
14
|
+
which stops a RE-DELIVERED event being distilled twice. Re-delivery only happens
|
|
15
|
+
for recent events (ingest retries / DLQ replays), so done rows older than a
|
|
16
|
+
safety window are pure tombstones. The redistill gardener does NOT read done rows
|
|
17
|
+
(its "already queued" guard checks only pending/claimed; its "already clean" gate
|
|
18
|
+
reads distillation_traces + event_distillations) — verified 2026-07-09. So
|
|
19
|
+
pruning old terminal rows is safe and does not trigger re-distillation.
|
|
20
|
+
|
|
21
|
+
Deletes in bounded batches (commit per batch) so it never holds a long lock or a
|
|
22
|
+
huge transaction against live distillation / replication. Idempotent + resumable.
|
|
23
|
+
|
|
24
|
+
DRY-RUN by default (reports how many rows match, writes nothing). --apply to delete.
|
|
25
|
+
|
|
26
|
+
python3 prune_distillation_queue.py --older-than-days 14 # dry-run
|
|
27
|
+
python3 prune_distillation_queue.py --older-than-days 14 --apply
|
|
28
|
+
|
|
29
|
+
Only 'done' by default. 'failed' rows are kept (few; useful for investigation)
|
|
30
|
+
unless --include-failed. After a large prune, plain autovacuum reclaims the space
|
|
31
|
+
for reuse (table stops growing); returning it to disk needs VACUUM/pg_repack.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
import psycopg
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> int:
|
|
43
|
+
ap = argparse.ArgumentParser()
|
|
44
|
+
ap.add_argument("--older-than-days", type=int, default=14,
|
|
45
|
+
help="prune terminal rows whose completed_at (or enqueued_at) is older than this")
|
|
46
|
+
ap.add_argument("--batch", type=int, default=10000, help="rows deleted per commit")
|
|
47
|
+
ap.add_argument("--include-failed", action="store_true",
|
|
48
|
+
help="also prune old 'failed' rows (default: 'done' only)")
|
|
49
|
+
ap.add_argument("--apply", action="store_true", help="delete (default: dry-run)")
|
|
50
|
+
args = ap.parse_args()
|
|
51
|
+
|
|
52
|
+
statuses = ["done"] + (["failed"] if args.include_failed else [])
|
|
53
|
+
# age is measured on completion time, falling back to enqueue time for legacy
|
|
54
|
+
# rows written before completed_at existed.
|
|
55
|
+
where = (
|
|
56
|
+
"status = ANY(%(statuses)s) "
|
|
57
|
+
"AND COALESCE(completed_at, enqueued_at) < now() - make_interval(days => %(days)s)"
|
|
58
|
+
)
|
|
59
|
+
params = {"statuses": statuses, "days": args.older_than_days}
|
|
60
|
+
|
|
61
|
+
with psycopg.connect(os.environ["PG_DSN"], autocommit=True) as conn:
|
|
62
|
+
total = conn.execute(
|
|
63
|
+
f"SELECT count(*) FROM distillation_queue WHERE {where}", params
|
|
64
|
+
).fetchone()[0]
|
|
65
|
+
overall = conn.execute("SELECT count(*) FROM distillation_queue").fetchone()[0]
|
|
66
|
+
print(f"matched {total} prunable row(s) (statuses={statuses}, "
|
|
67
|
+
f"older_than={args.older_than_days}d) of {overall} total")
|
|
68
|
+
if not args.apply:
|
|
69
|
+
print("dry-run — pass --apply to delete")
|
|
70
|
+
return 0
|
|
71
|
+
if total == 0:
|
|
72
|
+
print("nothing to prune")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
deleted = 0
|
|
76
|
+
t0 = time.monotonic()
|
|
77
|
+
while True:
|
|
78
|
+
# Delete by PK from a bounded id subset so each statement is a small,
|
|
79
|
+
# index-driven batch — never a full-table lock.
|
|
80
|
+
n = conn.execute(
|
|
81
|
+
f"DELETE FROM distillation_queue WHERE id IN "
|
|
82
|
+
f"(SELECT id FROM distillation_queue WHERE {where} LIMIT %(batch)s)",
|
|
83
|
+
{**params, "batch": args.batch},
|
|
84
|
+
).rowcount
|
|
85
|
+
deleted += n
|
|
86
|
+
print(f" deleted {deleted}/{total} ({time.monotonic()-t0:.0f}s)", flush=True)
|
|
87
|
+
if n == 0:
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
remaining = conn.execute("SELECT count(*) FROM distillation_queue").fetchone()[0]
|
|
91
|
+
print(f"done: pruned {deleted} row(s); distillation_queue now {remaining} rows")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|