feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,508 @@
// PostgreSQL authority for bounded Cluster schedule claim and commit.
import {
InvalidClusterScheduleError,
normalizeClaimClusterScheduleCommand,
normalizeClusterScheduleClaim,
normalizeCommitClusterScheduleDecisionCommand,
type ClaimClusterScheduleCommand,
type ClusterScheduleClaim,
type ClusterScheduleStore,
type CommitClusterScheduleDecisionCommand,
type CommitClusterScheduleDecisionResult,
} from '@qinglong/runtime-core/cluster-scheduler';
import {
BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
createBuiltInTriggerSpecSemanticRegistry,
type TriggerSpec,
} from '@qinglong/runtime-core/trigger';
import type {
PostgresClient,
PostgresPool,
} from '@qinglong/runtime-core';
import {
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
configurePostgresDefinitionTransaction,
postgresRequiredInteger,
postgresRequiredJsonObject,
postgresRequiredString,
postgresSqlState,
rollbackPostgresDefinitionTransaction,
} from '../repository/definitionRepositorySupport';
import { findExecutionRevision } from '../automation/taskDefinitionRepository';
type Row = Record<string, unknown>;
const triggerSemantics = createBuiltInTriggerSpecSemanticRegistry();
const CLAIM_SELECT = `
schedule.project_id AS "projectId",
schedule.trigger_id AS "triggerId",
schedule.trigger_revision AS "triggerRevision",
revision.content_digest AS "triggerContentDigest",
revision.created_at_ms AS "triggerUpdatedAtMs",
revision.task_id AS "taskId",
revision.task_revision AS "taskRevision",
revision.task_content_digest AS "taskContentDigest",
revision.spec_json AS "specJson",
task.name AS "taskName",
schedule.state_version AS "stateVersion",
schedule.next_fire_at_ms AS "nextFireAtMs",
schedule.claim_owner AS "claimOwner",
schedule.claim_token::text AS "claimToken",
schedule.claim_version AS "claimVersion",
schedule.claim_expires_at_ms AS "claimExpiresAtMs",
schedule.updated_at_ms AS "claimAcquiredAtMs"`;
export class PostgresClusterScheduleUnavailableError extends Error {
readonly code = 'CLUSTER_SCHEDULE_UNAVAILABLE';
constructor(message = 'PostgreSQL cluster schedule storage is unavailable') {
super(message);
this.name = 'PostgresClusterScheduleUnavailableError';
}
}
function unavailable(message?: string): PostgresClusterScheduleUnavailableError {
return new PostgresClusterScheduleUnavailableError(message);
}
function nullableInteger(row: Row, key: string): number | null {
return row[key] === null
? null
: postgresRequiredInteger(row[key], unavailable);
}
function parseClaim(row: Row): ClusterScheduleClaim {
try {
const projectId = postgresRequiredString(row.projectId, unavailable);
const triggerId = postgresRequiredString(row.triggerId, unavailable);
const taskId = postgresRequiredString(row.taskId, unavailable);
const taskRevision = postgresRequiredInteger(row.taskRevision, unavailable);
const spec = triggerSemantics.normalize({
projectId,
triggerId,
taskId,
taskRevision,
spec: postgresRequiredJsonObject(
row.specJson,
unavailable,
) as unknown as TriggerSpec,
});
if (spec.schema !== BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA) {
throw unavailable();
}
const config = spec.config as Readonly<Record<string, unknown>>;
return normalizeClusterScheduleClaim({
projectId,
triggerId,
triggerRevision: postgresRequiredInteger(
row.triggerRevision,
unavailable,
),
triggerContentDigest: postgresRequiredString(
row.triggerContentDigest,
unavailable,
),
triggerUpdatedAtMs: postgresRequiredInteger(
row.triggerUpdatedAtMs,
unavailable,
),
taskId,
taskRevision,
taskContentDigest: postgresRequiredString(
row.taskContentDigest,
unavailable,
),
expression: config.expression as string,
timezone: config.timezone as string,
misfirePolicy: config.misfirePolicy as 'skip' | 'fire_once',
stateVersion: postgresRequiredInteger(row.stateVersion, unavailable),
nextFireAtMs: nullableInteger(row, 'nextFireAtMs'),
claimOwner: postgresRequiredString(row.claimOwner, unavailable),
claimToken: postgresRequiredString(row.claimToken, unavailable),
claimVersion: postgresRequiredInteger(row.claimVersion, unavailable),
claimAcquiredAtMs: postgresRequiredInteger(
row.claimAcquiredAtMs,
unavailable,
),
claimExpiresAtMs: postgresRequiredInteger(
row.claimExpiresAtMs,
unavailable,
),
});
} catch (error) {
if (error instanceof PostgresClusterScheduleUnavailableError) throw error;
throw unavailable();
}
}
function sameClaim(left: ClusterScheduleClaim, right: ClusterScheduleClaim): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function taskName(row: Row): string {
const value = postgresRequiredString(row.taskName, unavailable);
if (value.length < 1 || Buffer.byteLength(value, 'utf8') > 255) {
throw unavailable();
}
return value;
}
function idempotencyKey(claim: ClusterScheduleClaim, scheduledForMs: number): string {
return `ql3:cron:v1:${claim.triggerId}:${claim.triggerRevision}:${scheduledForMs}`;
}
async function finishRace(
client: PostgresClient,
): Promise<CommitClusterScheduleDecisionResult> {
await client.query('ROLLBACK');
return Object.freeze({ status: 'raced' as const });
}
/** Runtime-only row-lease scheduler store; it is never loaded by edge profiles. */
export class PostgresClusterScheduleRepository implements ClusterScheduleStore {
constructor(private readonly pool: PostgresPool) {
if (
!pool ||
typeof pool.query !== 'function' ||
typeof pool.connect !== 'function'
) {
throw new TypeError('PostgreSQL cluster schedule pool is invalid');
}
}
async claimNextClusterSchedule(
input: ClaimClusterScheduleCommand,
): Promise<ClusterScheduleClaim | null> {
const command = normalizeClaimClusterScheduleCommand(input);
try {
const result = await this.pool.query<Row>(
`WITH observation AS MATERIALIZED (
SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
AS observed_at_ms
), candidate AS (
SELECT schedule.project_id, schedule.trigger_id,
observation.observed_at_ms
FROM "ql3"."trigger_schedules" AS schedule
JOIN "ql3"."triggers" AS head
ON head.project_id = schedule.project_id
AND head.trigger_id = schedule.trigger_id
AND head.current_revision = schedule.trigger_revision
JOIN "ql3"."trigger_revisions" AS revision
ON revision.project_id = schedule.project_id
AND revision.trigger_id = schedule.trigger_id
AND revision.revision = schedule.trigger_revision
JOIN "ql3"."task_definition_revisions" AS task
ON task.project_id = revision.project_id
AND task.task_id = revision.task_id
AND task.revision = revision.task_revision
AND task.content_digest = revision.task_content_digest
JOIN "ql3"."task_definitions" AS task_head
ON task_head.project_id = task.project_id
AND task_head.task_id = task.task_id
AND task_head.current_revision = task.revision
JOIN "ql3"."projects" AS project
ON project.id = schedule.project_id
CROSS JOIN observation
WHERE project.status = 'active'
AND revision.enabled = true
AND revision.spec_json->>'schema' = 'qinglong/cron@v1'
AND (schedule.next_fire_at_ms IS NULL
OR schedule.next_fire_at_ms <= observation.observed_at_ms)
AND (schedule.claim_token IS NULL
OR schedule.claim_expires_at_ms <= observation.observed_at_ms)
AND schedule.updated_at_ms <= observation.observed_at_ms
AND schedule.state_version < 2147483647
AND schedule.claim_version < 2147483647
ORDER BY schedule.next_fire_at_ms NULLS FIRST,
schedule.project_id, schedule.trigger_id
FOR UPDATE OF schedule SKIP LOCKED
LIMIT 1
), claimed AS (
UPDATE "ql3"."trigger_schedules" AS schedule
SET claim_owner = $1,
claim_token = $2::uuid,
claim_version = schedule.claim_version + 1,
claim_expires_at_ms = candidate.observed_at_ms + $3::bigint,
state_version = schedule.state_version + 1,
updated_at_ms = candidate.observed_at_ms
FROM candidate
WHERE schedule.project_id = candidate.project_id
AND schedule.trigger_id = candidate.trigger_id
RETURNING schedule.*
)
SELECT ${CLAIM_SELECT}
FROM claimed AS schedule
JOIN "ql3"."trigger_revisions" AS revision
ON revision.project_id = schedule.project_id
AND revision.trigger_id = schedule.trigger_id
AND revision.revision = schedule.trigger_revision
JOIN "ql3"."task_definition_revisions" AS task
ON task.project_id = revision.project_id
AND task.task_id = revision.task_id
AND task.revision = revision.task_revision
AND task.content_digest = revision.task_content_digest
JOIN "ql3"."task_definitions" AS task_head
ON task_head.project_id = task.project_id
AND task_head.task_id = task.task_id
AND task_head.current_revision = task.revision`,
[command.ownerId, command.claimToken, command.leaseMs],
);
if (result.rows.length === 0) return null;
if (result.rows.length !== 1) throw unavailable();
return parseClaim(result.rows[0]!);
} catch (error) {
if (error instanceof InvalidClusterScheduleError) throw error;
if (error instanceof PostgresClusterScheduleUnavailableError) throw error;
throw unavailable();
}
}
async commitClusterScheduleDecision(
input: CommitClusterScheduleDecisionCommand,
): Promise<CommitClusterScheduleDecisionResult> {
const command = normalizeCommitClusterScheduleDecisionCommand(input);
for (
let transactionAttempt = 0;
transactionAttempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
transactionAttempt += 1
) {
let client: PostgresClient;
try {
client = await this.pool.connect();
} catch {
throw unavailable();
}
let began = false;
let commitAttempted = false;
try {
await configurePostgresDefinitionTransaction(client);
began = true;
const locked = await client.query<Row>(
`WITH observation AS MATERIALIZED (
SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
AS observed_at_ms
)
SELECT ${CLAIM_SELECT},
observation.observed_at_ms AS "commitObservedAtMs"
FROM "ql3"."trigger_schedules" AS schedule
JOIN "ql3"."triggers" AS head
ON head.project_id = schedule.project_id
AND head.trigger_id = schedule.trigger_id
AND head.current_revision = schedule.trigger_revision
JOIN "ql3"."trigger_revisions" AS revision
ON revision.project_id = schedule.project_id
AND revision.trigger_id = schedule.trigger_id
AND revision.revision = schedule.trigger_revision
JOIN "ql3"."task_definition_revisions" AS task
ON task.project_id = revision.project_id
AND task.task_id = revision.task_id
AND task.revision = revision.task_revision
AND task.content_digest = revision.task_content_digest
JOIN "ql3"."task_definitions" AS task_head
ON task_head.project_id = task.project_id
AND task_head.task_id = task.task_id
AND task_head.current_revision = task.revision
JOIN "ql3"."projects" AS project
ON project.id = schedule.project_id
CROSS JOIN observation
WHERE schedule.project_id = $1 AND schedule.trigger_id = $2
AND project.status = 'active' AND revision.enabled = true
AND revision.spec_json->>'schema' = 'qinglong/cron@v1'
FOR UPDATE OF schedule`,
[command.claim.projectId, command.claim.triggerId],
);
if (locked.rows.length !== 1) {
const result = await finishRace(client);
began = false;
return result;
}
const currentClaim = parseClaim(locked.rows[0]!);
const commitObservedAtMs = postgresRequiredInteger(
locked.rows[0]!.commitObservedAtMs,
unavailable,
);
if (
!sameClaim(currentClaim, command.claim) ||
commitObservedAtMs >= currentClaim.claimExpiresAtMs
) {
const result = await finishRace(client);
began = false;
return result;
}
if (commitObservedAtMs < currentClaim.claimAcquiredAtMs) {
throw unavailable('Cluster schedule clock moved backwards');
}
if (command.decision.disposition === 'admit') {
const scheduledForMs = command.decision.scheduledForMs!;
const execution = await findExecutionRevision(
client,
currentClaim.projectId,
currentClaim.taskId,
currentClaim.taskRevision,
);
if (
!execution ||
execution.sourceContentDigest !== currentClaim.taskContentDigest
) {
throw unavailable('Pinned remote Worker execution revision is unavailable');
}
const key = idempotencyKey(
currentClaim,
scheduledForMs,
);
await client.query(
`INSERT INTO "ql3"."runs" (
id, project_id, task_id, task_revision, task_name,
task_snapshot_ref, trigger_id, trigger_type, execution_origin,
execution_owner, triggered_by, scheduled_for_ms, status,
version, event_sequence, priority, idempotency_key,
created_at_ms, queued_at_ms
) VALUES (
$1, $2, $3, $4, $5, $4, $6, 'cron', 'scheduled_system',
'runtime', $6, $7, 'queued', 2, 2, 0, $8, $9, $9
)`,
[
command.runId!,
currentClaim.projectId,
currentClaim.taskId,
execution.taskRevision,
taskName(locked.rows[0]!),
currentClaim.triggerId,
scheduledForMs,
key,
commitObservedAtMs,
],
);
await client.query(
`INSERT INTO "ql3"."run_attempts" (
id, run_id, attempt, status, executor_type,
callback_sequence, created_at_ms
) VALUES ($1, $2, 1, 'claimed', 'remote_worker', 0, $3)`,
[
command.attemptId!,
command.runId!,
commitObservedAtMs,
],
);
await client.query(
`INSERT INTO "ql3"."run_events" (
id, run_id, sequence, type, dedupe_key, actor_type,
actor_id, payload, created_at_ms
) VALUES ($1, $2, 1, 'run.created', $3, 'scheduler',
'cluster', $4::jsonb, $5)`,
[
command.createdEventId!,
command.runId!,
`cluster-schedule-created:${key}`,
JSON.stringify({
status: 'created',
version: 1,
execution_owner: 'runtime',
executor_type: execution.executorType,
execution_revision_digest: execution.contentDigest,
trigger_revision: currentClaim.triggerRevision,
trigger_content_digest: currentClaim.triggerContentDigest,
scheduled_for_ms: scheduledForMs,
}),
commitObservedAtMs,
],
);
await client.query(
`INSERT INTO "ql3"."run_events" (
id, run_id, sequence, type, dedupe_key, actor_type,
actor_id, attempt_id, payload, created_at_ms
) VALUES ($1, $2, 2, 'run.queued', $3, 'scheduler',
'cluster', $4, $5::jsonb, $6)`,
[
command.queuedEventId!,
command.runId!,
`cluster-schedule-queued:${key}`,
command.attemptId!,
JSON.stringify({
from_status: 'created',
to_status: 'queued',
version: 2,
}),
commitObservedAtMs,
],
);
}
const advanced = await client.query(
`UPDATE "ql3"."trigger_schedules"
SET next_fire_at_ms = $1,
last_scheduled_at_ms = $2,
state_version = state_version + 1,
claim_owner = NULL,
claim_token = NULL,
claim_expires_at_ms = NULL,
updated_at_ms = $3
WHERE project_id = $4 AND trigger_id = $5
AND trigger_revision = $6 AND state_version = $7
AND next_fire_at_ms IS NOT DISTINCT FROM $8
AND claim_owner = $9 AND claim_token = $10::uuid
AND claim_version = $11 AND claim_expires_at_ms = $12`,
[
command.decision.nextFireAtMs,
command.decision.disposition === 'admit'
? command.decision.scheduledForMs
: null,
commitObservedAtMs,
currentClaim.projectId,
currentClaim.triggerId,
currentClaim.triggerRevision,
currentClaim.stateVersion,
currentClaim.nextFireAtMs,
currentClaim.claimOwner,
currentClaim.claimToken,
currentClaim.claimVersion,
currentClaim.claimExpiresAtMs,
],
);
if (advanced.rowCount !== 1) {
await client.query('ROLLBACK');
began = false;
return Object.freeze({ status: 'raced' as const });
}
commitAttempted = true;
await client.query('COMMIT');
began = false;
return command.decision.disposition === 'admit'
? Object.freeze({
status: 'admitted' as const,
disposition: 'admit' as const,
runId: command.runId!,
attemptId: command.attemptId!,
})
: Object.freeze({
status: 'advanced' as const,
disposition: command.decision.disposition,
});
} catch (error) {
if (began && !commitAttempted) {
await rollbackPostgresDefinitionTransaction(client);
}
if (commitAttempted) throw unavailable();
const state = postgresSqlState(error);
if (
state &&
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
transactionAttempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
) {
continue;
}
if (error instanceof InvalidClusterScheduleError) throw error;
if (error instanceof PostgresClusterScheduleUnavailableError) {
throw error;
}
throw unavailable();
} finally {
client.release();
}
}
throw unavailable();
}
}
@@ -0,0 +1,625 @@
// PostgreSQL authority for Trigger publication and schedule binding.
import {
InvalidTriggerError,
TriggerConflictError,
TriggerSpecSemanticRegistry,
TriggerUnavailableError,
assertTriggerIdentifier,
assertTriggerPageSize,
assertTriggerRevision,
createBuiltInTriggerSpecSemanticRegistry,
createTriggerRecord,
normalizeAppendTriggerRevisionCommand,
normalizeTriggerCursor,
normalizeTriggerRecord,
type AppendTriggerRevisionCommand,
type TriggerPage,
type TriggerRecord,
type TriggerRepository,
type TriggerSource,
} from '@qinglong/runtime-core/trigger';
import {
normalizeTaskDefinitionRecord,
type TaskDefinitionRecord,
} from '@qinglong/runtime-core/task-definition';
import type {
PostgresClient,
PostgresPool,
} from '@qinglong/runtime-core';
import {
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
configurePostgresDefinitionTransaction,
postgresRequiredBoolean,
postgresRequiredInteger,
postgresRequiredJsonObject,
postgresRequiredString,
postgresSqlState,
rollbackPostgresDefinitionTransaction,
} from '../repository/definitionRepositorySupport';
type Row = Record<string, unknown>;
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
export interface PostgresTriggerRevisionTransactionContext {
readonly command: Readonly<AppendTriggerRevisionCommand>;
readonly replay: Readonly<TriggerRecord> | null;
readonly record: Readonly<TriggerRecord>;
}
export type PostgresTriggerRevisionTransactionHook = (
client: PostgresClient,
context: Readonly<PostgresTriggerRevisionTransactionContext>,
) => Promise<void>;
const SELECT_FIELDS = `
head.project_id AS "projectId",
head.trigger_id AS "triggerId",
revision.revision,
revision.mutation_id AS "mutationId",
revision.task_id AS "taskId",
revision.task_revision AS "taskRevision",
revision.task_content_digest AS "taskContentDigest",
revision.spec_json AS "specJson",
revision.enabled,
revision.content_digest AS "contentDigest",
head.created_at_ms AS "createdAtMs",
revision.created_at_ms AS "updatedAtMs"`;
const TASK_SELECT_FIELDS = `
head.project_id AS "projectId",
head.task_id AS "taskId",
revision.revision,
revision.mutation_id AS "mutationId",
revision.name,
revision.description,
revision.kind,
revision.spec_json AS "specJson",
revision.labels_json AS "labelsJson",
revision.enabled,
revision.content_digest AS "contentDigest",
head.created_at_ms AS "createdAtMs",
revision.created_at_ms AS "updatedAtMs"`;
function unavailable(): TriggerUnavailableError {
return new TriggerUnavailableError();
}
function triggerRecord(row: Row): TriggerRecord {
try {
return normalizeTriggerRecord({
projectId: postgresRequiredString(row.projectId, unavailable),
triggerId: postgresRequiredString(row.triggerId, unavailable),
revision: postgresRequiredInteger(row.revision, unavailable),
mutationId: postgresRequiredString(row.mutationId, unavailable),
taskId: postgresRequiredString(row.taskId, unavailable),
taskRevision: postgresRequiredInteger(row.taskRevision, unavailable),
taskContentDigest: postgresRequiredString(
row.taskContentDigest,
unavailable,
),
spec: postgresRequiredJsonObject(
row.specJson,
unavailable,
) as unknown as TriggerRecord['spec'],
enabled: postgresRequiredBoolean(row.enabled, unavailable),
contentDigest: postgresRequiredString(row.contentDigest, unavailable),
createdAtMs: postgresRequiredInteger(row.createdAtMs, unavailable),
updatedAtMs: postgresRequiredInteger(row.updatedAtMs, unavailable),
});
} catch (error) {
if (error instanceof TriggerUnavailableError) throw error;
throw unavailable();
}
}
function taskRecord(row: Row): TaskDefinitionRecord {
try {
const description = row.description;
if (description !== null && typeof description !== 'string') {
throw unavailable();
}
return normalizeTaskDefinitionRecord({
projectId: postgresRequiredString(row.projectId, unavailable),
taskId: postgresRequiredString(row.taskId, unavailable),
revision: postgresRequiredInteger(row.revision, unavailable),
mutationId: postgresRequiredString(row.mutationId, unavailable),
name: postgresRequiredString(row.name, unavailable),
...(description === null ? {} : { description }),
kind: postgresRequiredString(
row.kind,
unavailable,
) as TaskDefinitionRecord['kind'],
spec: postgresRequiredJsonObject(
row.specJson,
unavailable,
) as unknown as TaskDefinitionRecord['spec'],
labels: postgresRequiredJsonObject(
row.labelsJson,
unavailable,
) as TaskDefinitionRecord['labels'],
enabled: postgresRequiredBoolean(row.enabled, unavailable),
contentDigest: postgresRequiredString(row.contentDigest, unavailable),
createdAtMs: postgresRequiredInteger(row.createdAtMs, unavailable),
updatedAtMs: postgresRequiredInteger(row.updatedAtMs, unavailable),
});
} catch (error) {
if (error instanceof TriggerUnavailableError) throw error;
throw unavailable();
}
}
function sameRecord(left: TriggerRecord, right: TriggerRecord): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function mappedError(error: unknown): Error {
if (
error instanceof TriggerConflictError ||
error instanceof TriggerUnavailableError
) {
return error;
}
const state = postgresSqlState(error);
if (state === '23503' || state === '23505' || state === '23514') {
return new TriggerConflictError();
}
return unavailable();
}
async function findCurrent(
queryable: Queryable,
projectId: string,
triggerId: string,
): Promise<TriggerRecord | null> {
const result = await queryable.query<Row>(
`SELECT ${SELECT_FIELDS}
FROM "ql3"."triggers" AS head
LEFT JOIN "ql3"."trigger_revisions" AS revision
ON revision.project_id = head.project_id
AND revision.trigger_id = head.trigger_id
AND revision.revision = head.current_revision
WHERE head.project_id = $1 AND head.trigger_id = $2
LIMIT 2`,
[projectId, triggerId],
);
if (result.rows.length === 0) return null;
if (result.rows.length !== 1) throw unavailable();
return triggerRecord(result.rows[0]!);
}
async function findRevision(
queryable: Queryable,
projectId: string,
triggerId: string,
revision: number,
): Promise<TriggerRecord | null> {
const result = await queryable.query<Row>(
`SELECT ${SELECT_FIELDS}
FROM "ql3"."triggers" AS head
JOIN "ql3"."trigger_revisions" AS revision
ON revision.project_id = head.project_id
AND revision.trigger_id = head.trigger_id
WHERE head.project_id = $1 AND head.trigger_id = $2
AND revision.revision = $3
LIMIT 2`,
[projectId, triggerId, revision],
);
if (result.rows.length === 0) return null;
if (result.rows.length !== 1) throw unavailable();
return triggerRecord(result.rows[0]!);
}
async function findByMutation(
queryable: Queryable,
mutationId: string,
): Promise<TriggerRecord | null> {
const result = await queryable.query<Row>(
`SELECT ${SELECT_FIELDS}
FROM "ql3"."trigger_revisions" AS revision
JOIN "ql3"."triggers" AS head
ON head.project_id = revision.project_id
AND head.trigger_id = revision.trigger_id
WHERE revision.mutation_id = $1
LIMIT 2`,
[mutationId],
);
if (result.rows.length === 0) return null;
if (result.rows.length !== 1) throw unavailable();
return triggerRecord(result.rows[0]!);
}
async function assertPinnedTask(
queryable: Queryable,
trigger: TriggerRecord,
requireCurrent: boolean,
): Promise<void> {
const result = await queryable.query<Row>(
`SELECT ${TASK_SELECT_FIELDS}
FROM "ql3"."task_definitions" AS head
JOIN "ql3"."task_definition_revisions" AS revision
ON revision.project_id = head.project_id
AND revision.task_id = head.task_id
WHERE revision.project_id = $1 AND revision.task_id = $2
AND revision.revision = $3
AND ($4::boolean = false OR head.current_revision = revision.revision)
LIMIT 2`,
[
trigger.projectId,
trigger.taskId,
trigger.taskRevision,
requireCurrent,
],
);
if (result.rows.length === 0) throw new TriggerConflictError();
if (result.rows.length !== 1) throw unavailable();
const task = taskRecord(result.rows[0]!);
if (
task.contentDigest !== trigger.taskContentDigest ||
(trigger.enabled && !task.enabled)
) {
throw new TriggerConflictError();
}
}
async function assertScheduleBound(
queryable: Queryable,
trigger: TriggerRecord,
): Promise<void> {
const result = await queryable.query<Row>(
`SELECT trigger_revision AS "triggerRevision"
FROM "ql3"."trigger_schedules"
WHERE project_id = $1 AND trigger_id = $2
LIMIT 2`,
[trigger.projectId, trigger.triggerId],
);
if (
result.rows.length !== 1 ||
postgresRequiredInteger(
result.rows[0]?.triggerRevision,
unavailable,
) !== trigger.revision
) {
throw unavailable();
}
}
/** Read-only PostgreSQL Trigger port for cluster scheduler consumers. */
export class PostgresTriggerSource implements TriggerSource {
constructor(protected readonly pool: PostgresPool) {
if (
!pool ||
typeof pool.query !== 'function' ||
typeof pool.connect !== 'function'
) {
throw new TypeError('PostgreSQL Trigger pool is invalid');
}
}
async findCurrentTrigger(
projectId: string,
triggerId: string,
): Promise<TriggerRecord | null> {
assertTriggerIdentifier(projectId, 'projectId');
assertTriggerIdentifier(triggerId, 'triggerId');
try {
return await findCurrent(this.pool, projectId, triggerId);
} catch (error) {
throw mappedError(error);
}
}
async findTriggerRevision(
projectId: string,
triggerId: string,
revision: number,
): Promise<TriggerRecord | null> {
assertTriggerIdentifier(projectId, 'projectId');
assertTriggerIdentifier(triggerId, 'triggerId');
assertTriggerRevision(revision);
try {
return await findRevision(this.pool, projectId, triggerId, revision);
} catch (error) {
throw mappedError(error);
}
}
async listTriggers(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: { readonly triggerId: string };
}): Promise<TriggerPage> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new InvalidTriggerError('list options are invalid');
}
const keys = Object.keys(options);
if (
!keys.includes('limit') ||
!keys.includes('projectId') ||
keys.some((key) => !['after', 'limit', 'projectId'].includes(key))
) {
throw new InvalidTriggerError('list options have an invalid shape');
}
assertTriggerIdentifier(options.projectId, 'projectId');
assertTriggerPageSize(options.limit);
const after = options.after ? normalizeTriggerCursor(options.after) : undefined;
try {
const result = await this.pool.query<Row>(
`SELECT ${SELECT_FIELDS}
FROM "ql3"."triggers" AS head
LEFT JOIN "ql3"."trigger_revisions" AS revision
ON revision.project_id = head.project_id
AND revision.trigger_id = head.trigger_id
AND revision.revision = head.current_revision
WHERE head.project_id = $1 AND head.trigger_id > $2
ORDER BY head.trigger_id
LIMIT $3`,
[options.projectId, after?.triggerId ?? '', options.limit + 1],
);
const truncated = result.rows.length > options.limit;
const triggers = Object.freeze(
result.rows.slice(0, options.limit).map(triggerRecord),
);
const last = triggers.at(-1);
return Object.freeze({
triggers,
truncated,
...(truncated && last
? { next: Object.freeze({ triggerId: last.triggerId }) }
: {}),
});
} catch (error) {
throw mappedError(error);
}
}
}
/** Administration-only Trigger publisher; schedule claims remain separate. */
export class PostgresTriggerRepository
extends PostgresTriggerSource
implements TriggerRepository
{
constructor(
pool: PostgresPool,
private readonly semanticRegistry: TriggerSpecSemanticRegistry =
createBuiltInTriggerSpecSemanticRegistry(),
) {
super(pool);
}
async appendTriggerRevision(
input: AppendTriggerRevisionCommand,
transactionHook?: PostgresTriggerRevisionTransactionHook,
): Promise<
Readonly<{
status: 'created' | 'updated' | 'existing';
trigger: TriggerRecord;
}>
> {
if (
transactionHook !== undefined &&
typeof transactionHook !== 'function'
) {
throw new InvalidTriggerError('transaction hook is invalid');
}
const normalized = normalizeAppendTriggerRevisionCommand(input);
const command = Object.freeze({
...normalized,
spec: this.semanticRegistry.normalize({
projectId: normalized.projectId,
triggerId: normalized.triggerId,
taskId: normalized.taskId,
taskRevision: normalized.taskRevision,
spec: normalized.spec,
}),
});
for (
let attempt = 0;
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
attempt += 1
) {
let client: PostgresClient;
try {
client = await this.pool.connect();
} catch {
throw unavailable();
}
let began = false;
let transactionHookError: unknown;
try {
await configurePostgresDefinitionTransaction(client);
began = true;
const replay = await findByMutation(client, command.mutationId);
if (replay) {
const expected = createTriggerRecord(command, replay.createdAtMs);
if (!sameRecord(replay, expected)) throw new TriggerConflictError();
await assertPinnedTask(client, replay, false);
await assertScheduleBound(client, replay);
if (transactionHook) {
try {
const hookResult = await transactionHook(
client,
Object.freeze({ command, replay, record: replay }),
);
if (hookResult !== undefined) {
throw new InvalidTriggerError(
'transaction hook must not return a value',
);
}
} catch (error) {
transactionHookError = error;
throw error;
}
}
await client.query('COMMIT');
began = false;
return Object.freeze({ status: 'existing', trigger: replay });
}
const project = await client.query<{ status: unknown }>(
`SELECT status FROM "ql3"."projects"
WHERE id = $1`,
[command.projectId],
);
if (
project.rows.length !== 1 ||
project.rows[0]?.status !== 'active'
) {
throw new TriggerConflictError();
}
const inserted = await client.query(
`INSERT INTO "ql3"."triggers" (
project_id, trigger_id, task_id, current_revision,
created_at_ms, updated_at_ms
) VALUES ($1, $2, $3, 1, $4, $4)
ON CONFLICT (project_id, trigger_id) DO NOTHING
RETURNING trigger_id`,
[
command.projectId,
command.triggerId,
command.taskId,
command.occurredAtMs,
],
);
if (inserted.rows.length > 1) throw unavailable();
const created = inserted.rows.length === 1;
const head = await client.query<Row>(
`SELECT task_id AS "taskId",
current_revision AS "currentRevision",
created_at_ms AS "createdAtMs",
updated_at_ms AS "updatedAtMs"
FROM "ql3"."triggers"
WHERE project_id = $1 AND trigger_id = $2
FOR UPDATE`,
[command.projectId, command.triggerId],
);
if (head.rows.length !== 1) throw unavailable();
const taskId = postgresRequiredString(head.rows[0]!.taskId, unavailable);
const currentRevision = postgresRequiredInteger(
head.rows[0]!.currentRevision,
unavailable,
);
const createdAtMs = postgresRequiredInteger(
head.rows[0]!.createdAtMs,
unavailable,
);
const previousUpdatedAtMs = postgresRequiredInteger(
head.rows[0]!.updatedAtMs,
unavailable,
);
if (
(created ? command.expectedRevision !== null :
currentRevision !== command.expectedRevision) ||
taskId !== command.taskId ||
command.occurredAtMs < previousUpdatedAtMs
) {
throw new TriggerConflictError();
}
const trigger = createTriggerRecord(command, createdAtMs);
await assertPinnedTask(client, trigger, trigger.enabled);
await client.query(
`INSERT INTO "ql3"."trigger_revisions" (
project_id, trigger_id, revision, mutation_id, task_id,
task_revision, task_content_digest, spec_json, enabled,
content_digest, created_at_ms
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`,
[
trigger.projectId,
trigger.triggerId,
trigger.revision,
trigger.mutationId,
trigger.taskId,
trigger.taskRevision,
trigger.taskContentDigest,
JSON.stringify(trigger.spec),
trigger.enabled,
trigger.contentDigest,
trigger.updatedAtMs,
],
);
await client.query(
`INSERT INTO "ql3"."trigger_schedules" (
project_id, trigger_id, trigger_revision, next_fire_at_ms,
last_scheduled_at_ms, state_version, claim_owner, claim_token,
claim_version, claim_expires_at_ms, updated_at_ms
) VALUES ($1, $2, $3, NULL, NULL, 0, NULL, NULL, 0, NULL, $4)
ON CONFLICT (project_id, trigger_id) DO UPDATE SET
trigger_revision = EXCLUDED.trigger_revision,
next_fire_at_ms = NULL,
last_scheduled_at_ms = NULL,
state_version = "trigger_schedules".state_version + 1,
claim_owner = NULL,
claim_token = NULL,
claim_version = "trigger_schedules".claim_version + 1,
claim_expires_at_ms = NULL,
updated_at_ms = EXCLUDED.updated_at_ms`,
[
trigger.projectId,
trigger.triggerId,
trigger.revision,
trigger.updatedAtMs,
],
);
if (!created) {
const update = await client.query(
`UPDATE "ql3"."triggers"
SET current_revision = $1, updated_at_ms = $2
WHERE project_id = $3 AND trigger_id = $4
AND current_revision = $5 AND task_id = $6`,
[
trigger.revision,
trigger.updatedAtMs,
trigger.projectId,
trigger.triggerId,
command.expectedRevision,
trigger.taskId,
],
);
if (update.rowCount !== 1) throw new TriggerConflictError();
}
if (transactionHook) {
try {
const hookResult = await transactionHook(
client,
Object.freeze({ command, replay: null, record: trigger }),
);
if (hookResult !== undefined) {
throw new InvalidTriggerError(
'transaction hook must not return a value',
);
}
} catch (error) {
transactionHookError = error;
throw error;
}
}
await client.query('COMMIT');
began = false;
return Object.freeze({
status: created ? 'created' : 'updated',
trigger,
});
} catch (error) {
if (began) await rollbackPostgresDefinitionTransaction(client);
const state = postgresSqlState(error);
if (
state &&
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
) {
continue;
}
if (error === transactionHookError && error instanceof Error) {
throw error;
}
throw mappedError(error);
} finally {
client.release();
}
}
throw unavailable();
}
}