feat: persist blocked executor jobs

This commit is contained in:
john
2026-07-24 23:07:46 +08:00
parent d21c4849a9
commit bbb43f97a3
17 changed files with 649 additions and 26 deletions
@@ -0,0 +1,191 @@
import pg from 'pg';
import { createInMemoryExecutorJobStore } from './executor-gateway.mjs';
const { Pool } = pg;
const DEFAULT_EXECUTOR_SCHEMA = 'memind_orchestrator';
const EXECUTOR_JOB_TABLE = 'executor_jobs';
function normalizeMode(value) {
const mode = String(value ?? 'postgres').trim().toLowerCase();
if (mode === 'memory' || mode === 'postgres') return mode;
throw new Error(`Unsupported executor job store mode: ${value}`);
}
function normalizeSchema(value) {
const schema = String(value ?? DEFAULT_EXECUTOR_SCHEMA).trim();
if (!/^[a-z_][a-z0-9_]{0,62}$/i.test(schema)) {
throw new Error('Invalid Executor Gateway PostgreSQL schema');
}
return schema;
}
function parseState(value) {
if (value == null) return null;
if (typeof value === 'object') return structuredClone(value);
try {
return JSON.parse(String(value));
} catch {
return null;
}
}
function projectRow(row) {
return parseState(row?.state_json);
}
export function createPostgresExecutorJobStore({
pool,
schema = DEFAULT_EXECUTOR_SCHEMA,
} = {}) {
if (!pool?.query) throw new Error('PostgreSQL Executor Job Store requires a pool');
const normalizedSchema = normalizeSchema(schema);
const table = `"${normalizedSchema}"."${EXECUTOR_JOB_TABLE}"`;
return {
kind: 'postgres',
durable: true,
async ensureSchema() {
await pool.query(`CREATE SCHEMA IF NOT EXISTS "${normalizedSchema}"`);
await pool.query(`
CREATE TABLE IF NOT EXISTS ${table} (
job_id VARCHAR(128) PRIMARY KEY,
idempotency_key VARCHAR(200) NOT NULL UNIQUE,
request_fingerprint CHAR(64) NOT NULL,
state_json JSONB NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS executor_jobs_updated_at_idx
ON ${table} (updated_at DESC)
`);
},
async getById(jobId) {
const result = await pool.query(
`SELECT state_json FROM ${table} WHERE job_id = $1 LIMIT 1`,
[String(jobId ?? '').trim()],
);
return projectRow(result.rows?.[0]);
},
async getByIdempotencyKey(key) {
const result = await pool.query(
`SELECT state_json FROM ${table} WHERE idempotency_key = $1 LIMIT 1`,
[String(key ?? '').trim()],
);
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]);
if (created) return { created: true, record: created };
const existingResult = await pool.query(
`SELECT state_json FROM ${table} WHERE idempotency_key = $1 LIMIT 1`,
[record.idempotencyKey],
);
const existing = projectRow(existingResult.rows?.[0]);
if (existing) return { created: false, record: existing };
const error = new Error(`Executor job id already exists: ${record.jobId}`);
error.code = 'EXECUTOR_JOB_ID_CONFLICT';
error.status = 409;
throw error;
},
async update(record) {
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],
);
return projectRow(result.rows?.[0]);
},
};
}
export async function createExecutorJobPersistence({
mode = process.env.MEMIND_ORCHESTRATOR_CHECKPOINT_MODE,
connectionString = process.env.MEMIND_ORCHESTRATOR_DATABASE_URL,
schema = process.env.MEMIND_ORCHESTRATOR_DATABASE_SCHEMA,
poolFactory = (config) => new Pool(config),
} = {}) {
const normalizedMode = normalizeMode(mode);
if (normalizedMode === 'memory') {
const store = createInMemoryExecutorJobStore();
return {
kind: 'memory',
durable: false,
store,
async probe() {
return true;
},
async close() {},
};
}
const normalizedConnectionString = String(connectionString ?? '').trim();
if (!normalizedConnectionString) {
const error = new Error(
'MEMIND_ORCHESTRATOR_DATABASE_URL is required for PostgreSQL Executor Job Store',
);
error.code = 'ORCHESTRATOR_DATABASE_URL_REQUIRED';
throw error;
}
const normalizedSchema = normalizeSchema(schema);
const pool = poolFactory({ connectionString: normalizedConnectionString });
const store = createPostgresExecutorJobStore({ pool, schema: normalizedSchema });
try {
await store.ensureSchema();
} catch (error) {
try {
await pool.end?.();
} catch {
// Preserve the schema initialization failure.
}
throw error;
}
return {
kind: 'postgres',
durable: true,
store,
async probe() {
await pool.query('SELECT 1 AS ok');
return true;
},
async close() {
await pool.end?.();
},
};
}
export const executorJobStoreInternals = {
DEFAULT_EXECUTOR_SCHEMA,
EXECUTOR_JOB_TABLE,
normalizeMode,
normalizeSchema,
parseState,
projectRow,
};