mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 20:41:37 +00:00
251 lines
8.5 KiB
TypeScript
251 lines
8.5 KiB
TypeScript
// Stores durable delivery queue entries in SQLite.
|
|
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
|
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
|
|
import {
|
|
executeSqliteQuerySync,
|
|
executeSqliteQueryTakeFirstSync,
|
|
getNodeSqliteKysely,
|
|
} from "./kysely-sync.js";
|
|
|
|
// Generic durable delivery queue storage shared by session and outbound queues.
|
|
// Queue-specific wrappers own payload shape; this layer owns SQLite state.
|
|
type QueueStatus = "pending" | "failed";
|
|
type DeliveryQueueDatabase = Pick<OpenClawStateKyselyDatabase, "delivery_queue_entries">;
|
|
|
|
/** Indexed metadata extracted from queue payloads for diagnostics and recovery. */
|
|
export type DeliveryQueueRowMetadata = {
|
|
entryKind?: string;
|
|
sessionKey?: string;
|
|
channel?: string;
|
|
target?: string;
|
|
accountId?: string;
|
|
};
|
|
|
|
/** Persisted queue entry fields common to all delivery queue payloads. */
|
|
export type DeliveryQueueEntryState = {
|
|
id: string;
|
|
enqueuedAt: number;
|
|
retryCount: number;
|
|
lastAttemptAt?: number;
|
|
lastError?: string;
|
|
platformSendStartedAt?: number;
|
|
recoveryState?: string;
|
|
};
|
|
|
|
type QueueRow = {
|
|
id: string;
|
|
entry_json: string;
|
|
enqueued_at: number | bigint;
|
|
retry_count: number | bigint;
|
|
last_attempt_at: number | bigint | null;
|
|
last_error: string | null;
|
|
platform_send_started_at: number | bigint | null;
|
|
recovery_state: string | null;
|
|
};
|
|
|
|
function openStateDatabase(stateDir?: string) {
|
|
return openOpenClawStateDatabase({
|
|
env: stateDir ? { ...process.env, OPENCLAW_STATE_DIR: stateDir } : process.env,
|
|
});
|
|
}
|
|
|
|
function enoent(queueName: string, id: string): Error & { code: string } {
|
|
const err = new Error(`No pending ${queueName} delivery queue entry ${id}`) as Error & {
|
|
code: string;
|
|
};
|
|
err.code = "ENOENT";
|
|
return err;
|
|
}
|
|
|
|
function inflate(row: QueueRow): DeliveryQueueEntryState {
|
|
return {
|
|
...(JSON.parse(row.entry_json) as DeliveryQueueEntryState),
|
|
id: row.id,
|
|
enqueuedAt: Number(row.enqueued_at),
|
|
retryCount: Number(row.retry_count),
|
|
...(row.last_attempt_at == null ? {} : { lastAttemptAt: Number(row.last_attempt_at) }),
|
|
...(row.last_error == null ? {} : { lastError: row.last_error }),
|
|
...(row.platform_send_started_at == null
|
|
? {}
|
|
: { platformSendStartedAt: Number(row.platform_send_started_at) }),
|
|
...(row.recovery_state == null ? {} : { recoveryState: row.recovery_state }),
|
|
};
|
|
}
|
|
|
|
function metadata(entry: DeliveryQueueEntryState): DeliveryQueueRowMetadata {
|
|
const item = entry as DeliveryQueueEntryState & {
|
|
kind?: string;
|
|
sessionKey?: string;
|
|
channel?: string;
|
|
to?: string;
|
|
accountId?: string;
|
|
session?: { key?: string };
|
|
route?: { channel?: string; to?: string; accountId?: string };
|
|
deliveryContext?: { channel?: string; to?: string; accountId?: string };
|
|
};
|
|
return {
|
|
entryKind: item.kind,
|
|
sessionKey: item.sessionKey ?? item.session?.key,
|
|
channel: item.channel ?? item.route?.channel ?? item.deliveryContext?.channel,
|
|
target: item.to ?? item.route?.to ?? item.deliveryContext?.to,
|
|
accountId: item.accountId ?? item.route?.accountId ?? item.deliveryContext?.accountId,
|
|
};
|
|
}
|
|
|
|
/** Insert or replace a delivery queue entry under a queue namespace. */
|
|
export function upsertDeliveryQueueEntry(params: {
|
|
queueName: string;
|
|
entry: DeliveryQueueEntryState;
|
|
metadata?: DeliveryQueueRowMetadata;
|
|
status?: QueueStatus;
|
|
stateDir?: string;
|
|
}): void {
|
|
const now = Date.now();
|
|
const status = params.status ?? "pending";
|
|
const meta = params.metadata ?? metadata(params.entry);
|
|
const database = openStateDatabase(params.stateDir);
|
|
const queueDb = getNodeSqliteKysely<DeliveryQueueDatabase>(database.db);
|
|
executeSqliteQuerySync(
|
|
database.db,
|
|
queueDb
|
|
.insertInto("delivery_queue_entries")
|
|
.values({
|
|
queue_name: params.queueName,
|
|
id: params.entry.id,
|
|
status,
|
|
entry_kind: meta.entryKind ?? null,
|
|
session_key: meta.sessionKey ?? null,
|
|
channel: meta.channel ?? null,
|
|
target: meta.target ?? null,
|
|
account_id: meta.accountId ?? null,
|
|
retry_count: params.entry.retryCount,
|
|
last_attempt_at: params.entry.lastAttemptAt ?? null,
|
|
last_error: params.entry.lastError ?? null,
|
|
recovery_state: params.entry.recoveryState ?? null,
|
|
platform_send_started_at: params.entry.platformSendStartedAt ?? null,
|
|
entry_json: JSON.stringify(params.entry),
|
|
enqueued_at: params.entry.enqueuedAt,
|
|
updated_at: now,
|
|
failed_at: status === "failed" ? now : null,
|
|
})
|
|
.onConflict((conflict) =>
|
|
conflict.columns(["queue_name", "id"]).doUpdateSet({
|
|
status: (eb) => eb.ref("excluded.status"),
|
|
entry_kind: (eb) => eb.ref("excluded.entry_kind"),
|
|
session_key: (eb) => eb.ref("excluded.session_key"),
|
|
channel: (eb) => eb.ref("excluded.channel"),
|
|
target: (eb) => eb.ref("excluded.target"),
|
|
account_id: (eb) => eb.ref("excluded.account_id"),
|
|
retry_count: (eb) => eb.ref("excluded.retry_count"),
|
|
last_attempt_at: (eb) => eb.ref("excluded.last_attempt_at"),
|
|
last_error: (eb) => eb.ref("excluded.last_error"),
|
|
recovery_state: (eb) => eb.ref("excluded.recovery_state"),
|
|
platform_send_started_at: (eb) => eb.ref("excluded.platform_send_started_at"),
|
|
entry_json: (eb) => eb.ref("excluded.entry_json"),
|
|
enqueued_at: (eb) => eb.ref("excluded.enqueued_at"),
|
|
updated_at: (eb) => eb.ref("excluded.updated_at"),
|
|
failed_at: (eb) => eb.ref("excluded.failed_at"),
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Load a single pending delivery queue entry. */
|
|
export function loadDeliveryQueueEntry(
|
|
queueName: string,
|
|
id: string,
|
|
stateDir?: string,
|
|
): DeliveryQueueEntryState | null {
|
|
const database = openStateDatabase(stateDir);
|
|
const queueDb = getNodeSqliteKysely<DeliveryQueueDatabase>(database.db);
|
|
const row = executeSqliteQueryTakeFirstSync(
|
|
database.db,
|
|
queueDb
|
|
.selectFrom("delivery_queue_entries")
|
|
.select([
|
|
"id",
|
|
"entry_json",
|
|
"enqueued_at",
|
|
"retry_count",
|
|
"last_attempt_at",
|
|
"last_error",
|
|
"platform_send_started_at",
|
|
"recovery_state",
|
|
])
|
|
.where("queue_name", "=", queueName)
|
|
.where("id", "=", id)
|
|
.where("status", "=", "pending"),
|
|
) as QueueRow | undefined;
|
|
return row ? inflate(row) : null;
|
|
}
|
|
|
|
/** Load all pending entries for a queue namespace in database order. */
|
|
export function loadDeliveryQueueEntries(
|
|
queueName: string,
|
|
stateDir?: string,
|
|
): DeliveryQueueEntryState[] {
|
|
const database = openStateDatabase(stateDir);
|
|
const queueDb = getNodeSqliteKysely<DeliveryQueueDatabase>(database.db);
|
|
const rows = executeSqliteQuerySync(
|
|
database.db,
|
|
queueDb
|
|
.selectFrom("delivery_queue_entries")
|
|
.select([
|
|
"id",
|
|
"entry_json",
|
|
"enqueued_at",
|
|
"retry_count",
|
|
"last_attempt_at",
|
|
"last_error",
|
|
"platform_send_started_at",
|
|
"recovery_state",
|
|
])
|
|
.where("queue_name", "=", queueName)
|
|
.where("status", "=", "pending")
|
|
.orderBy("enqueued_at", "asc")
|
|
.orderBy("id", "asc"),
|
|
).rows as QueueRow[];
|
|
return rows.map(inflate);
|
|
}
|
|
|
|
/** Delete a pending delivery queue entry after successful delivery. */
|
|
export function deleteDeliveryQueueEntry(queueName: string, id: string, stateDir?: string): void {
|
|
const database = openStateDatabase(stateDir);
|
|
const queueDb = getNodeSqliteKysely<DeliveryQueueDatabase>(database.db);
|
|
executeSqliteQuerySync(
|
|
database.db,
|
|
queueDb
|
|
.deleteFrom("delivery_queue_entries")
|
|
.where("queue_name", "=", queueName)
|
|
.where("id", "=", id)
|
|
.where("status", "=", "pending"),
|
|
);
|
|
}
|
|
|
|
/** Load, transform, and persist a pending delivery queue entry. */
|
|
export function updateDeliveryQueueEntry(
|
|
queueName: string,
|
|
id: string,
|
|
stateDir: string | undefined,
|
|
update: (entry: DeliveryQueueEntryState) => DeliveryQueueEntryState,
|
|
): void {
|
|
const current = loadDeliveryQueueEntry(queueName, id, stateDir);
|
|
if (!current) {
|
|
throw enoent(queueName, id);
|
|
}
|
|
upsertDeliveryQueueEntry({ queueName, entry: update(current), stateDir });
|
|
}
|
|
|
|
/** Mark a pending delivery queue entry as failed for later diagnostics. */
|
|
export function moveDeliveryQueueEntryToFailed(
|
|
queueName: string,
|
|
id: string,
|
|
stateDir?: string,
|
|
): void {
|
|
const current = loadDeliveryQueueEntry(queueName, id, stateDir);
|
|
if (!current) {
|
|
throw enoent(queueName, id);
|
|
}
|
|
upsertDeliveryQueueEntry({ queueName, entry: current, status: "failed", stateDir });
|
|
}
|