feat(core): Add the Instance AI durable event-log store and writer (no-changelog) (#33910)

Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Raúl Gómez Morales
2026-07-14 13:12:31 +02:00
committed by GitHub
parent 9c3f7fa44b
commit 05b412cd28
19 changed files with 1618 additions and 2 deletions
+11
View File
@@ -63,6 +63,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.installed_nodes](public.installed_nodes.md) | 4 | | BASE TABLE |
| [public.installed_packages](public.installed_packages.md) | 6 | | BASE TABLE |
| [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) | 8 | | BASE TABLE |
| [public.instance_ai_events](public.instance_ai_events.md) | 7 | | BASE TABLE |
| [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) | 6 | | BASE TABLE |
| [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) | 7 | | BASE TABLE |
| [public.instance_ai_messages](public.instance_ai_messages.md) | 8 | | BASE TABLE |
@@ -227,6 +228,7 @@ erDiagram
"public.insights_raw" }o--|| "public.insights_metadata" : "FOREIGN KEY (#quot;metaId#quot;) REFERENCES insights_metadata(#quot;metaId#quot;) ON DELETE CASCADE"
"public.installed_nodes" }o--|| "public.installed_packages" : "FOREIGN KEY (package) REFERENCES installed_packages(#quot;packageName#quot;) ON UPDATE CASCADE ON DELETE CASCADE"
"public.instance_ai_checkpoints" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_events" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_iteration_logs" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_mcp_registry_connections" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_mcp_registry_connections" }o--|| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE CASCADE"
@@ -824,6 +826,15 @@ erDiagram
uuid threadId FK
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_events" {
timestamp_3__with_time_zone createdAt
text payload
varchar_64_ runId
integer seq
uuid threadId FK
varchar_64_ type
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_iteration_logs" {
timestamp_3__with_time_zone createdAt
text entry
@@ -0,0 +1,65 @@
# public.instance_ai_events
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| payload | text | | false | | | JSON of the canonical InstanceAiEvent |
| runId | varchar(64) | | false | | | Run that emitted the event — opaque ID from the agent runtime |
| seq | integer | | false | | | Per-thread monotonic sequence — the SSE replay cursor |
| threadId | uuid | | false | | [public.instance_ai_threads](public.instance_ai_threads.md) | |
| type | varchar(64) | | false | | | Event type discriminator, duplicated out of the payload |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| FK_35909c5576a4a6c1d6a6fb71caa | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES instance_ai_threads(id) ON DELETE CASCADE |
| PK_12489cd6197feeac2089acc7ef6 | PRIMARY KEY | PRIMARY KEY ("threadId", seq) |
| instance_ai_events_createdAt_not_null | n | NOT NULL "createdAt" |
| instance_ai_events_payload_not_null | n | NOT NULL payload |
| instance_ai_events_runId_not_null | n | NOT NULL "runId" |
| instance_ai_events_seq_not_null | n | NOT NULL seq |
| instance_ai_events_threadId_not_null | n | NOT NULL "threadId" |
| instance_ai_events_type_not_null | n | NOT NULL type |
| instance_ai_events_updatedAt_not_null | n | NOT NULL "updatedAt" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_32cdd799675715fb1d2a8683e9 | CREATE INDEX "IDX_32cdd799675715fb1d2a8683e9" ON public.instance_ai_events USING btree ("threadId", "runId") |
| PK_12489cd6197feeac2089acc7ef6 | CREATE UNIQUE INDEX "PK_12489cd6197feeac2089acc7ef6" ON public.instance_ai_events USING btree ("threadId", seq) |
## Relations
```mermaid
erDiagram
"public.instance_ai_events" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_events" {
timestamp_3__with_time_zone createdAt
text payload
varchar_64_ runId
integer seq
uuid threadId FK
varchar_64_ type
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_threads" {
timestamp_3__with_time_zone createdAt
uuid id
json metadata
varchar_36_ projectId FK
varchar_255_ resourceId
text title
timestamp_3__with_time_zone updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
@@ -5,7 +5,7 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | | |
| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_events](public.instance_ai_events.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | | |
| metadata | json | | true | | | |
| projectId | varchar(36) | | false | | [public.project](public.project.md) | Project this thread is scoped to |
| resourceId | varchar(255) | | false | | | |
@@ -40,6 +40,7 @@ erDiagram
"public.ai_builder_temporary_workflow" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_checkpoints" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_events" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_iteration_logs" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_messages" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_observation_cursors" |o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;observationScopeId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
@@ -76,6 +77,15 @@ erDiagram
uuid threadId FK
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_events" {
timestamp_3__with_time_zone createdAt
text payload
varchar_64_ runId
integer seq
uuid threadId FK
varchar_64_ type
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_iteration_logs" {
timestamp_3__with_time_zone createdAt
text entry
+11
View File
@@ -63,6 +63,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [installed_nodes](installed_nodes.md) | 4 | | table |
| [installed_packages](installed_packages.md) | 6 | | table |
| [instance_ai_checkpoints](instance_ai_checkpoints.md) | 8 | | table |
| [instance_ai_events](instance_ai_events.md) | 7 | | table |
| [instance_ai_iteration_logs](instance_ai_iteration_logs.md) | 6 | | table |
| [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) | 7 | | table |
| [instance_ai_messages](instance_ai_messages.md) | 8 | | table |
@@ -210,6 +211,7 @@ erDiagram
"insights_raw" }o--|| "insights_metadata" : "FOREIGN KEY (metaId) REFERENCES insights_metadata (metaId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"installed_nodes" }o--|| "installed_packages" : "FOREIGN KEY (package) REFERENCES installed_packages (packageName) ON UPDATE CASCADE ON DELETE CASCADE MATCH NONE"
"instance_ai_checkpoints" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_events" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_iteration_logs" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_mcp_registry_connections" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_mcp_registry_connections" }o--|| "mcp_registry_server" : "FOREIGN KEY (serverSlug) REFERENCES mcp_registry_server (slug) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -811,6 +813,15 @@ erDiagram
varchar threadId FK
datetime_3_ updatedAt
}
"instance_ai_events" {
datetime_3_ createdAt
TEXT payload
varchar_64_ runId
INTEGER seq PK
varchar threadId PK
varchar_64_ type
datetime_3_ updatedAt
}
"instance_ai_iteration_logs" {
datetime_3_ createdAt
TEXT entry
@@ -0,0 +1,71 @@
# instance_ai_events
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "instance_ai_events" ("threadId" varchar NOT NULL, "seq" integer NOT NULL, "runId" varchar(64) NOT NULL, "type" varchar(64) NOT NULL, "payload" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_35909c5576a4a6c1d6a6fb71caa" FOREIGN KEY ("threadId") REFERENCES "instance_ai_threads" ("id") ON DELETE CASCADE, PRIMARY KEY ("threadId", "seq"))
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| payload | TEXT | | false | | | |
| runId | varchar(64) | | false | | | |
| seq | INTEGER | | false | | | |
| threadId | varchar | | false | | [instance_ai_threads](instance_ai_threads.md) | |
| type | varchar(64) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| seq | PRIMARY KEY | PRIMARY KEY (seq) |
| sqlite_autoindex_instance_ai_events_1 | PRIMARY KEY | PRIMARY KEY (threadId, seq) |
| threadId | PRIMARY KEY | PRIMARY KEY (threadId) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_32cdd799675715fb1d2a8683e9 | CREATE INDEX "IDX_32cdd799675715fb1d2a8683e9" ON "instance_ai_events" ("threadId", "runId") |
| sqlite_autoindex_instance_ai_events_1 | PRIMARY KEY (threadId, seq) |
## Relations
```mermaid
erDiagram
"instance_ai_events" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_events" {
datetime_3_ createdAt
TEXT payload
varchar_64_ runId
INTEGER seq PK
varchar threadId PK
varchar_64_ type
datetime_3_ updatedAt
}
"instance_ai_threads" {
datetime_3_ createdAt
varchar id PK
TEXT metadata
varchar_36_ projectId FK
varchar_255_ resourceId
TEXT title
datetime_3_ updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
@@ -16,7 +16,7 @@ CREATE TABLE "instance_ai_threads" ("id" varchar PRIMARY KEY NOT NULL, "resource
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_run_snapshots](instance_ai_run_snapshots.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) | | |
| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_events](instance_ai_events.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_run_snapshots](instance_ai_run_snapshots.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) | | |
| metadata | TEXT | | true | | | |
| projectId | varchar(36) | | false | | [project](project.md) | |
| resourceId | varchar(255) | | false | | | |
@@ -46,6 +46,7 @@ erDiagram
"ai_builder_temporary_workflow" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_checkpoints" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_events" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_iteration_logs" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_messages" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_observation_cursors" |o--|| "instance_ai_threads" : "FOREIGN KEY (observationScopeId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -82,6 +83,15 @@ erDiagram
varchar threadId FK
datetime_3_ updatedAt
}
"instance_ai_events" {
datetime_3_ createdAt
TEXT payload
varchar_64_ runId
INTEGER seq PK
varchar threadId PK
varchar_64_ type
datetime_3_ updatedAt
}
"instance_ai_iteration_logs" {
datetime_3_ createdAt
TEXT entry
@@ -0,0 +1,36 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = 'instance_ai_events';
/**
* Append-only durable event log for Instance AI — the source of truth for
* rendering and SSE replay. Rows are immutable; `seq` is the per-thread
* monotonic replay cursor (assigned in the writer's per-thread drain).
*/
export class CreateInstanceAiEventsTable1784000000046 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table)
.withColumns(
column('threadId').uuid.primary,
column('seq').int.primary.comment('Per-thread monotonic sequence — the SSE replay cursor'),
column('runId')
.varchar(64)
.notNull.comment('Run that emitted the event — opaque ID from the agent runtime'),
column('type')
.varchar(64)
.notNull.comment('Event type discriminator, duplicated out of the payload'),
column('payload').text.notNull.comment('JSON of the canonical InstanceAiEvent'),
)
// Run-scoped reads (agent-tree derivation) filter by (threadId, runId).
.withIndexOn(['threadId', 'runId'])
.withForeignKey('threadId', {
tableName: 'instance_ai_threads',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(table);
}
}
@@ -219,6 +219,7 @@ import { AddUsedPrivateCredentialsToExecutionEntity1784000000041 } from '../comm
import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables';
import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials';
import { AddRecurringCronScheduleKind1784000000045 } from '../common/1784000000045-AddRecurringCronScheduleKind';
import { CreateInstanceAiEventsTable1784000000046 } from '../common/1784000000046-CreateInstanceAiEventsTable';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@@ -443,4 +444,5 @@ export const postgresMigrations: Migration[] = [
CreateWorkflowStatisticsDeltaTable1784000000043,
AddPartialIndexForGlobalCredentials1784000000044,
AddRecurringCronScheduleKind1784000000045,
CreateInstanceAiEventsTable1784000000046,
];
@@ -211,6 +211,7 @@ import { CreateWorkflowPublicationTriggerStatusTable1784000000040 } from '../com
import { AddUsedPrivateCredentialsToExecutionEntity1784000000041 } from '../common/1784000000041-AddUsedPrivateCredentialsToExecutionEntity';
import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables';
import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials';
import { CreateInstanceAiEventsTable1784000000046 } from '../common/1784000000046-CreateInstanceAiEventsTable';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@@ -425,6 +426,7 @@ const sqliteMigrations: Migration[] = [
CreateSchedulerTables1784000000042,
AddPartialIndexForGlobalCredentials1784000000044,
AddRecurringCronScheduleKind1784000000045,
CreateInstanceAiEventsTable1784000000046,
];
export { sqliteMigrations };
@@ -1,4 +1,30 @@
export type InstanceAiEventMap = {
/** One durable-log batch persisted by the writer's per-thread drain. */
'instance-ai-durable-log-drained': {
rows: number;
bytes: number;
};
/** publish() enqueue to batch persisted, per published event. */
'instance-ai-durable-log-queue-latency': {
ms: number;
};
/** (threadId, seq) append collision, retried after a reseed (multi-main). */
'instance-ai-durable-log-append-conflict': {
attempt: number;
};
/** A batch dropped after exhausting append retries. */
'instance-ai-durable-log-append-failure': {
events: number;
};
/** An SSE reconnect served a replay from the durable log. */
'instance-ai-durable-log-replayed': {
events: number;
cursorAgeEvents: number;
};
/** History rendered from the message-derived fallback ladder instead of a renderable snapshot tree. */
'instance-ai-parser-fallback': {
count: number;
};
'instance-ai-run-finished': {
/** 'suspended' is a non-terminal HITL segment: usage/tool counts only; the terminal event counts the run. */
status: 'completed' | 'cancelled' | 'error' | 'suspended';
@@ -74,6 +74,77 @@ export class PrometheusInstanceAiMetricsService implements PrometheusMetricsColl
},
});
// Durable event log (RFC: instance-ai durable event log). All series are
// flat when N8N_INSTANCE_AI_DURABLE_LOG is off.
const durableLogRowsTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_durable_log_rows_total`,
help: 'Durable Instance AI event rows appended (structural facts + coalesced blocks).',
});
durableLogRowsTotal.inc(0);
const durableLogBytesTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_durable_log_bytes_total`,
help: 'Serialized payload bytes appended to the durable Instance AI event log.',
});
durableLogBytesTotal.inc(0);
const durableLogQueueLatency = new promClient.Histogram({
name: `${this.config.prefix}instance_ai_durable_log_queue_latency_seconds`,
help: 'Time from event publish to durable batch persistence, per event.',
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
});
const durableLogAppendConflictsTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_durable_log_append_conflicts_total`,
help: 'Retried (threadId, seq) append collisions between concurrent writers.',
});
durableLogAppendConflictsTotal.inc(0);
const durableLogAppendFailuresTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_durable_log_append_failures_total`,
help: 'Durable-log batches dropped after exhausting append retries.',
});
durableLogAppendFailuresTotal.inc(0);
const durableLogReplaysTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_durable_log_replays_total`,
help: 'SSE reconnects that served a replay from the durable event log.',
});
durableLogReplaysTotal.inc(0);
const durableLogReplayCursorAge = new promClient.Histogram({
name: `${this.config.prefix}instance_ai_durable_log_replay_cursor_age_events`,
help: 'How many events behind the log head a reconnecting cursor was.',
buckets: [1, 5, 25, 100, 500],
});
const parserFallbacksTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_parser_fallbacks_total`,
help: 'History messages rendered from the message-derived fallback ladder instead of a renderable snapshot tree.',
});
parserFallbacksTotal.inc(0);
this.eventService.on('instance-ai-durable-log-drained', ({ rows, bytes }) => {
durableLogRowsTotal.inc(rows);
durableLogBytesTotal.inc(bytes);
});
this.eventService.on('instance-ai-durable-log-queue-latency', ({ ms }) => {
durableLogQueueLatency.observe(ms / 1000);
});
this.eventService.on('instance-ai-durable-log-append-conflict', () => {
durableLogAppendConflictsTotal.inc(1);
});
this.eventService.on('instance-ai-durable-log-append-failure', () => {
durableLogAppendFailuresTotal.inc(1);
});
this.eventService.on('instance-ai-durable-log-replayed', ({ cursorAgeEvents }) => {
durableLogReplaysTotal.inc(1);
durableLogReplayCursorAge.observe(cursorAgeEvents);
});
this.eventService.on('instance-ai-parser-fallback', ({ count }) => {
parserFallbacksTotal.inc(count);
});
this.eventService.on(
'instance-ai-run-finished',
({ status, durationMs, model, toolCalls, toolErrors, usage }) => {
@@ -9,6 +9,7 @@ export { InstanceAiObservationCursor } from './instance-ai-observation-cursor.en
export { InstanceAiObservationLock } from './instance-ai-observation-lock.entity';
export { InstanceAiMcpRegistryConnection } from './instance-ai-mcp-registry-connection.entity';
export { InstanceAiThreadGrant } from './instance-ai-thread-grant.entity';
export { InstanceAiEventLogEntry } from './instance-ai-event-log-entry.entity';
export type {
InstanceAiObservationMarker,
InstanceAiObservationStatus,
@@ -0,0 +1,42 @@
import { WithTimestamps } from '@n8n/db';
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { InstanceAiThread } from './instance-ai-thread.entity';
/**
* Append-only log of durable Instance AI events — the source of truth for
* rendering and SSE replay (see RFC: instance-ai durable event log).
*
* Rows are immutable: state transitions are new facts appended later (e.g. a
* `run-finish` supersedes in-flight tool calls at fold time), never UPDATEs.
* Token deltas are NOT stored — completed text/reasoning blocks are coalesced
* into one row at the next structural fact. `seq` is the per-thread monotonic
* SSE replay cursor; it survives restarts because it is derived from this
* table (MAX(seq)), unlike the previous in-memory counter.
*/
@Entity({ name: 'instance_ai_events' })
@Index(['threadId', 'runId'])
export class InstanceAiEventLogEntry extends WithTimestamps {
@ManyToOne(() => InstanceAiThread, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'threadId' })
thread: InstanceAiThread;
@PrimaryColumn({ type: 'uuid' })
threadId: string;
/** Per-thread monotonic sequence — the SSE replay cursor. */
@PrimaryColumn({ type: 'int' })
seq: number;
/** Indexed with threadId for run-scoped reads (agent-tree derivation, run summaries). */
@Column({ type: 'varchar', length: 64 })
runId: string;
/** Event type discriminator, duplicated out of the payload for cheap filtering. */
@Column({ type: 'varchar', length: 64 })
type: string;
/** JSON.stringify of the canonical `InstanceAiEvent` (already redacted upstream). */
@Column({ type: 'text' })
payload: string;
}
@@ -0,0 +1,465 @@
import type { Logger } from '@n8n/backend-common';
import type { InstanceAiEvent } from '@n8n/api-types';
import { QueryFailedError } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import type { EventService } from '@/events/event.service';
import { DurableEventLog, type DrainedEvent } from '../durable-event-log';
import { DurableLogMetrics } from '../durable-log-metrics';
import type { InstanceAiEventLogRepository } from '../../repositories/instance-ai-event-log.repository';
const THREAD = 'thread-1';
const RUN = 'run-1';
const AGENT = 'orchestrator:run-1';
/** In-memory stand-in for the repository, with a conflict-injection knob. */
class FakeRepo {
rows: Array<{ seq: number; event: InstanceAiEvent }> = [];
/** Simulate a sibling main winning seq ranges: fail the next N appends. */
failNextAppends = 0;
/** When an append fails, advance the store as if the sibling wrote rows. */
siblingRowsPerConflict = 0;
/** Fail the next N appends with a NON-constraint error (e.g. connectivity). */
failNextAppendsTransient = 0;
/** Commit the rows, then fail the response (a lost ack after COMMIT). */
commitThenFailNextAppends = 0;
/** Fail the next N maxSeq reads (seq seeding hits a transient DB error). */
failNextMaxSeq = 0;
/** Hold the next append until released — models an in-flight DB round trip. */
gateNextAppend: Promise<void> | undefined;
async maxSeq(_threadId: string): Promise<number> {
if (this.failNextMaxSeq > 0) {
this.failNextMaxSeq--;
throw new Error('connect ETIMEDOUT');
}
return this.rows.length ? this.rows[this.rows.length - 1].seq : 0;
}
async payloadAt(_threadId: string, seq: number): Promise<string | null> {
const row = this.rows.find((r) => r.seq === seq);
return row ? JSON.stringify(row.event) : null;
}
async appendBatch(_threadId: string, firstSeq: number, events: InstanceAiEvent[]) {
if (this.gateNextAppend) {
const gate = this.gateNextAppend;
this.gateNextAppend = undefined;
await gate;
}
if (this.failNextAppendsTransient > 0) {
this.failNextAppendsTransient--;
throw new Error('connect ETIMEDOUT');
}
if (this.commitThenFailNextAppends > 0) {
this.commitThenFailNextAppends--;
events.forEach((event, i) => this.rows.push({ seq: firstSeq + i, event }));
throw new Error('read ECONNRESET');
}
const conflict =
this.failNextAppends > 0 ||
this.rows.some((r) => r.seq >= firstSeq && r.seq < firstSeq + events.length);
if (conflict) {
if (this.failNextAppends > 0) {
this.failNextAppends--;
for (let i = 0; i < this.siblingRowsPerConflict; i++) {
const seq = (await this.maxSeq(_threadId)) + 1;
this.rows.push({
seq,
event: {
type: 'status',
runId: 'run-sibling',
agentId: 'a',
payload: { message: 'x' },
},
});
}
}
// Same shape a real (threadId, seq) PK collision produces, so the
// writer's isUniqueConstraintError classification is exercised.
throw new QueryFailedError(
'INSERT INTO instance_ai_events',
[],
Object.assign(new Error('SQLITE_CONSTRAINT: UNIQUE constraint failed: (threadId, seq)'), {
code: 'SQLITE_CONSTRAINT',
}),
);
}
let bytes = 0;
events.forEach((event, i) => {
bytes += Buffer.byteLength(JSON.stringify(event), 'utf8');
this.rows.push({ seq: firstSeq + i, event });
});
return bytes;
}
async getAfter(_threadId: string, afterSeq: number) {
return this.rows.filter((r) => r.seq > afterSeq).map((r) => ({ id: r.seq, event: r.event }));
}
async getForRuns(_threadId: string, runIds: string[]) {
const set = new Set(runIds);
return this.rows.filter((r) => set.has(r.event.runId)).map((r) => r.event);
}
}
function buildLog(repo: FakeRepo) {
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const metrics = new DurableLogMetrics(mock<EventService>());
const log = new DurableEventLog(logger, repo as unknown as InstanceAiEventLogRepository, metrics);
return { log, metrics };
}
function textDelta(text: string, responseId = 'msg-1', agentId = AGENT): InstanceAiEvent {
return { type: 'text-delta', runId: RUN, agentId, responseId, payload: { text } };
}
function reasoningDelta(text: string, responseId = 'msg-1', agentId = AGENT): InstanceAiEvent {
return { type: 'reasoning-delta', runId: RUN, agentId, responseId, payload: { text } };
}
function toolCall(toolCallId: string, agentId = AGENT): InstanceAiEvent {
return {
type: 'tool-call',
runId: RUN,
agentId,
payload: { toolCallId, toolName: 'search-workflows', args: {} },
};
}
function runFinish(): InstanceAiEvent {
return { type: 'run-finish', runId: RUN, agentId: AGENT, payload: { status: 'completed' } };
}
/** Publish events through the drain and await it, collecting emissions. */
async function publishAll(
log: DurableEventLog,
events: InstanceAiEvent[],
): Promise<DrainedEvent[]> {
const emitted: DrainedEvent[] = [];
for (const event of events) {
log.publish(THREAD, event, (drained) => emitted.push(drained));
}
await log.flush(THREAD);
return emitted;
}
describe('DurableEventLog', () => {
it('coalesces a segment into one text-block flushed immediately before the next structural fact', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
await publishAll(log, [textDelta('AAA'), textDelta('BBB'), toolCall('tc-1')]);
const persisted = repo.rows.map((r) => r.event.type);
expect(persisted).toEqual(['text-block', 'tool-call']);
const block = repo.rows[0].event;
expect(block.type === 'text-block' && block.payload.text).toBe('AAABBB');
expect(block.responseId).toBe('msg-1');
});
it('flushes reasoning and text of one segment as separate blocks, reasoning first', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
await publishAll(log, [
reasoningDelta('think '),
reasoningDelta('hard'),
textDelta('answer'),
toolCall('tc-1'),
]);
expect(repo.rows.map((r) => r.event.type)).toEqual([
'reasoning-block',
'text-block',
'tool-call',
]);
expect(repo.rows[0].event.payload).toEqual({ text: 'think hard' });
expect(repo.rows[1].event.payload).toEqual({ text: 'answer' });
});
it('rolls the open segment into a block when the responseId changes (blocks stay 1:1 with segments)', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
await publishAll(log, [
textDelta('first', 'msg-1'),
textDelta(' segment', 'msg-1'),
textDelta('second segment', 'msg-2'),
runFinish(),
]);
const blocks = repo.rows.filter((r) => r.event.type === 'text-block');
expect(blocks).toHaveLength(2);
expect(blocks[0].event.payload).toEqual({ text: 'first segment' });
expect(blocks[0].event.responseId).toBe('msg-1');
expect(blocks[1].event.payload).toEqual({ text: 'second segment' });
expect(blocks[1].event.responseId).toBe('msg-2');
});
it('run-finish flushes the open blocks of every agent in the run', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
const subAgent = 'sub:run-1:builder';
await publishAll(log, [
textDelta('orchestrator text', 'msg-1', AGENT),
textDelta('sub-agent text', 'msg-s', subAgent),
runFinish(),
]);
const blocks = repo.rows.filter((r) => r.event.type === 'text-block');
expect(blocks.map((b) => b.event.agentId).sort()).toEqual([AGENT, subAgent].sort());
expect(repo.rows.at(-1)?.event.type).toBe('run-finish');
});
it('live-emits ephemeral events without ids and structural facts with contiguous seqs', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
const emitted = await publishAll(log, [
textDelta('AAA'),
toolCall('tc-1'),
{ type: 'status', runId: RUN, agentId: AGENT, payload: { message: 'working' } },
runFinish(),
]);
// The live stream carries every published event, in order.
const live = emitted.filter((e) => e.live);
expect(live.map((e) => e.event.type)).toEqual([
'text-delta',
'tool-call',
'status',
'run-finish',
]);
// Deltas and status carry no id; structural facts carry the DB seq.
expect(live[0].id).toBeUndefined();
expect(live[2].id).toBeUndefined();
expect(live[1].id).toBeDefined();
expect(live[3].id).toBeDefined();
// Persisted seqs are contiguous from 1.
expect(repo.rows.map((r) => r.seq)).toEqual([1, 2, 3]);
});
it('continues the seq across a restart (fresh instance seeds from the DB)', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
await publishAll(log, [toolCall('tc-1'), toolCall('tc-2')]);
expect(repo.rows.map((r) => r.seq)).toEqual([1, 2]);
// Restart: a new instance over the same repo.
const { log: log2 } = buildLog(repo);
expect(await log2.getNextEventId(THREAD)).toBe(3);
await publishAll(log2, [toolCall('tc-3')]);
expect(repo.rows.map((r) => r.seq)).toEqual([1, 2, 3]);
});
it('retries an append conflict with re-seeded seqs and counts it', async () => {
const repo = new FakeRepo();
repo.failNextAppends = 1;
repo.siblingRowsPerConflict = 2; // the sibling that won wrote 2 rows
const { log, metrics } = buildLog(repo);
const emitted = await publishAll(log, [toolCall('tc-1')]);
expect(metrics.drain.appendConflicts).toBe(1);
expect(metrics.drain.appendFailures).toBe(0);
// Our fact landed after the sibling's rows, with the re-seeded seq.
const ours = repo.rows.find((r) => r.event.type === 'tool-call');
expect(ours?.seq).toBe(3);
expect(emitted.find((e) => e.live)?.id).toBe(3);
});
it('retries a transient append failure without counting it as a conflict', async () => {
const repo = new FakeRepo();
repo.failNextAppendsTransient = 1; // e.g. a connectivity blip, not a PK collision
const { log, metrics } = buildLog(repo);
const emitted = await publishAll(log, [toolCall('tc-1')]);
expect(metrics.drain.appendConflicts).toBe(0);
expect(metrics.drain.appendFailures).toBe(0);
expect(repo.rows.find((r) => r.event.type === 'tool-call')?.seq).toBe(1);
expect(emitted.find((e) => e.live)?.id).toBe(1);
});
it('does not duplicate a batch whose append committed but lost its response', async () => {
const repo = new FakeRepo();
repo.commitThenFailNextAppends = 1; // COMMIT succeeded, the ack was lost
const { log, metrics } = buildLog(repo);
const emitted = await publishAll(log, [toolCall('tc-1')]);
// Exactly one row: the retry detected the committed batch instead of
// re-appending it under fresh seqs.
expect(repo.rows.map((r) => [r.seq, r.event.type])).toEqual([[1, 'tool-call']]);
expect(metrics.drain.appendConflicts).toBe(0);
expect(metrics.drain.appendFailures).toBe(0);
expect(metrics.drain.rowsWritten).toBe(1);
expect(emitted.find((e) => e.live)?.id).toBe(1);
});
it('retries when seeding the sequence fails instead of rejecting the drain', async () => {
const repo = new FakeRepo();
repo.failNextMaxSeq = 1; // the currentSeq() seed read hits a transient error
const { log, metrics } = buildLog(repo);
const emitted = await publishAll(log, [toolCall('tc-1')]);
expect(metrics.drain.appendConflicts).toBe(0);
expect(repo.rows.map((r) => r.seq)).toEqual([1]);
expect(emitted.find((e) => e.live)?.id).toBe(1);
});
it('serializes flush() through the drain so a concurrent fact cannot outrun its block', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
const emitted: DrainedEvent[] = [];
const collect = (drained: DrainedEvent) => emitted.push(drained);
log.publish(THREAD, textDelta('streamed tail'), collect);
// A flush (idle timer / shutdown) and a structural fact race: the flush
// marker was queued first, so the block must persist before the fact.
const flushed = log.flush(THREAD);
log.publish(THREAD, toolCall('tc-1'), collect);
await flushed;
await log.flush(THREAD);
expect(repo.rows.map((r) => [r.seq, r.event.type])).toEqual([
[1, 'text-block'],
[2, 'tool-call'],
]);
});
it('clearThread during an in-flight append aborts the batch instead of retrying it', async () => {
const repo = new FakeRepo();
let releaseAppend!: () => void;
repo.gateNextAppend = new Promise((resolve) => (releaseAppend = resolve));
repo.failNextAppendsTransient = 1; // the gated attempt fails once released
const { log, metrics } = buildLog(repo);
const emitted: DrainedEvent[] = [];
log.publish(THREAD, toolCall('tc-old'), (drained) => emitted.push(drained));
// The drain is now awaiting the DB. The thread gets cleared meanwhile
// (deletion / E2E reset); the resumed attempt must stop, not retry into
// the id's next lifecycle.
log.clearThread(THREAD);
releaseAppend();
await log.flush(THREAD);
expect(repo.rows).toEqual([]);
expect(emitted).toEqual([]);
expect(metrics.drain.appendConflicts).toBe(0);
// Not a durability incident: the batch was dropped because its thread is gone.
expect(metrics.drain.appendFailures).toBe(0);
});
it('a recreated thread id does not receive rows from the previous lifecycle', async () => {
const repo = new FakeRepo();
let releaseAppend!: () => void;
repo.gateNextAppend = new Promise((resolve) => (releaseAppend = resolve));
repo.failNextAppendsTransient = 1;
const { log } = buildLog(repo);
const emitted: DrainedEvent[] = [];
const collect = (drained: DrainedEvent) => emitted.push(drained);
log.publish(THREAD, toolCall('tc-old'), collect);
// Same id, new lifecycle, while the old append is still in flight.
log.clearThread(THREAD);
log.publish(THREAD, toolCall('tc-new'), collect);
releaseAppend();
await log.flush(THREAD);
// Only the new lifecycle's fact exists, from seq 1.
const persisted = repo.rows.map((r) => [
r.seq,
r.event.type === 'tool-call' ? r.event.payload.toolCallId : r.event.type,
]);
expect(persisted).toEqual([[1, 'tc-new']]);
expect(emitted.filter((e) => e.live).map((e) => e.id)).toEqual([1]);
});
it('getNextEventId reflects rows appended by another writer', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
await publishAll(log, [toolCall('tc-1'), toolCall('tc-2')]);
// A sibling main appends directly to the shared table.
repo.rows.push({
seq: 3,
event: { type: 'status', runId: 'run-sibling', agentId: 'a', payload: { message: 'x' } },
});
// The cursor authority is the DB, not this main's last-append cache.
expect(await log.getNextEventId(THREAD)).toBe(4);
});
it('drops the batch after exhausting retries but still delivers live', async () => {
const repo = new FakeRepo();
repo.failNextAppends = 99; // never succeeds
const { log, metrics } = buildLog(repo);
const emitted = await publishAll(log, [toolCall('tc-1')]);
expect(metrics.drain.appendFailures).toBe(1);
expect(repo.rows.filter((r) => r.event.type === 'tool-call')).toHaveLength(0);
const live = emitted.filter((e) => e.live);
expect(live).toHaveLength(1);
expect(live[0].id).toBeUndefined(); // nothing durable to point a cursor at
});
it('flush() persists still-open buffers as blocks (shutdown path)', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
// Open segment with no structural fact after it: only flush() closes it.
await publishAll(log, [textDelta('tail of a streamed answer')]);
const blocks = repo.rows.filter((r) => r.event.type === 'text-block');
expect(blocks).toHaveLength(1);
expect(blocks[0].event.payload).toEqual({ text: 'tail of a streamed answer' });
});
it('clearThread drops the stale seq cache so a reused thread reseeds from the DB', async () => {
const repo = new FakeRepo();
const { log } = buildLog(repo);
await publishAll(log, [toolCall('tc-1'), toolCall('tc-2')]);
expect(repo.rows.map((r) => r.seq)).toEqual([1, 2]);
// Thread deleted: rows cascade away, the log's per-thread state clears.
repo.rows = [];
log.clearThread(THREAD);
// A later publish for the (re)created thread starts clean at seq 1,
// with no conflict retries against the stale cached counter.
await publishAll(log, [toolCall('tc-3')]);
expect(repo.rows.map((r) => r.seq)).toEqual([1]);
});
it('idle flush persists trailing deltas that no structural fact ever follows', async () => {
// e.g. a terminal-outcome line published after run-finish, or a liveness
// timeout notice: without the idle flush these would never reach the log
// and disappear on reload.
const repo = new FakeRepo();
const { log } = buildLog(repo);
log.idleFlushMs = 25;
const emitted = [];
log.publish(THREAD, textDelta('The background workflow-builder task was cancelled.'), (d) =>
emitted.push(d),
);
await new Promise((resolve) => setTimeout(resolve, 120));
const blocks = repo.rows.filter((r) => r.event.type === 'text-block');
expect(blocks).toHaveLength(1);
expect(blocks[0].event.payload).toEqual({
text: 'The background workflow-builder task was cancelled.',
});
});
});
@@ -0,0 +1,618 @@
import { INSTANCE_AI_EPHEMERAL_EVENT_TYPES, type InstanceAiEvent } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { isUniqueConstraintError } from '@n8n/db';
import { Service } from '@n8n/di';
import type { StoredEvent } from '@n8n/instance-ai';
import { DurableLogMetrics } from './durable-log-metrics';
import { InstanceAiEventLogRepository } from '../repositories/instance-ai-event-log.repository';
/**
* Rule: streaming granularity ≠ persistence granularity. Deltas are transport;
* steps are state (see RFC: instance-ai durable event log).
*
* Every published event is exactly one of:
* - EPHEMERAL — live-emitted with NO seq (SSE frame without `id:`, so the
* browser replay cursor never points at it); never persisted. Text/reasoning
* deltas are additionally buffered for coalescing.
* - COALESCED — the buffered deltas of a completed block, flushed as ONE
* persisted row immediately before the next structural fact. Never
* live-emitted (live clients already saw the deltas).
* - STRUCTURAL — persisted with a seq AND live-emitted. These are the facts
* the fold reconstructs state from.
*
* Live stream = ephemeral + structural. Replay stream = coalesced + structural.
* Both are complete; a block always flushes before the fact that follows it,
* so any cursor taken from a structural fact replays exactly the missing tail.
*
* The ephemeral list is shared with the frontend's SSE dedup gate
* (INSTANCE_AI_EPHEMERAL_EVENT_TYPES, #33915) so the two sides cannot drift.
*/
const EPHEMERAL_TYPES = INSTANCE_AI_EPHEMERAL_EVENT_TYPES;
/** Retries per batch on (threadId, seq) PK collision before giving up. */
const MAX_APPEND_ATTEMPTS = 5;
/**
* Trailing deltas with no structural fact after them (e.g. a terminal-outcome
* line published after run-finish, or a liveness timeout notice) would sit in
* the coalescer forever; after this quiet period the open buffers are flushed
* as blocks so they reach the durable log and replay/history stay complete.
*/
const IDLE_FLUSH_MS = 3_000;
/** How a drained event reaches the bus. `id` set = durable; `live` = emit to SSE. */
export interface DrainedEvent {
id?: number;
event: InstanceAiEvent;
live: boolean;
}
interface PendingEvent {
event: InstanceAiEvent;
enqueuedAt: number;
}
interface FlushSignal {
resolve: () => void;
}
/**
* A flush marker rides the same per-thread queue as events, so open buffers
* are persisted exactly at the marker's position: everything published before
* it lands first, everything after it lands later. Persisting outside the
* drain would race a concurrent publish for seqs and could reorder a block
* after the structural fact it must precede.
*/
type PendingEntry = PendingEvent | { flushSignal: FlushSignal };
function isFlushMarker(entry: PendingEntry): entry is { flushSignal: FlushSignal } {
return 'flushSignal' in entry;
}
function serializedBytes(events: InstanceAiEvent[]): number {
return events.reduce(
(total, event) => total + Buffer.byteLength(JSON.stringify(event), 'utf8'),
0,
);
}
interface CoalesceBuffer {
text: string[];
reasoning: string[];
/** responseId of the segment being coalesced — carried on the flushed block
* so the reducer can REPLACE the segment's streamed deltas on replay. */
textResponseId?: string;
reasoningResponseId?: string;
}
type EmitFn = (drained: DrainedEvent) => void;
@Service()
export class DurableEventLog {
/** publish() stays synchronous: events queue here, a per-thread drain assigns seq. */
private readonly pendingByThread = new Map<string, PendingEntry[]>();
/** In-flight drain per thread, awaited by flush(). */
private readonly draining = new Map<string, Promise<void>>();
/** Last assigned seq per thread; lazily seeded from MAX(seq) in the DB. */
private readonly lastSeq = new Map<string, number>();
/** Open text/reasoning blocks per thread, keyed `${runId}:${agentId}`. */
private readonly buffers = new Map<string, Map<string, CoalesceBuffer>>();
private readonly emitters = new Map<string, EmitFn>();
/**
* Lifecycle token per thread, compared by identity. A drain captures it at
* batch start and re-checks after every await: clearThread() replaces the
* thread's token, so a drain resuming from a DB round trip after the clear
* aborts instead of persisting or emitting into the id's next lifecycle
* (e.g. a thread deleted and recreated under the same id mid-append).
*/
private readonly lifecycles = new Map<string, object>();
/** Per-thread idle timers driving the trailing-delta flush. */
private readonly idleFlushTimers = new Map<string, NodeJS.Timeout>();
/** Overridable for tests. */
idleFlushMs = IDLE_FLUSH_MS;
constructor(
private readonly logger: Logger,
private readonly repo: InstanceAiEventLogRepository,
private readonly metrics: DurableLogMetrics,
) {
this.logger = this.logger.scoped('instance-ai');
}
/** Synchronous enqueue — ordering is preserved by the single per-thread drain. */
publish(threadId: string, event: InstanceAiEvent, emit: EmitFn): void {
this.emitters.set(threadId, emit);
const pending = this.pendingByThread.get(threadId);
const entry: PendingEvent = { event, enqueuedAt: Date.now() };
if (pending) pending.push(entry);
else this.pendingByThread.set(threadId, [entry]);
this.ensureDraining(threadId);
this.scheduleIdleFlush(threadId);
}
/** (Re)arm the trailing-delta flush: fires only when the thread goes quiet. */
private scheduleIdleFlush(threadId: string): void {
const existing = this.idleFlushTimers.get(threadId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.idleFlushTimers.delete(threadId);
void this.flush(threadId).catch((error) => {
this.logger.error('Instance AI event log idle flush failed', { threadId, error });
});
}, this.idleFlushMs);
timer.unref();
this.idleFlushTimers.set(threadId, timer);
}
async getEventsAfter(threadId: string, afterSeq: number): Promise<StoredEvent[]> {
return await this.repo.getAfter(threadId, afterSeq);
}
async getEventsForRuns(threadId: string, runIds: string[]): Promise<InstanceAiEvent[]> {
return await this.repo.getForRuns(threadId, runIds);
}
async getNextEventId(threadId: string): Promise<number> {
// Always from the DB: the local cache only tracks THIS main's appends and
// goes stale the moment a sibling main wins a seq range, which would seed
// the client's replay cursor below rows its history response already
// covered. The cache stays writer-only (persistWithRetry).
return (await this.repo.maxSeq(threadId)) + 1;
}
/**
* Drop per-thread drain state (seq cache, pending queue, open buffers,
* emitter). Called when a thread is cleared or deleted: a straggler publish
* after deletion would otherwise append against a stale seq cache and burn
* the retry loop on the thread FK, and the per-thread maps would grow
* unbounded across a long-lived process.
*/
clearThread(threadId: string): void {
this.resolvePendingFlushes(threadId);
this.pendingByThread.delete(threadId);
this.lastSeq.delete(threadId);
this.buffers.delete(threadId);
this.emitters.delete(threadId);
// Invalidates any drain currently awaiting the DB for this thread: it
// re-checks the token when it resumes and aborts its persist/emits.
this.lifecycles.delete(threadId);
const timer = this.idleFlushTimers.get(threadId);
if (timer) clearTimeout(timer);
this.idleFlushTimers.delete(threadId);
}
/** Drop all per-thread drain state. Used during module shutdown. */
clear(): void {
for (const threadId of this.pendingByThread.keys()) this.resolvePendingFlushes(threadId);
this.pendingByThread.clear();
this.lastSeq.clear();
this.buffers.clear();
this.emitters.clear();
this.lifecycles.clear();
for (const timer of this.idleFlushTimers.values()) clearTimeout(timer);
this.idleFlushTimers.clear();
}
/** A cleared thread has nothing left to flush — settle waiters so they never hang. */
private resolvePendingFlushes(threadId: string): void {
const pending = this.pendingByThread.get(threadId);
if (!pending) return;
for (const entry of pending) {
if (isFlushMarker(entry)) entry.flushSignal.resolve();
}
}
/**
* Persist any still-open coalesce buffers as blocks so streamed text
* survives a shutdown mid-segment. Serialized through the per-thread drain
* (as a queue marker), so it can never race a concurrent publish for seqs.
*/
async flush(threadId: string): Promise<void> {
const hasBuffers = (this.buffers.get(threadId)?.size ?? 0) > 0;
if (!this.pendingByThread.has(threadId) && !this.draining.has(threadId) && !hasBuffers) {
return;
}
await new Promise<void>((resolve) => {
const entry: PendingEntry = { flushSignal: { resolve } };
const pending = this.pendingByThread.get(threadId);
if (pending) pending.push(entry);
else this.pendingByThread.set(threadId, [entry]);
this.ensureDraining(threadId);
});
}
/** Drain shutdown flush: called from module shutdown so no thread loses its tail. */
async flushAll(): Promise<void> {
const threadIds = new Set([
...this.draining.keys(),
...this.buffers.keys(),
...this.pendingByThread.keys(),
]);
for (const threadId of threadIds) {
try {
await this.flush(threadId);
} catch (error) {
this.logger.error('Failed to flush Instance AI event log on shutdown', {
threadId,
error,
});
}
}
}
private ensureDraining(threadId: string): void {
if (this.draining.has(threadId)) return;
const drain = (async () => {
try {
let batch = this.takePending(threadId);
while (batch.length > 0) {
try {
await this.drainBatch(threadId, batch);
} catch (error) {
// Keep the drain alive: a failed batch must not reject the
// (unawaited) drain promise or strand later publishes/flushes.
this.logger.error('Instance AI event log drain failed for a batch', {
threadId,
error,
});
for (const entry of batch) {
if (isFlushMarker(entry)) entry.flushSignal.resolve();
}
}
batch = this.takePending(threadId);
}
} finally {
this.draining.delete(threadId);
}
})();
this.draining.set(threadId, drain);
}
private async drainBatch(threadId: string, batch: PendingEntry[]): Promise<void> {
const lifecycle = this.currentLifecycle(threadId);
const flushSignals: FlushSignal[] = [];
const settleFlushes = () => {
for (const signal of flushSignals) signal.resolve();
};
const emit = this.emitters.get(threadId);
if (!emit) {
for (const entry of batch) {
if (isFlushMarker(entry)) entry.flushSignal.resolve();
}
return;
}
// Build the batch plan first; seqs are assigned inside persistWithRetry so
// an append conflict can re-assign them from a re-seeded counter.
const toPersist: InstanceAiEvent[] = [];
const toEmit: Array<{ event: InstanceAiEvent; persistIndex?: number; live: boolean }> = [];
for (const entry of batch) {
if (isFlushMarker(entry)) {
// Persist every open buffer at the marker's queue position, so the
// flush is ordered exactly against the events published around it.
for (const block of this.takeAllOpenBlocks(threadId)) {
toEmit.push({ event: block, persistIndex: toPersist.length, live: false });
toPersist.push(block);
}
flushSignals.push(entry.flushSignal);
continue;
}
const { event } = entry;
if (EPHEMERAL_TYPES.has(event.type)) {
// A delta with a new responseId starts a new segment: close the old
// one as a block first, so blocks stay exactly 1:1 with segments and
// the reducer's open-segment replacement stays exact.
for (const rolled of this.rollSegmentOnResponseChange(threadId, event)) {
toEmit.push({ event: rolled, persistIndex: toPersist.length, live: false });
toPersist.push(rolled);
}
this.bufferDelta(threadId, event);
toEmit.push({ event, live: true });
continue;
}
// Structural fact: flush this agent's open blocks first so replay
// order matches live order (block content precedes the fact).
for (const block of this.flushBlocks(threadId, event)) {
toEmit.push({ event: block, persistIndex: toPersist.length, live: false });
toPersist.push(block);
}
toEmit.push({ event, persistIndex: toPersist.length, live: true });
toPersist.push(event);
}
let firstSeq: number | undefined;
if (toPersist.length > 0) {
firstSeq = await this.persistWithRetry(threadId, toPersist, lifecycle);
// The thread was cleared while the persist was in flight: its next
// lifecycle (a recreated id, or nothing) must not receive this batch's
// emissions. Flush waiters still settle — there is nothing left to flush.
if (this.lifecycles.get(threadId) !== lifecycle) {
settleFlushes();
return;
}
const persistedAt = Date.now();
for (const entry of batch) {
if (!isFlushMarker(entry)) this.metrics.recordQueueLatency(persistedAt - entry.enqueuedAt);
}
}
for (const drained of toEmit) {
const id =
drained.persistIndex !== undefined && firstSeq !== undefined
? firstSeq + drained.persistIndex
: undefined;
emit({ ...(id !== undefined ? { id } : {}), event: drained.event, live: drained.live });
}
settleFlushes();
}
/** The thread's current lifecycle token, minted on first use. */
private currentLifecycle(threadId: string): object {
let token = this.lifecycles.get(threadId);
if (!token) {
token = {};
this.lifecycles.set(threadId, token);
}
return token;
}
/** Drain every open buffer of the thread into block facts (flush marker path). */
private takeAllOpenBlocks(threadId: string): InstanceAiEvent[] {
const threadBuffers = this.buffers.get(threadId);
if (!threadBuffers || threadBuffers.size === 0) return [];
const blocks: InstanceAiEvent[] = [];
for (const [key, buffer] of threadBuffers) {
const separator = key.indexOf(':');
const runId = key.slice(0, separator);
const agentId = key.slice(separator + 1);
const reasoning = this.takeBlock('reasoning', runId, agentId, buffer);
if (reasoning) blocks.push(reasoning);
const text = this.takeBlock('text', runId, agentId, buffer);
if (text) blocks.push(text);
}
this.buffers.delete(threadId);
return blocks;
}
/**
* Append `events` with contiguous seqs, retrying on (threadId, seq) PK
* collision — another main won the range (multi-main only), so re-seed from
* the DB and try again. Returns the first assigned seq, or undefined when
* the batch had to be dropped (logged; live delivery still happens).
* INS-844 merges the shared-sequence drain (#33558) here: its Redis INCRBY
* becomes this batch-INSERT's id assignment, one round trip.
*/
private async persistWithRetry(
threadId: string,
events: InstanceAiEvent[],
lifecycle: object,
): Promise<number | undefined> {
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {
// The thread was cleared while an earlier attempt was in flight: stop
// instead of appending into the id's next lifecycle (deleted threads
// would burn every retry on the FK; a recreated id would accept the
// stale rows). Not a durability failure — the thread is gone.
if (this.lifecycles.get(threadId) !== lifecycle) {
this.logger.debug('Instance AI event log dropped a batch for a cleared thread', {
threadId,
events: events.length,
});
return undefined;
}
// The seed read lives inside the try: a transient failure there must
// consume an attempt and retry, not reject the (unawaited) drain.
let firstSeq: number | undefined;
try {
firstSeq = (await this.currentSeq(threadId)) + 1;
const bytes = await this.repo.appendBatch(threadId, firstSeq, events);
this.lastSeq.set(threadId, firstSeq + events.length - 1);
this.metrics.recordDrainBatch(events.length, bytes);
return firstSeq;
} catch (error) {
lastError = error;
if (isUniqueConstraintError(error)) {
// (threadId, seq) collision: another main won the range — re-seed
// from the DB and try again.
this.lastSeq.delete(threadId);
this.metrics.recordAppendConflict(attempt);
this.logger.warn('Instance AI event log append conflict, retrying', {
threadId,
attempt,
error,
});
continue;
}
// Transient failure (seed read, connectivity, timeout). The append is
// a single INSERT, so if its commit outran a lost response the batch
// is already durable — detect that instead of re-appending it under
// fresh seqs, which would duplicate every fact in the replay.
if (firstSeq !== undefined && (await this.didBatchCommit(threadId, firstSeq, events))) {
this.lastSeq.set(threadId, firstSeq + events.length - 1);
this.metrics.recordDrainBatch(events.length, serializedBytes(events));
return firstSeq;
}
// Also covers a PK violation a driver reports under a code the
// detector doesn't know: the committed row differs from ours, so the
// re-seed below realigns and the next attempt lands cleanly.
this.lastSeq.delete(threadId);
this.logger.warn('Instance AI event log append failed, retrying', {
threadId,
attempt,
error,
});
}
}
this.metrics.recordAppendFailure(events.length);
this.logger.error('Instance AI event log append failed, dropping batch', {
threadId,
events: events.length,
error: lastError,
});
return undefined;
}
/**
* Whether a batch whose append errored actually committed: the first row of
* the attempted range exists with exactly our payload (single-statement
* INSERT, so the first row proves the whole batch). A read failure means
* the DB is still unreachable — report not-committed and keep retrying.
*/
private async didBatchCommit(
threadId: string,
firstSeq: number,
events: InstanceAiEvent[],
): Promise<boolean> {
try {
const payload = await this.repo.payloadAt(threadId, firstSeq);
return payload !== null && payload === JSON.stringify(events[0]);
} catch {
return false;
}
}
/** Append a delta to its agent's open block. */
private bufferDelta(threadId: string, event: InstanceAiEvent): void {
if (event.type !== 'text-delta' && event.type !== 'reasoning-delta') return;
if (!event.responseId) {
// Every delta producer stamps a responseId (provider-supplied or a
// synthetic segment id); an id-less delta would flush an id-less block
// and break the reducer's segment-keyed replace semantics on replay.
this.logger.debug('Instance AI durable log buffered a delta without a responseId', {
threadId,
runId: event.runId,
agentId: event.agentId,
});
}
const buffer = this.getOrCreateBuffer(threadId, `${event.runId}:${event.agentId}`);
if (event.type === 'text-delta') {
buffer.text.push(event.payload.text);
buffer.textResponseId = event.responseId ?? buffer.textResponseId;
} else {
buffer.reasoning.push(event.payload.text);
buffer.reasoningResponseId = event.responseId ?? buffer.reasoningResponseId;
}
}
/**
* Close open blocks made stale by a structural fact: the fact's own agent
* always; every agent of the run on `run-finish`. Blocks are persisted as
* `text-block`/`reasoning-block` facts carrying the segment's responseId —
* on replay the reducer REPLACES the segment's streamed deltas, so a client
* that reconnects mid-block never sees partial text or reasoning twice.
*/
private flushBlocks(threadId: string, fact: InstanceAiEvent): InstanceAiEvent[] {
const threadBuffers = this.buffers.get(threadId);
if (!threadBuffers) return [];
const keys =
fact.type === 'run-finish'
? [...threadBuffers.keys()].filter((k) => k.startsWith(`${fact.runId}:`))
: [`${fact.runId}:${fact.agentId}`];
const flushed: InstanceAiEvent[] = [];
for (const key of keys) {
const buffer = threadBuffers.get(key);
if (!buffer) continue;
threadBuffers.delete(key);
const agentId = key.slice(fact.runId.length + 1);
const reasoning = this.takeBlock('reasoning', fact.runId, agentId, buffer);
if (reasoning) flushed.push(reasoning);
const text = this.takeBlock('text', fact.runId, agentId, buffer);
if (text) flushed.push(text);
}
return flushed;
}
/**
* A delta whose responseId differs from its kind's open buffer starts a new
* segment (e.g. consecutive reasoning-only steps with no tool call between).
* Close the previous segment as a block so blocks stay 1:1 with segments.
*/
private rollSegmentOnResponseChange(threadId: string, event: InstanceAiEvent): InstanceAiEvent[] {
if (event.type !== 'text-delta' && event.type !== 'reasoning-delta') return [];
const buffer = this.buffers.get(threadId)?.get(`${event.runId}:${event.agentId}`);
if (!buffer) return [];
const kind = event.type === 'text-delta' ? 'text' : 'reasoning';
const openResponseId = kind === 'text' ? buffer.textResponseId : buffer.reasoningResponseId;
const hasContent = (kind === 'text' ? buffer.text : buffer.reasoning).length > 0;
if (!hasContent || openResponseId === event.responseId) return [];
const block = this.takeBlock(kind, event.runId, event.agentId, buffer);
return block ? [block] : [];
}
/** Drain one kind's open segment from a buffer into its block fact. */
private takeBlock(
kind: 'text' | 'reasoning',
runId: string,
agentId: string,
buffer: CoalesceBuffer,
): InstanceAiEvent | undefined {
const parts = kind === 'text' ? buffer.text : buffer.reasoning;
if (parts.length === 0) return undefined;
const text = parts.join('');
if (kind === 'text') {
const responseId = buffer.textResponseId;
buffer.text = [];
buffer.textResponseId = undefined;
return {
type: 'text-block',
runId,
agentId,
...(responseId ? { responseId } : {}),
payload: { text },
};
}
const responseId = buffer.reasoningResponseId;
buffer.reasoning = [];
buffer.reasoningResponseId = undefined;
return {
type: 'reasoning-block',
runId,
agentId,
...(responseId ? { responseId } : {}),
payload: { text },
};
}
private async currentSeq(threadId: string): Promise<number> {
const cached = this.lastSeq.get(threadId);
if (cached !== undefined) return cached;
const max = await this.repo.maxSeq(threadId);
// Cutover note (RFC Q&A on cursors): INS-844 seeds from max(DB, Redis
// high-water mark) so cursors minted by the live shared sequence stay valid.
this.lastSeq.set(threadId, max);
return max;
}
private getOrCreateBuffer(threadId: string, key: string): CoalesceBuffer {
let threadBuffers = this.buffers.get(threadId);
if (!threadBuffers) {
threadBuffers = new Map();
this.buffers.set(threadId, threadBuffers);
}
let buffer = threadBuffers.get(key);
if (!buffer) {
buffer = { text: [], reasoning: [] };
threadBuffers.set(key, buffer);
}
return buffer;
}
private takePending(threadId: string): PendingEntry[] {
const pending = this.pendingByThread.get(threadId);
if (!pending) return [];
this.pendingByThread.delete(threadId);
return pending;
}
}
@@ -0,0 +1,90 @@
import { Service } from '@n8n/di';
import { EventService } from '@/events/event.service';
/**
* Durable-log instrumentation (RFC: instance-ai durable event log,
* "Instrumentation"). Two consumers, one recording point:
*
* - Typed `EventService` events feed the Prometheus collectors
* (`PrometheusInstanceAiMetricsService`), following the same convention as
* `instance-ai-run-finished`.
* - The in-process counters below are kept in lockstep for synchronous reads
* in unit tests and log lines.
*/
@Service()
export class DurableLogMetrics {
/** Writer-side counters, recorded inside DurableEventLog's per-thread drain. */
drain = {
batches: 0,
/** Durable rows written (structural facts + coalesced blocks). */
rowsWritten: 0,
/** Serialized payload bytes written. */
bytesWritten: 0,
/** (threadId, seq) PK collisions — another main won the range. */
appendConflicts: 0,
/** Batches dropped after exhausting append retries. */
appendFailures: 0,
/** publish() enqueue → batch persisted, per event. */
queueLatencyMsTotal: 0,
queueLatencyMsMax: 0,
queueLatencySamples: 0,
};
/** SSE endpoint counters (replay path). */
sse = {
/** Reconnects that served a replay from the durable log. */
replaysServed: 0,
/** Events delivered by those replays. */
replayEventsServed: 0,
/** Cursor age at replay time, in events behind the log head. */
cursorAgeEventsTotal: 0,
cursorAgeEventsMax: 0,
};
constructor(private readonly eventService: EventService) {}
recordDrainBatch(rows: number, bytes: number): void {
this.drain.batches++;
this.drain.rowsWritten += rows;
this.drain.bytesWritten += bytes;
this.eventService.emit('instance-ai-durable-log-drained', { rows, bytes });
}
recordQueueLatency(ms: number): void {
this.drain.queueLatencyMsTotal += ms;
this.drain.queueLatencyMsMax = Math.max(this.drain.queueLatencyMsMax, ms);
this.drain.queueLatencySamples++;
this.eventService.emit('instance-ai-durable-log-queue-latency', { ms });
}
recordAppendConflict(attempt: number): void {
this.drain.appendConflicts++;
this.eventService.emit('instance-ai-durable-log-append-conflict', { attempt });
}
recordAppendFailure(events: number): void {
this.drain.appendFailures++;
this.eventService.emit('instance-ai-durable-log-append-failure', { events });
}
recordReplay(eventsServed: number, cursorAgeEvents: number): void {
this.sse.replaysServed++;
this.sse.replayEventsServed += eventsServed;
this.sse.cursorAgeEventsTotal += cursorAgeEvents;
this.sse.cursorAgeEventsMax = Math.max(this.sse.cursorAgeEventsMax, cursorAgeEvents);
this.eventService.emit('instance-ai-durable-log-replayed', {
events: eventsServed,
cursorAgeEvents,
});
}
/**
* The parser is pure module code and keeps its own counter
* (messageParserStats); this only forwards new activations to the
* metrics pipeline.
*/
notifyParserFallbacks(count: number): void {
if (count > 0) this.eventService.emit('instance-ai-parser-fallback', { count });
}
}
@@ -76,6 +76,9 @@ export class InstanceAiModule implements ModuleInterface {
'./entities/instance-ai-mcp-registry-connection.entity'
);
const { InstanceAiThreadGrant } = await import('./entities/instance-ai-thread-grant.entity');
const { InstanceAiEventLogEntry } = await import(
'./entities/instance-ai-event-log-entry.entity'
);
return [
InstanceAiThread,
@@ -90,6 +93,7 @@ export class InstanceAiModule implements ModuleInterface {
InstanceAiObservationLock,
InstanceAiMcpRegistryConnection,
InstanceAiThreadGrant,
InstanceAiEventLogEntry,
];
}
@@ -9,3 +9,4 @@ export { InstanceAiObservationCursorRepository } from './instance-ai-observation
export { InstanceAiObservationLockRepository } from './instance-ai-observation-lock.repository';
export { InstanceAiMcpRegistryConnectionRepository } from './instance-ai-mcp-registry-connection.repository';
export { InstanceAiThreadGrantRepository } from './instance-ai-thread-grant.repository';
export { InstanceAiEventLogRepository } from './instance-ai-event-log.repository';
@@ -0,0 +1,80 @@
import type { InstanceAiEvent } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { StoredEvent } from '@n8n/instance-ai';
import { DataSource, MoreThan, Repository } from '@n8n/typeorm';
import { jsonParse } from 'n8n-workflow';
import { InstanceAiEventLogEntry } from '../entities/instance-ai-event-log-entry.entity';
@Service()
export class InstanceAiEventLogRepository extends Repository<InstanceAiEventLogEntry> {
constructor(dataSource: DataSource) {
super(InstanceAiEventLogEntry, dataSource.manager);
}
/** Highest seq assigned for a thread, 0 when the log is empty. */
async maxSeq(threadId: string): Promise<number> {
const row = await this.createQueryBuilder('e')
.select('MAX(e.seq)', 'max')
.where('e.threadId = :threadId', { threadId })
.getRawOne<{ max: number | null }>();
return row?.max ?? 0;
}
/**
* Append a batch of events with contiguous seq values starting at `firstSeq`,
* in one transaction. The (threadId, seq) PK makes a concurrent-writer race
* fail loudly instead of silently interleaving — the caller re-reads maxSeq
* and retries. Returns the serialized payload bytes written (instrumentation).
*
* INS-844 (compose with the shared-sequence drain): the live-id Redis INCRBY
* merges into this call, so id assignment and durable insert become one round trip.
*/
async appendBatch(
threadId: string,
firstSeq: number,
events: InstanceAiEvent[],
): Promise<number> {
let bytes = 0;
const rows = events.map((event, i) => {
const payload = JSON.stringify(event);
bytes += Buffer.byteLength(payload, 'utf8');
return {
threadId,
seq: firstSeq + i,
runId: event.runId,
type: event.type,
payload,
};
});
await this.insert(rows);
return bytes;
}
/**
* Raw payload at an exact (threadId, seq), or null. Used by the writer to
* detect whether an append whose response was lost actually committed.
*/
async payloadAt(threadId: string, seq: number): Promise<string | null> {
const row = await this.findOne({ where: { threadId, seq } });
return row?.payload ?? null;
}
async getAfter(threadId: string, afterSeq: number): Promise<StoredEvent[]> {
const rows = await this.find({
where: { threadId, seq: MoreThan(afterSeq) },
order: { seq: 'ASC' },
});
return rows.map((r) => ({ id: r.seq, event: jsonParse<InstanceAiEvent>(r.payload) }));
}
async getForRuns(threadId: string, runIds: string[]): Promise<InstanceAiEvent[]> {
if (runIds.length === 0) return [];
const rows = await this.createQueryBuilder('e')
.where('e.threadId = :threadId', { threadId })
.andWhere('e.runId IN (:...runIds)', { runIds })
.orderBy('e.seq', 'ASC')
.getMany();
return rows.map((r) => jsonParse<InstanceAiEvent>(r.payload));
}
}