Skip to content

Commit 86ff344

Browse files
authored
fix(worker): bound terminal operation history (#2708)
Add configurable TTL (default 30 days, 0=keep-forever) for terminal async_operations rows. Expired completed/failed/cancelled rows are pruned in bounded batches (1000/cycle) by a background task that never touches pending/processing work. Batch children are protected until their parent is pruned; cancelled-child cleanup atomically cancels a pending parent first. PG uses FOR UPDATE SKIP LOCKED; both PG and Oracle re-check eligibility under the row lock before deleting. Includes indexes, docs, and regenerated SDKs. 184 retention/worker/operation-status tests pass locally. All CI green. Fixes #2705
1 parent b6c7b2a commit 86ff344

26 files changed

Lines changed: 1395 additions & 31 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ HINDSIGHT_API_LOG_LEVEL=info
105105
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
106106
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
107107
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
108+
# HINDSIGHT_API_OPERATION_RETENTION_DAYS=30 # Keep terminal operation rows, payloads, and metadata for this many days; 0 disables automatic pruning.
109+
# HINDSIGHT_API_OPERATION_CLEANUP_BATCH_SIZE=1000 # Maximum expired terminal rows deleted per tenant schema in each cleanup cycle; must be positive.
108110

109111
# Vector Extension (Optional - uses pgvector by default)
110112
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Add indexes for terminal cleanup and newest-first operation listing.
2+
3+
Revision ID: a8c1e4f7b0d3
4+
Revises: f2a4b6c8d0e2
5+
Create Date: 2026-07-14
6+
"""
7+
8+
from collections.abc import Sequence
9+
10+
from alembic import context, op
11+
12+
from hindsight_api.alembic._dialect import run_for_dialect
13+
14+
revision: str = "a8c1e4f7b0d3"
15+
down_revision: str | Sequence[str] | None = "f2a4b6c8d0e2"
16+
branch_labels: str | Sequence[str] | None = None
17+
depends_on: str | Sequence[str] | None = None
18+
19+
20+
def _pg_schema_prefix() -> str:
21+
"""Schema-qualifier for PostgreSQL multi-tenant migration runs."""
22+
schema = context.config.get_main_option("target_schema")
23+
return f'"{schema}".' if schema else ""
24+
25+
26+
def _pg_upgrade() -> None:
27+
schema = _pg_schema_prefix()
28+
# These can be large tables in long-running installations. Concurrent DDL
29+
# keeps operation submission, polling, and status reads available.
30+
with op.get_context().autocommit_block():
31+
op.execute(
32+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_terminal_cleanup "
33+
f"ON {schema}async_operations (updated_at, operation_id) "
34+
"WHERE status IN ('completed', 'failed', 'cancelled')"
35+
)
36+
op.execute(
37+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_bank_created_desc "
38+
f"ON {schema}async_operations (bank_id, created_at DESC)"
39+
)
40+
41+
42+
def _pg_downgrade() -> None:
43+
schema = _pg_schema_prefix()
44+
with op.get_context().autocommit_block():
45+
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_bank_created_desc")
46+
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_terminal_cleanup")
47+
48+
49+
def _oracle_create_index(sql: str) -> None:
50+
"""Create an index idempotently for rerun-safe Oracle migrations."""
51+
block = (
52+
"BEGIN "
53+
"EXECUTE IMMEDIATE :stmt; "
54+
"EXCEPTION WHEN OTHERS THEN "
55+
"IF SQLCODE = -955 THEN NULL; ELSE RAISE; END IF; "
56+
"END;"
57+
)
58+
op.get_bind().exec_driver_sql(block, {"stmt": sql})
59+
60+
61+
def _oracle_upgrade() -> None:
62+
# Oracle migrations run with CURRENT_SCHEMA set to each tenant, so table
63+
# and index names intentionally remain unqualified here.
64+
_oracle_create_index(
65+
"CREATE INDEX idx_async_operations_terminal_cleanup ON async_operations (updated_at, operation_id, status)"
66+
)
67+
_oracle_create_index(
68+
"CREATE INDEX idx_async_operations_bank_created_desc ON async_operations (bank_id, created_at DESC)"
69+
)
70+
71+
72+
def _oracle_downgrade() -> None:
73+
op.execute("DROP INDEX idx_async_operations_bank_created_desc")
74+
op.execute("DROP INDEX idx_async_operations_terminal_cleanup")
75+
76+
77+
def upgrade() -> None:
78+
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
79+
80+
81+
def downgrade() -> None:
82+
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3144,6 +3144,8 @@ async def lifespan(app: FastAPI):
31443144
max_slots=config.worker_max_slots,
31453145
slot_reservations=config.worker_slot_reservations,
31463146
consolidation_bank_priority=config.worker_consolidation_bank_priority or None,
3147+
operation_retention_days=config.operation_retention_days,
3148+
operation_cleanup_batch_size=config.operation_cleanup_batch_size,
31473149
)
31483150
poller_task = asyncio.create_task(poller.run())
31493151
logging.info(f"Worker poller started (worker_id={worker_id})")
@@ -5422,8 +5424,9 @@ async def api_list_operations(
54225424
"/v1/default/banks/{bank_id}/operations/{operation_id}",
54235425
response_model=OperationStatusResponse,
54245426
summary="Get operation status",
5425-
description="Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. "
5426-
"Completed operations are removed from storage, so 'completed' means the operation finished successfully.",
5427+
description="Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', "
5428+
"'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured "
5429+
"retention window and are pruned afterward.",
54275430
operation_id="get_operation_status",
54285431
tags=["Operations"],
54295432
)

hindsight-api-slim/hindsight_api/config.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,8 @@ def _resolve_operation_temperature(operation_env: str, default: float) -> float
599599
ENV_WORKER_TASK_RETRY_BACKOFF_SECONDS = "HINDSIGHT_API_WORKER_TASK_RETRY_BACKOFF_SECONDS"
600600
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
601601
ENV_WORKER_MAX_SLOTS = "HINDSIGHT_API_WORKER_MAX_SLOTS"
602+
ENV_OPERATION_RETENTION_DAYS = "HINDSIGHT_API_OPERATION_RETENTION_DAYS"
603+
ENV_OPERATION_CLEANUP_BATCH_SIZE = "HINDSIGHT_API_OPERATION_CLEANUP_BATCH_SIZE"
602604

603605
# Per-operation-type slot reservations. Each entry maps an operation_type
604606
# (as stored in async_operations.operation_type) to its env var and default.
@@ -1055,6 +1057,10 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
10551057
DEFAULT_WORKER_TASK_RETRY_BACKOFF_SECONDS = 60 # Seconds between retries on transient task failure
10561058
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
10571059
DEFAULT_WORKER_MAX_SLOTS = 10 # Total concurrent tasks per worker
1060+
# Terminal rows keep their payload and metadata for one coherent debug/retry TTL.
1061+
# Zero retention days disables automatic pruning entirely.
1062+
DEFAULT_OPERATION_RETENTION_DAYS = 30
1063+
DEFAULT_OPERATION_CLEANUP_BATCH_SIZE = 1000
10581064
DEFAULT_RETAIN_MAX_CONCURRENT = 4 # Max concurrent retain DB phases (HNSW reads + writes). Limits I/O contention.
10591065

10601066
# Reflect agent settings
@@ -1929,6 +1935,8 @@ class HindsightConfig:
19291935
worker_max_slots: int
19301936
worker_slot_reservations: dict[str, int]
19311937
worker_consolidation_bank_priority: dict[str, int]
1938+
operation_retention_days: int
1939+
operation_cleanup_batch_size: int
19321940
retain_max_concurrent: int
19331941

19341942
# Reflect agent settings
@@ -2293,6 +2301,13 @@ def validate(self) -> None:
22932301
f"Reduce reservations or increase HINDSIGHT_API_WORKER_MAX_SLOTS."
22942302
)
22952303

2304+
if self.operation_retention_days < 0:
2305+
raise ValueError(f"{ENV_OPERATION_RETENTION_DAYS} must be >= 0, got {self.operation_retention_days}")
2306+
if self.operation_cleanup_batch_size < 1:
2307+
raise ValueError(
2308+
f"{ENV_OPERATION_CLEANUP_BATCH_SIZE} must be >= 1, got {self.operation_cleanup_batch_size}"
2309+
)
2310+
22962311
@classmethod
22972312
def from_env(cls) -> "HindsightConfig":
22982313
"""Create configuration from environment variables."""
@@ -2958,6 +2973,16 @@ def from_env(cls) -> "HindsightConfig":
29582973
worker_consolidation_bank_priority=_parse_bank_priority(
29592974
os.getenv(ENV_WORKER_CONSOLIDATION_BANK_PRIORITY, "")
29602975
),
2976+
operation_retention_days=_parse_non_negative_int(
2977+
ENV_OPERATION_RETENTION_DAYS,
2978+
os.getenv(ENV_OPERATION_RETENTION_DAYS),
2979+
DEFAULT_OPERATION_RETENTION_DAYS,
2980+
),
2981+
operation_cleanup_batch_size=_parse_positive_int(
2982+
ENV_OPERATION_CLEANUP_BATCH_SIZE,
2983+
os.getenv(ENV_OPERATION_CLEANUP_BATCH_SIZE),
2984+
DEFAULT_OPERATION_CLEANUP_BATCH_SIZE,
2985+
),
29612986
retain_max_concurrent=int(os.getenv(ENV_RETAIN_MAX_CONCURRENT, str(DEFAULT_RETAIN_MAX_CONCURRENT))),
29622987
# Reflect agent settings
29632988
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from abc import ABC, abstractmethod
2020
from dataclasses import dataclass
21+
from datetime import datetime
2122
from typing import Any
2223

2324
from .base import DatabaseConnection
@@ -484,6 +485,23 @@ async def prune_stale_cooccurrences(
484485

485486
# -- Task claiming operations ------------------------------------------
486487

488+
@abstractmethod
489+
async def prune_terminal_operations(
490+
self,
491+
conn: DatabaseConnection,
492+
table: str,
493+
cutoff: datetime,
494+
*,
495+
batch_size: int,
496+
) -> int:
497+
"""Delete one deterministic batch of terminal operations older than ``cutoff``.
498+
499+
Implementations must lock candidates without waiting on rows another
500+
worker is pruning, never select pending/processing rows, and return the
501+
number deleted. The caller provides a transaction around this method.
502+
"""
503+
...
504+
487505
@abstractmethod
488506
async def claim_tasks(
489507
self,

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

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from .ops import DataAccessOps, TagListingParts
1414
from .result import DictResultRow as ResultRow
1515

16+
ORACLE_IN_LIST_LIMIT = 1000
17+
1618

1719
class OracleOps(DataAccessOps):
1820
"""Oracle-specific data access operations."""
@@ -831,6 +833,157 @@ async def insert_webhook_delivery_task(self, conn, ops_table, operation_id, bank
831833

832834
# -- Task claiming operations ------------------------------------------
833835

836+
async def prune_terminal_operations(
837+
self,
838+
conn: DatabaseConnection,
839+
table: str,
840+
cutoff: datetime,
841+
*,
842+
batch_size: int,
843+
) -> int:
844+
# Oracle rejects a row-limited SELECT ... FOR UPDATE (ORA-02014). Pick
845+
# the deterministic bounded IDs first, then lock only that candidate
846+
# set and re-check eligibility before deleting in the same transaction.
847+
# Clamp to Oracle's 1000-expression IN-list limit because the adapter
848+
# expands the candidate UUID list into individual bind variables.
849+
# Cancelled children cannot complete parent aggregation, so retain the
850+
# parent guard only for completed/failed children. Before removing a
851+
# cancelled child, preserve its signal by cancelling a pending parent
852+
# in this transaction and refreshing the parent's retention window.
853+
# Validate metadata before HEXTORAW: CASE makes malformed UUIDs yield
854+
# NULL while keeping the indexed RAW parent.operation_id key unwrapped.
855+
effective_batch_size = min(batch_size, ORACLE_IN_LIST_LIMIT)
856+
candidates = await conn.fetch(
857+
f"""
858+
SELECT candidate_operation.operation_id
859+
FROM {table} candidate_operation
860+
WHERE candidate_operation.status IN ('completed', 'failed', 'cancelled')
861+
AND candidate_operation.updated_at < $1
862+
AND (
863+
candidate_operation.status = 'cancelled'
864+
OR NOT EXISTS (
865+
SELECT 1
866+
FROM {table} parent
867+
WHERE parent.operation_id = CASE
868+
WHEN REGEXP_LIKE(
869+
JSON_VALUE(
870+
candidate_operation.result_metadata,
871+
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
872+
),
873+
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
874+
)
875+
THEN HEXTORAW(REPLACE(
876+
JSON_VALUE(
877+
candidate_operation.result_metadata,
878+
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
879+
),
880+
'-',
881+
''
882+
))
883+
ELSE NULL
884+
END
885+
AND parent.bank_id = candidate_operation.bank_id
886+
)
887+
)
888+
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
889+
LIMIT $2
890+
""",
891+
cutoff,
892+
effective_batch_size,
893+
)
894+
if not candidates:
895+
return 0
896+
897+
candidate_ids = [row["operation_id"] for row in candidates]
898+
locked = await conn.fetch(
899+
f"""
900+
SELECT candidate_operation.operation_id
901+
FROM {table} candidate_operation
902+
WHERE candidate_operation.operation_id = ANY($1)
903+
AND candidate_operation.status IN ('completed', 'failed', 'cancelled')
904+
AND candidate_operation.updated_at < $2
905+
AND (
906+
candidate_operation.status = 'cancelled'
907+
OR NOT EXISTS (
908+
SELECT 1
909+
FROM {table} parent
910+
WHERE parent.operation_id = CASE
911+
WHEN REGEXP_LIKE(
912+
JSON_VALUE(
913+
candidate_operation.result_metadata,
914+
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
915+
),
916+
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
917+
)
918+
THEN HEXTORAW(REPLACE(
919+
JSON_VALUE(
920+
candidate_operation.result_metadata,
921+
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
922+
),
923+
'-',
924+
''
925+
))
926+
ELSE NULL
927+
END
928+
AND parent.bank_id = candidate_operation.bank_id
929+
)
930+
)
931+
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
932+
FOR UPDATE OF candidate_operation.operation_id SKIP LOCKED
933+
""",
934+
candidate_ids,
935+
cutoff,
936+
)
937+
if not locked:
938+
return 0
939+
operation_ids = [row["operation_id"] for row in locked]
940+
await conn.execute(
941+
f"""
942+
UPDATE {table} parent
943+
SET status = 'cancelled',
944+
updated_at = now(),
945+
completed_at = COALESCE(parent.completed_at, now()),
946+
error_message = COALESCE(
947+
parent.error_message,
948+
'Cancelled because a child operation was cancelled'
949+
)
950+
WHERE parent.status = 'pending'
951+
AND EXISTS (
952+
SELECT 1
953+
FROM {table} candidate_operation
954+
WHERE candidate_operation.operation_id = ANY($1)
955+
AND candidate_operation.status = 'cancelled'
956+
AND candidate_operation.updated_at < $2
957+
AND candidate_operation.bank_id = parent.bank_id
958+
AND parent.operation_id = CASE
959+
WHEN REGEXP_LIKE(
960+
JSON_VALUE(
961+
candidate_operation.result_metadata,
962+
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
963+
),
964+
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
965+
)
966+
THEN HEXTORAW(REPLACE(
967+
JSON_VALUE(
968+
candidate_operation.result_metadata,
969+
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
970+
),
971+
'-',
972+
''
973+
))
974+
ELSE NULL
975+
END
976+
)
977+
""",
978+
operation_ids,
979+
cutoff,
980+
)
981+
await conn.execute(
982+
f"DELETE FROM {table} WHERE operation_id = ANY($1)",
983+
operation_ids,
984+
)
985+
return len(operation_ids)
986+
834987
async def _claim_consolidation_tasks(
835988
self,
836989
conn,

0 commit comments

Comments
 (0)