deepline 0.3.275 → 0.3.277

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.
@@ -841,7 +841,7 @@ export type RunsLogsResult = {
841
841
  logsTruncated?: boolean;
842
842
  };
843
843
 
844
- /** Read existing events in the authenticated organization/runtime scope, newest first by occurrence time and event ID. See the complete catalog and field contract in `deepline runs logs --help`. */
844
+ /** Search existing events in your organization, newest first. Discover kinds and filters with `deepline runs logs --help`. */
845
845
  export type RunsEventSearchOptions = {
846
846
  /** Inclusive UTC timestamp; defaults to one hour before the request. */
847
847
  since?: string;
@@ -853,9 +853,9 @@ export type RunsEventSearchOptions = {
853
853
  playName?: string;
854
854
  /** Exact dotted event kind, such as `step.failed`. The catalog is discoverable with `runs logs --help`; future stored kinds are accepted too. */
855
855
  kind?: string;
856
- /** Equality joined with `&&`, at most eight predicates and 512 bytes. Fields: orgId, runId, playName, runtimeScope, producerAttempt, kind, status, source, stepId, stepKind, datasetId, phase, provider, operation and scalar `context.<key>`. Missing fields do not match; status is not inferred from kind. No body, message, level, nested payload, range or OR search. HTTP `jsonpath`, CLI `--where`. */
856
+ /** JSONPath equality joined with `&&`, at most eight predicates and 512 bytes. See `runs logs --help` for fields and examples. Missing fields do not match; status is not inferred from kind. HTTP `jsonpath`, CLI `--where`. */
857
857
  jsonpath?: string;
858
- /** Add stored canonical `eventPayload` and, for receipt matches, retained `payload`. At most ten events and 16 MiB; unavailable receipts return null. HTTP `payloads=true`, CLI `--payloads`. Does not execute tools. */
858
+ /** Include available `eventPayload` details and receipt `payload` results; maximum ten events and 16 MiB. HTTP `payloads=true`, CLI `--payloads`. */
859
859
  includePayloads?: boolean;
860
860
  /** Page size: default 100, maximum 200; with payloads, default and maximum ten. */
861
861
  limit?: number;
@@ -865,7 +865,7 @@ export type RunsEventSearchOptions = {
865
865
  runtime?: PlayRuntimeSelection;
866
866
  };
867
867
 
868
- /** One indexed observation; optional indexed fields and payloads depend on what its producer recorded. */
868
+ /** One matching event. Optional fields and details are present only when recorded. */
869
869
  export type RunsEventSearchEntry = {
870
870
  /** Stable event identity; also breaks ties in newest-first pagination. */
871
871
  eventId: string;
@@ -877,15 +877,15 @@ export type RunsEventSearchEntry = {
877
877
  runId: string;
878
878
  /** Recorded Play name. */
879
879
  playName: string;
880
- /** Exact indexed kind; canonical payloads use `type` for this dotted name. */
880
+ /** Exact dotted event kind, such as `step.failed`. */
881
881
  kind: string;
882
882
  /** Recorded log severity, or null; not a JSONPath filter field. */
883
883
  level: string | null;
884
884
  /** Log message preview of at most 2,048 characters, or null. Use text logs/export for the full retained line. */
885
885
  message: string | null;
886
- /** Indexed identity and recorded scalar fields. SDK/API keep this nested; CLI search flattens it onto each event. Provider/operation describe typed activity observations, not every provider call. */
886
+ /** Recorded filter fields. SDK/API keep this nested; the CLI places these fields directly on each event. */
887
887
  searchDoc: Record<string, unknown>;
888
- /** Stored canonical event details, only when payloads are requested and available. Lifecycle/activity/dataset payloads are observations, not complete tool responses or dataset rows. */
888
+ /** Available event details when requested with `includePayloads`; dataset events contain metadata, not dataset rows. */
889
889
  eventPayload?: Record<string, unknown>;
890
890
  /** Retained work result for a receipt match when payloads are requested; null if unavailable. Other events need not carry this field. */
891
891
  payload?: RunsEventPayload | null;
@@ -903,7 +903,7 @@ export type RunsEventSearchResult = {
903
903
  until: string;
904
904
  };
905
905
 
906
- /** Retained work outcome hydrated for a receipt event; not a new execution, billing receipt or guarantee of a raw HTTP response. */
906
+ /** Saved work result returned for a receipt event. Reading it does not execute the work again. */
907
907
  export type RunsEventPayload = {
908
908
  /** Recorded terminal work outcome. */
909
909
  status: 'completed' | 'failed' | 'skipped';
@@ -200,7 +200,7 @@ export const SDK_RELEASE = {
200
200
  // getters keep their established compatibility behavior.
201
201
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
202
202
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
203
- version: '0.3.275',
203
+ version: '0.3.277',
204
204
  updateSummary:
205
205
  'Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.',
206
206
  packageCapabilities: {
@@ -17391,6 +17391,22 @@ function runLedgerOrderSql(alias: string): string {
17391
17391
  return `CASE ${alias}.event_type WHEN ${RUN_EVENT_KIND_SQL_LITERAL.runCreated} THEN 0 WHEN ${RUN_EVENT_KIND_SQL_LITERAL.runStarted} THEN 1 ELSE 2 END, ${alias}.created_at, ${alias}.event_id`;
17392
17392
  }
17393
17393
 
17394
+ /** Prioritize a terminal's causal head without skipping its predecessors. */
17395
+ function runLedgerTerminalPendingSql(
17396
+ alias: string,
17397
+ options?: Pick<PostgresSchedulerOptions, 'schema'>,
17398
+ ): string {
17399
+ return `EXISTS (
17400
+ SELECT 1 FROM ${tableName('outbox', options)} AS terminal_event
17401
+ WHERE terminal_event.event_id = 'run-terminal:' || ${alias}.run_id
17402
+ AND terminal_event.run_id = ${alias}.run_id
17403
+ AND terminal_event.event_type IN (${RUN_EVENT_KIND_SQL_LITERAL.runCompleted}, ${RUN_EVENT_KIND_SQL_LITERAL.runFailed}, ${RUN_EVENT_KIND_SQL_LITERAL.runCancelled})
17404
+ AND terminal_event.delivered_at IS NULL
17405
+ AND terminal_event.failed_at IS NULL
17406
+ AND coalesce(terminal_event.next_attempt_at, terminal_event.created_at) <= now()
17407
+ )`;
17408
+ }
17409
+
17394
17410
  /**
17395
17411
  * Claim a causal prefix, preserving the v33 adapter while the additive batch
17396
17412
  * marker is absent. An enclosing transaction uses a savepoint so the missing
@@ -17463,16 +17479,14 @@ export async function readPendingPostgresSchedulerOutboxEvents(
17463
17479
  -- terminal facts. Project it first so a lifecycle backlog cannot make
17464
17480
  -- an accepted run invisible; per-run predecessor checks above retain
17465
17481
  -- strict ordering within each run.
17466
- -- A terminal head comes next: it is a run's last fact (one per run, so
17467
- -- it cannot starve anything), its predecessors are already delivered
17468
- -- (checked above), and it is what a caller is waiting on. Under a
17469
- -- lifecycle backlog, FIFO left a committed terminal queued behind other
17470
- -- runs' progress for seconds (live-run-observation-e2e: 4.9 s from
17471
- -- commit to first claim while the projector drained continuously).
17482
+ -- A run with a pending terminal comes next, including its causal
17483
+ -- predecessors. Giving priority only to a terminal head leaves a
17484
+ -- finished run's earlier progress behind unrelated progress. The
17485
+ -- predecessor checks still forbid skipping any fact within the run.
17472
17486
  ORDER BY
17473
17487
  CASE
17474
17488
  WHEN candidate.event_type = ${RUN_EVENT_KIND_SQL_LITERAL.runCreated} THEN 0
17475
- WHEN candidate.event_id LIKE 'run-terminal:%' THEN 1
17489
+ WHEN ${runLedgerTerminalPendingSql('candidate', options)} THEN 1
17476
17490
  ELSE 2
17477
17491
  END,
17478
17492
  coalesce(candidate.next_attempt_at, candidate.created_at),
@@ -18892,6 +18906,7 @@ export async function dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope(
18892
18906
  input: RunLedgerOutboxDispatchInput & { waitForRunLock?: boolean },
18893
18907
  options?: Pick<PostgresSchedulerOptions, 'schema'>,
18894
18908
  ): Promise<PostgresSchedulerOutboxProjectionResult> {
18909
+ const dispatchStartedAtMs = Date.now();
18895
18910
  const leaseOwner =
18896
18911
  input.leaseOwner?.trim() || `run-ledger-outbox-dispatch:${randomUUID()}`;
18897
18912
  const events = await withClient(async (client) => {
@@ -18986,16 +19001,16 @@ export async function dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope(
18986
19001
  AND active.lease_owner IS NOT NULL
18987
19002
  AND active.lease_expires_at > now()
18988
19003
  )
18989
- -- admission_first: admission, then a run whose head is its
18990
- -- terminal (its last fact; see
18991
- -- readPendingPostgresSchedulerOutboxEvents), then the FIFO
18992
- -- backlog. oldest_ready: pure age order, the anti-starvation turn.
19004
+ -- admission_first: admission, then the causal head of a run with
19005
+ -- a pending terminal, then FIFO progress. oldest_ready keeps its
19006
+ -- pure age order so continuous admissions/terminals cannot starve
19007
+ -- ordinary progress.
18993
19008
  ORDER BY ${
18994
19009
  input.headPriority === 'oldest_ready'
18995
19010
  ? ''
18996
19011
  : `CASE
18997
19012
  WHEN head.event_type = ${RUN_EVENT_KIND_SQL_LITERAL.runCreated} THEN 0
18998
- WHEN head.event_id LIKE 'run-terminal:%' THEN 1
19013
+ WHEN ${runLedgerTerminalPendingSql('head', options)} THEN 1
18999
19014
  ELSE 2
19000
19015
  END,`
19001
19016
  }
@@ -19035,6 +19050,10 @@ export async function dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope(
19035
19050
  };
19036
19051
  }
19037
19052
 
19053
+ const claimedAtMs = Date.now();
19054
+ let authorityReadyAtMs: number | null = null;
19055
+ let appendFinishedAtMs: number | null = null;
19056
+ let acknowledgedAtMs: number | null = null;
19038
19057
  try {
19039
19058
  const first = events[0]!;
19040
19059
  const tokenAuthority = input.tokenAuthority ?? 'local_mint';
@@ -19091,6 +19110,7 @@ export async function dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope(
19091
19110
  // The caller-owned admission deadline (see `signal` above) wins.
19092
19111
  ...(input.signal ? { signal: input.signal } : {}),
19093
19112
  };
19113
+ authorityReadyAtMs = Date.now();
19094
19114
  const renewDelivery = () =>
19095
19115
  withClient((client) =>
19096
19116
  renewPostgresRunLedgerDelivery(
@@ -19155,6 +19175,7 @@ export async function dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope(
19155
19175
  });
19156
19176
  }
19157
19177
  }
19178
+ appendFinishedAtMs = Date.now();
19158
19179
  const deliveredRows = await withClient((client) =>
19159
19180
  markPostgresSchedulerOutboxDeliveredRows(
19160
19181
  client,
@@ -19162,6 +19183,7 @@ export async function dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope(
19162
19183
  { ...options, leaseOwner },
19163
19184
  ),
19164
19185
  );
19186
+ acknowledgedAtMs = Date.now();
19165
19187
  const markedDelivered = deliveredRows.length;
19166
19188
  if (input.onDelivered && deliveredRows.length > 0) {
19167
19189
  try {
@@ -19253,6 +19275,37 @@ export async function dispatchRunLedgerOutboxToLedgerBatchOnceWithClientScope(
19253
19275
  retrying: settlement.retrying.length,
19254
19276
  retryingEventIds: settlement.retrying,
19255
19277
  };
19278
+ } finally {
19279
+ const settledAtMs = Date.now();
19280
+ const headAgeAtClaimMs = Math.max(
19281
+ 0,
19282
+ claimedAtMs - events[0]!.createdAt.getTime(),
19283
+ );
19284
+ if (
19285
+ settledAtMs - dispatchStartedAtMs >= 1_000 ||
19286
+ headAgeAtClaimMs >= 1_000
19287
+ ) {
19288
+ console.info('[run-ledger-outbox.slow_delivery]', {
19289
+ runId: events[0]!.runId,
19290
+ headEventId: events[0]!.eventId,
19291
+ eventCount: events.length,
19292
+ headAgeAtClaimMs,
19293
+ claimMs: claimedAtMs - dispatchStartedAtMs,
19294
+ authorityMs:
19295
+ authorityReadyAtMs === null ? null : authorityReadyAtMs - claimedAtMs,
19296
+ appendMs:
19297
+ appendFinishedAtMs === null || authorityReadyAtMs === null
19298
+ ? null
19299
+ : appendFinishedAtMs - authorityReadyAtMs,
19300
+ acknowledgeMs:
19301
+ acknowledgedAtMs === null || appendFinishedAtMs === null
19302
+ ? null
19303
+ : acknowledgedAtMs - appendFinishedAtMs,
19304
+ continuationMs:
19305
+ acknowledgedAtMs === null ? null : settledAtMs - acknowledgedAtMs,
19306
+ durationMs: settledAtMs - dispatchStartedAtMs,
19307
+ });
19308
+ }
19256
19309
  }
19257
19310
  }
19258
19311
 
@@ -51,7 +51,7 @@ const RUNTIME_EVENT_LOG_POSITION_EXPRESSION = `jsonb_set(
51
51
  // Migration readiness tracks this projection body independently of scheduler
52
52
  // wire and schema versions. Bump when a changed trigger must be reinstalled.
53
53
  export const RUNTIME_EVENT_SEARCH_OUTBOX_PROJECTION_MARKER =
54
- 'runtime-event-search-log-context-v12-scheduler-wait-logs';
54
+ 'runtime-event-search-log-context-v13-index-first-position';
55
55
  export const RUNTIME_EVENT_CANONICAL_RETENTION_INDEX_READY_MARKER =
56
56
  'runtime-event-canonical-retention-v1';
57
57
  export const RUNTIME_EVENTS_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1018,7 +1018,6 @@ export async function attachRuntimeEventSearchTriggers(
1018
1018
  doc jsonb; line_doc jsonb; line_context jsonb;
1019
1019
  eid text; existing_digest text; digest text; assigned_seq bigint; terminal_progress_identity boolean;
1020
1020
  log_channel text; log_position bigint; matched_event_id text; matched_doc jsonb;
1021
- previous_plan_cache_mode text;
1022
1021
  legacy_position_prefix text; position_event_ids text[];
1023
1022
  log_alias jsonb; position_alias jsonb; envelope_attempt jsonb;
1024
1023
  line_count bigint; fresh_event_ids text[] := ARRAY[]::text[];
@@ -1297,23 +1296,29 @@ export async function attachRuntimeEventSearchTriggers(
1297
1296
  -- A transport may already be aliased onto an event whose
1298
1297
  -- primary key belongs to another channel. Find that alias
1299
1298
  -- independently of content so changed bytes still fail the
1300
- -- immutable-position check below. PL/pgSQL's generic plan
1301
- -- underestimates large runs and scans every row in the run;
1302
- -- plan this lookup with its concrete position so the scoped
1303
- -- GIN index can seek directly to the alias.
1304
- previous_plan_cache_mode := current_setting('plan_cache_mode');
1305
- PERFORM set_config('plan_cache_mode', 'force_custom_plan', true);
1299
+ -- immutable-position check below. Materialize the scoped
1300
+ -- position lookup before applying run filters: otherwise
1301
+ -- the planner can choose the run-history index and inspect
1302
+ -- every old log for each incoming position, even with a
1303
+ -- custom plan. The indexed document already binds scope,
1304
+ -- org, run, attempt and channel position; retain the outer
1305
+ -- identity checks as defense in depth.
1306
+ WITH position_matches AS MATERIALIZED (
1307
+ SELECT event.event_id, event.source_digest, event.search_doc,
1308
+ event.runtime_scope, event.org_id, event.run_id, event.log_seq
1309
+ FROM runtime_observability.events AS event
1310
+ WHERE event.kind = ${RUN_EVENT_KIND_SQL_LITERAL.logAppended}
1311
+ AND event.search_doc ? 'producerAttempt'
1312
+ AND event.search_doc ? 'logPositions'
1313
+ AND (${RUNTIME_EVENT_LOG_POSITION_EXPRESSION.replaceAll('search_doc', 'event.search_doc')}) @> position_alias
1314
+ )
1306
1315
  SELECT event.event_id, event.source_digest, event.search_doc
1307
1316
  INTO matched_event_id, existing_digest, matched_doc
1308
- FROM runtime_observability.events AS event
1317
+ FROM position_matches AS event
1309
1318
  WHERE event.runtime_scope = ${quoteLiteral(scope)} AND event.org_id = run_org
1310
- AND event.run_id = observation.run_id AND event.kind = ${RUN_EVENT_KIND_SQL_LITERAL.logAppended}
1311
- AND event.search_doc ? 'producerAttempt'
1312
- AND event.search_doc ? 'logPositions'
1313
- AND (${RUNTIME_EVENT_LOG_POSITION_EXPRESSION.replaceAll('search_doc', 'event.search_doc')}) @> position_alias
1319
+ AND event.run_id = observation.run_id
1314
1320
  ORDER BY event.log_seq NULLS LAST, event.event_id
1315
1321
  LIMIT 1;
1316
- PERFORM set_config('plan_cache_mode', previous_plan_cache_mode, true);
1317
1322
  END IF;
1318
1323
  IF matched_event_id IS NOT NULL THEN
1319
1324
  IF existing_digest IS DISTINCT FROM digest THEN
@@ -2015,20 +2020,22 @@ type RuntimeEventIndexExpectation = {
2015
2020
  opclasses?: string[];
2016
2021
  };
2017
2022
 
2018
- const RUNTIME_EVENT_QUERY_INDEXES: RuntimeEventIndexExpectation[] = [
2019
- {
2020
- name: RUNTIME_EVENT_LOG_POSITION_INDEX,
2021
- method: 'gin',
2022
- opclasses: ['jsonb_path_ops'],
2023
- keys: [
2024
- "jsonb_set(jsonb_set(jsonb_set(jsonb_set(COALESCE(search_doc -> 'logPositions'::text, '{}'::jsonb), ARRAY['producerAttempt'::text], search_doc -> 'producerAttempt'::text, true), ARRAY['_runtimeScope:'::text || runtime_scope], 'true'::jsonb, true), ARRAY['_orgId:'::text || org_id], 'true'::jsonb, true), ARRAY['_runId:'::text || run_id], 'true'::jsonb, true)",
2025
- ],
2026
- predicate: RUNTIME_EVENT_LOG_POSITION_PREDICATE,
2027
- createSql: `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${RUNTIME_EVENT_LOG_POSITION_INDEX}
2023
+ const RUNTIME_EVENT_LOG_POSITION_INDEX_SPEC: RuntimeEventIndexExpectation = {
2024
+ name: RUNTIME_EVENT_LOG_POSITION_INDEX,
2025
+ method: 'gin',
2026
+ opclasses: ['jsonb_path_ops'],
2027
+ keys: [
2028
+ "jsonb_set(jsonb_set(jsonb_set(jsonb_set(COALESCE(search_doc -> 'logPositions'::text, '{}'::jsonb), ARRAY['producerAttempt'::text], search_doc -> 'producerAttempt'::text, true), ARRAY['_runtimeScope:'::text || runtime_scope], 'true'::jsonb, true), ARRAY['_orgId:'::text || org_id], 'true'::jsonb, true), ARRAY['_runId:'::text || run_id], 'true'::jsonb, true)",
2029
+ ],
2030
+ predicate: RUNTIME_EVENT_LOG_POSITION_PREDICATE,
2031
+ createSql: `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${RUNTIME_EVENT_LOG_POSITION_INDEX}
2028
2032
  ON runtime_observability.events USING gin
2029
2033
  ((${RUNTIME_EVENT_LOG_POSITION_EXPRESSION}) jsonb_path_ops)
2030
2034
  WHERE ${RUNTIME_EVENT_LOG_POSITION_PREDICATE}`,
2031
- },
2035
+ };
2036
+
2037
+ const RUNTIME_EVENT_QUERY_INDEXES: RuntimeEventIndexExpectation[] = [
2038
+ RUNTIME_EVENT_LOG_POSITION_INDEX_SPEC,
2032
2039
  {
2033
2040
  name: RUNTIME_EVENT_LOG_DEDUPE_INDEX,
2034
2041
  keys: [
@@ -2062,92 +2069,126 @@ const RUNTIME_EVENT_QUERY_INDEXES: RuntimeEventIndexExpectation[] = [
2062
2069
  },
2063
2070
  ];
2064
2071
 
2065
- /** Build only operator-owned indexes, concurrently and outside a transaction. */
2066
- export async function prepareRuntimeEventQueryIndexes(
2072
+ async function hasRuntimeEventQueryIndex(
2067
2073
  client: PostgresSchedulerQueryClient,
2068
- ): Promise<void> {
2069
- for (const expected of RUNTIME_EVENT_QUERY_INDEXES) {
2070
- type IndexShape = {
2071
- valid: boolean;
2072
- ready: boolean;
2073
- is_unique: boolean;
2074
- table_matches: boolean;
2075
- key_count: number;
2076
- attribute_count: number;
2077
- method: string;
2078
- keys: string[];
2079
- opclasses: string[];
2080
- predicate: string | null;
2081
- };
2082
- const inspect = () =>
2083
- client.query<IndexShape>(
2084
- `SELECT index_row.indisvalid AS valid,
2085
- index_row.indisready AS ready,
2086
- index_row.indisunique AS is_unique,
2087
- index_row.indrelid = to_regclass('runtime_observability.events') AS table_matches,
2088
- index_row.indnkeyatts AS key_count,
2089
- index_row.indnatts AS attribute_count,
2090
- method.amname AS method,
2091
- ARRAY(
2092
- SELECT pg_get_indexdef(index_row.indexrelid, key_number.position, true)
2093
- FROM generate_series(1, index_row.indnkeyatts) AS key_number(position)
2094
- ORDER BY key_number.position
2095
- ) AS keys,
2096
- (
2097
- SELECT coalesce(json_agg(operator_class.opcname ORDER BY classes.position), '[]'::json)
2098
- FROM unnest(index_row.indclass) WITH ORDINALITY AS classes(opclass_oid, position)
2099
- JOIN pg_opclass AS operator_class ON operator_class.oid = classes.opclass_oid
2100
- ) AS opclasses,
2101
- pg_get_expr(index_row.indpred, index_row.indrelid) AS predicate
2102
- FROM pg_index AS index_row
2103
- JOIN pg_class AS index_class ON index_class.oid = index_row.indexrelid
2104
- JOIN pg_am AS method ON method.oid = index_class.relam
2105
- WHERE index_class.oid = to_regclass($1)`,
2106
- [`runtime_observability.${expected.name}`],
2074
+ expected: RuntimeEventIndexExpectation,
2075
+ ): Promise<boolean> {
2076
+ type IndexShape = {
2077
+ valid: boolean;
2078
+ ready: boolean;
2079
+ is_unique: boolean;
2080
+ table_matches: boolean;
2081
+ key_count: number;
2082
+ attribute_count: number;
2083
+ method: string;
2084
+ keys: string[];
2085
+ opclasses: string[];
2086
+ predicate: string | null;
2087
+ };
2088
+ const inspect = () =>
2089
+ client.query<IndexShape>(
2090
+ `SELECT index_row.indisvalid AS valid,
2091
+ index_row.indisready AS ready,
2092
+ index_row.indisunique AS is_unique,
2093
+ index_row.indrelid = to_regclass('runtime_observability.events') AS table_matches,
2094
+ index_row.indnkeyatts AS key_count,
2095
+ index_row.indnatts AS attribute_count,
2096
+ method.amname AS method,
2097
+ ARRAY(
2098
+ SELECT pg_get_indexdef(index_row.indexrelid, key_number.position, true)
2099
+ FROM generate_series(1, index_row.indnkeyatts) AS key_number(position)
2100
+ ORDER BY key_number.position
2101
+ ) AS keys,
2102
+ (
2103
+ SELECT coalesce(json_agg(operator_class.opcname ORDER BY classes.position), '[]'::json)
2104
+ FROM unnest(index_row.indclass) WITH ORDINALITY AS classes(opclass_oid, position)
2105
+ JOIN pg_opclass AS operator_class ON operator_class.oid = classes.opclass_oid
2106
+ ) AS opclasses,
2107
+ pg_get_expr(index_row.indpred, index_row.indrelid) AS predicate
2108
+ FROM pg_index AS index_row
2109
+ JOIN pg_class AS index_class ON index_class.oid = index_row.indexrelid
2110
+ JOIN pg_am AS method ON method.oid = index_class.relam
2111
+ WHERE index_class.oid = to_regclass($1)`,
2112
+ [`runtime_observability.${expected.name}`],
2113
+ );
2114
+ const assertShape = (shape: IndexShape): void => {
2115
+ if (!shape.valid || !shape.ready) {
2116
+ throw new Error(
2117
+ `Runtime event index ${expected.name} is invalid or incomplete; inspect and repair the failed build explicitly.`,
2107
2118
  );
2108
- const assertShape = (shape: IndexShape): void => {
2109
- if (!shape.valid || !shape.ready) {
2110
- throw new Error(
2111
- `Runtime event index ${expected.name} is invalid or incomplete; inspect and repair the failed build explicitly.`,
2112
- );
2113
- }
2114
- if (
2115
- !shape.table_matches ||
2116
- shape.is_unique ||
2117
- shape.method !== (expected.method ?? 'btree') ||
2118
- (expected.opclasses !== undefined &&
2119
- (shape.opclasses.length !== expected.opclasses.length ||
2120
- shape.opclasses.some(
2121
- (name, index) => name !== expected.opclasses![index],
2122
- ))) ||
2123
- shape.key_count !== expected.keys.length ||
2124
- shape.attribute_count !== expected.keys.length ||
2125
- shape.keys.length !== expected.keys.length ||
2126
- shape.keys.some(
2127
- (key, index) =>
2128
- normalizedRetentionPredicate(key) !==
2129
- normalizedRetentionPredicate(expected.keys[index]!),
2130
- ) ||
2131
- shape.predicate === null ||
2132
- normalizedQueryIndexPredicate(shape.predicate) !==
2133
- normalizedQueryIndexPredicate(expected.predicate)
2134
- ) {
2135
- throw new Error(
2136
- `Runtime event index ${expected.name} differs from its expected table, keys, method, uniqueness or predicate. Actual catalog shape: ${JSON.stringify(shape)}. Inspect and repair it explicitly.`,
2137
- );
2138
- }
2139
- };
2140
-
2141
- const existing = (await inspect()).rows[0];
2142
- if (existing) assertShape(existing);
2143
- await client.query(expected.createSql);
2144
- const built = (await inspect()).rows[0];
2145
- if (!built) {
2119
+ }
2120
+ if (
2121
+ !shape.table_matches ||
2122
+ shape.is_unique ||
2123
+ shape.method !== (expected.method ?? 'btree') ||
2124
+ (expected.opclasses !== undefined &&
2125
+ (shape.opclasses.length !== expected.opclasses.length ||
2126
+ shape.opclasses.some(
2127
+ (name, index) => name !== expected.opclasses![index],
2128
+ ))) ||
2129
+ shape.key_count !== expected.keys.length ||
2130
+ shape.attribute_count !== expected.keys.length ||
2131
+ shape.keys.length !== expected.keys.length ||
2132
+ shape.keys.some(
2133
+ (key, index) =>
2134
+ normalizedRetentionPredicate(key) !==
2135
+ normalizedRetentionPredicate(expected.keys[index]!),
2136
+ ) ||
2137
+ shape.predicate === null ||
2138
+ normalizedQueryIndexPredicate(shape.predicate) !==
2139
+ normalizedQueryIndexPredicate(expected.predicate)
2140
+ ) {
2146
2141
  throw new Error(
2147
- `Runtime event index ${expected.name} was not found after preparation.`,
2142
+ `Runtime event index ${expected.name} differs from its expected table, keys, method, uniqueness or predicate. Actual catalog shape: ${JSON.stringify(shape)}. Inspect and repair it explicitly.`,
2148
2143
  );
2149
2144
  }
2150
- assertShape(built);
2145
+ };
2146
+
2147
+ const existing = (await inspect()).rows[0];
2148
+ if (!existing) return false;
2149
+ assertShape(existing);
2150
+ return true;
2151
+ }
2152
+
2153
+ async function prepareRuntimeEventQueryIndex(
2154
+ client: PostgresSchedulerQueryClient,
2155
+ expected: RuntimeEventIndexExpectation,
2156
+ ): Promise<void> {
2157
+ if (await hasRuntimeEventQueryIndex(client, expected)) return;
2158
+ await client.query(expected.createSql);
2159
+ if (!(await hasRuntimeEventQueryIndex(client, expected))) {
2160
+ throw new Error(
2161
+ `Runtime event index ${expected.name} was not found after preparation.`,
2162
+ );
2163
+ }
2164
+ }
2165
+
2166
+ /** Deployment-only check; does not gate requests or worker admission. */
2167
+ export async function hasRuntimeEventLogPositionIndex(
2168
+ client: PostgresSchedulerQueryClient,
2169
+ ): Promise<boolean> {
2170
+ return hasRuntimeEventQueryIndex(
2171
+ client,
2172
+ RUNTIME_EVENT_LOG_POSITION_INDEX_SPEC,
2173
+ );
2174
+ }
2175
+
2176
+ /** Prepare the existing position index before replacing the projection body. */
2177
+ export async function prepareRuntimeEventLogPositionIndex(
2178
+ client: PostgresSchedulerQueryClient,
2179
+ ): Promise<void> {
2180
+ await prepareRuntimeEventQueryIndex(
2181
+ client,
2182
+ RUNTIME_EVENT_LOG_POSITION_INDEX_SPEC,
2183
+ );
2184
+ }
2185
+
2186
+ /** Build only operator-owned indexes, concurrently and outside a transaction. */
2187
+ export async function prepareRuntimeEventQueryIndexes(
2188
+ client: PostgresSchedulerQueryClient,
2189
+ ): Promise<void> {
2190
+ for (const expected of RUNTIME_EVENT_QUERY_INDEXES) {
2191
+ await prepareRuntimeEventQueryIndex(client, expected);
2151
2192
  }
2152
2193
  }
2153
2194