feat: expose executor job observability

This commit is contained in:
john
2026-07-24 23:31:29 +08:00
parent bbb43f97a3
commit 396bb78200
18 changed files with 733 additions and 68 deletions
+164 -25
View File
@@ -4,6 +4,7 @@ import { createInMemoryExecutorJobStore } from './executor-gateway.mjs';
const { Pool } = pg;
const DEFAULT_EXECUTOR_SCHEMA = 'memind_orchestrator';
const EXECUTOR_JOB_TABLE = 'executor_jobs';
const EXECUTOR_JOB_EVENT_TABLE = 'executor_job_events';
function normalizeMode(value) {
const mode = String(value ?? 'postgres').trim().toLowerCase();
@@ -33,6 +34,40 @@ function projectRow(row) {
return parseState(row?.state_json);
}
function projectEventRow(row) {
return parseState(row?.event_json);
}
function eventWithSequence(event, jobId, sequence) {
return {
...structuredClone(event),
jobId,
sequence,
};
}
async function withTransaction(pool, operation) {
if (typeof pool?.connect !== 'function') {
throw new Error('PostgreSQL Executor Job Store requires transactional pool.connect');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await operation(client);
await client.query('COMMIT');
return result;
} catch (error) {
try {
await client.query('ROLLBACK');
} catch {
// Preserve the transactional operation failure.
}
throw error;
} finally {
client.release?.();
}
}
export function createPostgresExecutorJobStore({
pool,
schema = DEFAULT_EXECUTOR_SCHEMA,
@@ -40,6 +75,7 @@ export function createPostgresExecutorJobStore({
if (!pool?.query) throw new Error('PostgreSQL Executor Job Store requires a pool');
const normalizedSchema = normalizeSchema(schema);
const table = `"${normalizedSchema}"."${EXECUTOR_JOB_TABLE}"`;
const eventTable = `"${normalizedSchema}"."${EXECUTOR_JOB_EVENT_TABLE}"`;
return {
kind: 'postgres',
@@ -61,6 +97,45 @@ export function createPostgresExecutorJobStore({
CREATE INDEX IF NOT EXISTS executor_jobs_updated_at_idx
ON ${table} (updated_at DESC)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS ${eventTable} (
event_id VARCHAR(128) PRIMARY KEY,
job_id VARCHAR(128) NOT NULL REFERENCES ${table} (job_id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
event_json JSONB NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE (job_id, sequence)
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS executor_job_events_job_sequence_idx
ON ${eventTable} (job_id, sequence)
`);
await pool.query(`
INSERT INTO ${eventTable} (event_id, job_id, sequence, event_json, created_at)
SELECT
'evt_' || md5(job_id || ':snapshot-import'),
job_id,
1,
jsonb_build_object(
'version', 'executor-job-event-v1',
'eventId', 'evt_' || md5(job_id || ':snapshot-import'),
'jobId', job_id,
'sequence', 1,
'type', 'executor_job_snapshot_imported',
'timestamp', created_at,
'data', jsonb_build_object(
'status', state_json->>'status',
'reason', state_json->>'reason'
)
),
created_at
FROM ${table} jobs
WHERE NOT EXISTS (
SELECT 1 FROM ${eventTable} events WHERE events.job_id = jobs.job_id
)
ON CONFLICT DO NOTHING
`);
},
async getById(jobId) {
@@ -79,23 +154,40 @@ export function createPostgresExecutorJobStore({
return projectRow(result.rows?.[0]);
},
async createIfAbsent(record) {
const inserted = await pool.query(
`INSERT INTO ${table}
(job_id, idempotency_key, request_fingerprint, state_json, created_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
ON CONFLICT DO NOTHING
RETURNING state_json`,
[
record.jobId,
record.idempotencyKey,
record.requestFingerprint,
JSON.stringify(record),
record.createdAt,
record.updatedAt,
],
);
const created = projectRow(inserted.rows?.[0]);
async createIfAbsent(record, { initialEvent = null } = {}) {
const created = await withTransaction(pool, async (client) => {
const inserted = await client.query(
`INSERT INTO ${table}
(job_id, idempotency_key, request_fingerprint, state_json, created_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
ON CONFLICT DO NOTHING
RETURNING state_json`,
[
record.jobId,
record.idempotencyKey,
record.requestFingerprint,
JSON.stringify(record),
record.createdAt,
record.updatedAt,
],
);
const insertedRecord = projectRow(inserted.rows?.[0]);
if (!insertedRecord || !initialEvent) return insertedRecord;
const event = eventWithSequence(initialEvent, record.jobId, 1);
await client.query(
`INSERT INTO ${eventTable}
(event_id, job_id, sequence, event_json, created_at)
VALUES ($1, $2, $3, $4::jsonb, $5)`,
[
event.eventId,
record.jobId,
event.sequence,
JSON.stringify(event),
event.timestamp,
],
);
return insertedRecord;
});
if (created) return { created: true, record: created };
const existingResult = await pool.query(
@@ -111,16 +203,59 @@ export function createPostgresExecutorJobStore({
throw error;
},
async update(record) {
async update(record, { event = null } = {}) {
return withTransaction(pool, async (client) => {
const locked = await client.query(
`SELECT job_id FROM ${table} WHERE job_id = $1 FOR UPDATE`,
[record.jobId],
);
if (!locked.rows?.[0]) return null;
const result = await client.query(
`UPDATE ${table}
SET state_json = $2::jsonb,
updated_at = $3
WHERE job_id = $1
RETURNING state_json`,
[record.jobId, JSON.stringify(record), record.updatedAt],
);
if (event) {
const sequenceResult = await client.query(
`SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence
FROM ${eventTable}
WHERE job_id = $1`,
[record.jobId],
);
const sequence = Number(sequenceResult.rows?.[0]?.next_sequence ?? 1);
const persistedEvent = eventWithSequence(event, record.jobId, sequence);
await client.query(
`INSERT INTO ${eventTable}
(event_id, job_id, sequence, event_json, created_at)
VALUES ($1, $2, $3, $4::jsonb, $5)`,
[
persistedEvent.eventId,
record.jobId,
sequence,
JSON.stringify(persistedEvent),
persistedEvent.timestamp,
],
);
}
return projectRow(result.rows?.[0]);
});
},
async listEvents(jobId, { after = 0, limit = 500 } = {}) {
const cursor = Math.max(0, Number(after) || 0);
const pageSize = Math.min(500, Math.max(1, Number(limit) || 500));
const result = await pool.query(
`UPDATE ${table}
SET state_json = $2::jsonb,
updated_at = $3
WHERE job_id = $1
RETURNING state_json`,
[record.jobId, JSON.stringify(record), record.updatedAt],
`SELECT event_json
FROM ${eventTable}
WHERE job_id = $1 AND sequence > $2
ORDER BY sequence ASC
LIMIT $3`,
[String(jobId ?? '').trim(), cursor, pageSize],
);
return projectRow(result.rows?.[0]);
return (result.rows ?? []).map(projectEventRow).filter(Boolean);
},
};
}
@@ -184,8 +319,12 @@ export async function createExecutorJobPersistence({
export const executorJobStoreInternals = {
DEFAULT_EXECUTOR_SCHEMA,
EXECUTOR_JOB_TABLE,
EXECUTOR_JOB_EVENT_TABLE,
eventWithSequence,
normalizeMode,
normalizeSchema,
parseState,
projectEventRow,
projectRow,
withTransaction,
};