feat: expose executor job observability
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user