feat(ql3): add local run log retention

This commit is contained in:
whyour
2026-08-12 02:57:43 +08:00
parent 308aa75d89
commit 2bfa8ca279
50 changed files with 2752 additions and 85 deletions
@@ -96,6 +96,8 @@ import { local0083PluginPackageWorkflowTaskAttemptAdmissionsMigration } from '..
import { local0084CapabilityV42Migration } from '../migrations/0084-capability-v42';
import { local0085PluginPackageWorkflowRunListIndexMigration } from '../migrations/0085-plugin-package-workflow-run-list-index';
import { local0086CapabilityV43Migration } from '../migrations/0086-capability-v43';
import { local0087RunAttemptLogRetentionMigration } from '../migrations/0087-run-attempt-log-retention';
import { local0088CapabilityV44Migration } from '../migrations/0088-capability-v44';
import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration';
import {
LOCAL_SQLITE_MIGRATION_STREAM_ID,
@@ -204,6 +206,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition<LocalSqli
local0084CapabilityV42Migration,
local0085PluginPackageWorkflowRunListIndexMigration,
local0086CapabilityV43Migration,
local0087RunAttemptLogRetentionMigration,
local0088CapabilityV44Migration,
]),
});
@@ -442,5 +442,15 @@ export const localSqliteMigrationManifest: MigrationStreamManifest =
checksum:
'd7affd7b3d1f3719dabc7abc7d5e8a2880fc4dc455b5103585befe9b51f705f9',
}),
Object.freeze({
id: '0087-run-attempt-log-retention',
checksum:
'b13088c1926150ada6f010d7c694b3cf603ad44646991e77c713b882aba0e416',
}),
Object.freeze({
id: '0088-capability-v44',
checksum:
'c47a61b140b54d448c30ce7d5f7927c0d16fb897ab36dbe1d6010da2c39075a7',
}),
]),
});
@@ -0,0 +1,79 @@
import { defineLocalSqliteMigration } from './sqlMigration';
export const local0087RunAttemptLogRetentionMigration =
defineLocalSqliteMigration({
id: '0087-run-attempt-log-retention',
statements: [
`
CREATE TABLE "QingLong3RunAttemptLogArtifactTombstones" (
log_artifact_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
executor_type TEXT NOT NULL
CONSTRAINT ql3_run_log_tombstone_executor_check
CHECK (executor_type = 'local_process'),
finished_at_ms INTEGER NOT NULL,
eligible_at_ms INTEGER NOT NULL,
retired_at_ms INTEGER NOT NULL,
disposition TEXT NOT NULL
CONSTRAINT ql3_run_log_tombstone_disposition_check
CHECK (disposition IN ('deleted','already_absent')),
byte_length INTEGER NOT NULL,
truncated TEXT NOT NULL
CONSTRAINT ql3_run_log_tombstone_truncated_check
CHECK (truncated IN ('true','false','unknown')),
maximum_bytes INTEGER,
truncation_observed_at_ms INTEGER,
record_digest TEXT NOT NULL,
CONSTRAINT ql3_run_log_tombstone_identity_check CHECK (
length(project_id) BETWEEN 1 AND 128 AND
length(run_id) BETWEEN 1 AND 128 AND
length(attempt_id) BETWEEN 1 AND 128 AND
length(log_artifact_id) = 36 AND
substr(log_artifact_id, 1, 6) = 'local-' AND
substr(log_artifact_id, 7) NOT GLOB '*[^0-9a-f]*'
),
CONSTRAINT ql3_run_log_tombstone_time_check CHECK (
finished_at_ms >= 0 AND
eligible_at_ms >= finished_at_ms AND
retired_at_ms >= eligible_at_ms
),
CONSTRAINT ql3_run_log_tombstone_size_check CHECK (
byte_length BETWEEN 0 AND 1073741824 AND
(disposition <> 'already_absent' OR byte_length = 0)
),
CONSTRAINT ql3_run_log_tombstone_truncation_shape_check CHECK (
(truncated = 'unknown' AND maximum_bytes IS NULL AND truncation_observed_at_ms IS NULL) OR
(truncated IN ('true','false') AND maximum_bytes >= 1 AND truncation_observed_at_ms >= 0)
),
CONSTRAINT ql3_run_log_tombstone_digest_check CHECK (
length(record_digest) = 64 AND record_digest NOT GLOB '*[^0-9a-f]*'
),
CONSTRAINT ql3_run_log_tombstone_attempt_fk
FOREIGN KEY (attempt_id) REFERENCES "RunAttempts" (id) ON DELETE CASCADE,
CONSTRAINT ql3_run_log_tombstone_run_fk
FOREIGN KEY (run_id) REFERENCES "Runs" (id) ON DELETE CASCADE
)
`,
`CREATE INDEX ql3_run_log_tombstone_retired_idx ON "QingLong3RunAttemptLogArtifactTombstones" (retired_at_ms, attempt_id)`,
`CREATE UNIQUE INDEX ql3_run_log_tombstone_attempt_uidx ON "QingLong3RunAttemptLogArtifactTombstones" (attempt_id)`,
`CREATE INDEX ql3_run_log_retention_candidate_idx ON "RunAttempts" (executor_type, status, finished_at_ms, id) WHERE log_artifact_id IS NOT NULL`,
`
CREATE TABLE "QingLong3RunAttemptLogRetentionState" (
maintenance_id TEXT PRIMARY KEY
CONSTRAINT ql3_run_log_retention_state_id_check
CHECK (maintenance_id = 'local-run-attempt-log'),
cursor_finished_at_ms INTEGER,
cursor_attempt_id TEXT,
updated_at_ms INTEGER NOT NULL,
CONSTRAINT ql3_run_log_retention_state_cursor_check CHECK (
(cursor_finished_at_ms IS NULL AND cursor_attempt_id IS NULL) OR
(cursor_finished_at_ms >= 0 AND length(cursor_attempt_id) BETWEEN 1 AND 128)
),
CONSTRAINT ql3_run_log_retention_state_time_check CHECK (updated_at_ms >= 0)
)
`,
`INSERT INTO "QingLong3RunAttemptLogRetentionState" (maintenance_id, cursor_finished_at_ms, cursor_attempt_id, updated_at_ms) VALUES ('local-run-attempt-log', NULL, NULL, 0)`,
],
});
@@ -0,0 +1,24 @@
import { CAPABILITIES_V43 } from './0086-capability-v43';
import { defineLocalSqliteMigration } from './sqlMigration';
export const CAPABILITIES_V44 = CAPABILITIES_V43.replace(
'"plugin_package_workflow_run_list":1,',
'"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,',
);
export const local0088CapabilityV44Migration = defineLocalSqliteMigration({
id: '0088-capability-v44',
statements: [
`
UPDATE "QingLong3SchemaCapabilities"
SET contract_version = 44,
migration_id = '0087-run-attempt-log-retention',
capabilities = '${CAPABILITIES_V44}',
updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER)
WHERE contract_name = 'local-control-core'
AND contract_version = 43
AND migration_id = '0085-plugin-package-workflow-run-list-index'
AND capabilities = '${CAPABILITIES_V43}'
`,
],
});
@@ -46,6 +46,7 @@ export type LocalProfileStorageBootstrapResult =
readonly dispatch: LocalSqliteRuntimeDatabase['localDispatch'];
readonly executionControl: LocalSqliteRuntimeDatabase['executionControl'];
readonly completionReceipts: LocalSqliteRuntimeDatabase['completionReceipts'];
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
@@ -138,6 +139,7 @@ export async function bootstrapLocalProfileStorage(
dispatch: database.localDispatch,
executionControl: database.executionControl,
completionReceipts: database.completionReceipts,
runAttemptLogRetention: database.runAttemptLogRetention,
localSecrets: database.localSecrets,
localSecretAdministration: database.localSecretAdministration,
projectPolicy: database.projectPolicy,
@@ -8,7 +8,7 @@ import {
} from '../run/stepRunSchemaContract';
export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core';
export const LOCAL_SQLITE_CONTRACT_VERSION = 43;
export const LOCAL_SQLITE_CONTRACT_VERSION = 44;
const OPTIONAL_FEATURE_TABLE_NAMES = new Set([
'QingLong3AiSchemaMigrations',
@@ -164,6 +164,7 @@ const REQUIRED_SCHEMA = Object.freeze({
'ql3_local_attempts_run_status_idx',
'ql3_local_attempts_lease_idx',
'ql3_local_attempts_deadline_idx',
'ql3_run_log_retention_candidate_idx',
]),
}),
RunEvents: Object.freeze({
@@ -514,6 +515,37 @@ const REQUIRED_SCHEMA = Object.freeze({
'ql3_local_receipt_journal_purge_idx',
]),
}),
QingLong3RunAttemptLogArtifactTombstones: Object.freeze({
columns: Object.freeze([
'log_artifact_id',
'project_id',
'run_id',
'attempt_id',
'executor_type',
'finished_at_ms',
'eligible_at_ms',
'retired_at_ms',
'disposition',
'byte_length',
'truncated',
'maximum_bytes',
'truncation_observed_at_ms',
'record_digest',
]),
indexes: Object.freeze([
'ql3_run_log_tombstone_attempt_uidx',
'ql3_run_log_tombstone_retired_idx',
]),
}),
QingLong3RunAttemptLogRetentionState: Object.freeze({
columns: Object.freeze([
'maintenance_id',
'cursor_finished_at_ms',
'cursor_attempt_id',
'updated_at_ms',
]),
indexes: Object.freeze([]),
}),
QingLong3LocalExecutionContextRecipes: Object.freeze({
columns: Object.freeze([
'context_ref',
@@ -2464,11 +2496,10 @@ export async function auditLocalSqliteReadiness(
!capability ||
capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME ||
capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION ||
capability.migration_id !==
'0085-plugin-package-workflow-run-list-index' ||
capability.migration_id !== '0087-run-attempt-log-retention' ||
typeof capability.capabilities !== 'string' ||
capability.capabilities !==
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"plugin_package_workflow_task_attempt_admission":1}' ||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
typeof capability.updated_at_ms !== 'number' ||
!Number.isSafeInteger(capability.updated_at_ms) ||
capability.updated_at_ms < 0
@@ -0,0 +1,428 @@
import {
InvalidRunAttemptLogRetentionError,
MAX_RUN_ATTEMPT_LOG_RETENTION_PAGE_SIZE,
RunAttemptLogRetentionUnavailableError,
normalizeRunAttemptLogRetentionCandidate,
normalizeRunAttemptLogRetentionCursor,
normalizeRunAttemptLogRetirementRecord,
type RunAttemptLogRetentionCursor,
type RunAttemptLogRetentionPage,
type RunAttemptLogRetentionRepository,
type RunAttemptLogRetirementRecord,
} from '@qinglong/runtime-core/run-attempt-log-retention';
import type { RunAttemptLogReadIdentity } from '@qinglong/runtime-core/run-attempt-log-read';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type Row = Record<string, unknown>;
const LOCAL_ARTIFACT_ID = /^local-[a-f0-9]{30}$/;
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TOMBSTONE_SELECT = `
tombstone."log_artifact_id" AS "logArtifactId",
tombstone."project_id" AS "projectId",
tombstone."run_id" AS "runId",
tombstone."attempt_id" AS "attemptId",
tombstone."executor_type" AS "executorType",
tombstone."finished_at_ms" AS "finishedAtMs",
tombstone."eligible_at_ms" AS "eligibleAtMs",
tombstone."retired_at_ms" AS "retiredAtMs",
tombstone."disposition" AS "disposition",
tombstone."byte_length" AS "byteLength",
tombstone."truncated" AS "truncated",
tombstone."maximum_bytes" AS "maximumBytes",
tombstone."truncation_observed_at_ms" AS "truncationObservedAtMs",
tombstone."record_digest" AS "recordDigest"
`;
function text(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new RunAttemptLogRetentionUnavailableError();
}
return value;
}
function integer(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value) || Number(value) < 0) {
throw new RunAttemptLogRetentionUnavailableError();
}
return Number(value);
}
function optionalInteger(row: Row, key: string): number | undefined {
return row[key] === null ? undefined : integer(row, key);
}
function identity(
value: Readonly<RunAttemptLogReadIdentity>,
): Readonly<RunAttemptLogReadIdentity> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).sort().join(',') !==
'attemptId,logArtifactId,projectId,runId' ||
!ID_PATTERN.test(value.projectId) ||
!ID_PATTERN.test(value.runId) ||
!ID_PATTERN.test(value.attemptId) ||
!LOCAL_ARTIFACT_ID.test(value.logArtifactId)
) {
throw new InvalidRunAttemptLogRetentionError('identity is invalid');
}
return Object.freeze({ ...value });
}
function tombstone(row: Row): Readonly<RunAttemptLogRetirementRecord> {
const truncated = text(row, 'truncated');
return normalizeRunAttemptLogRetirementRecord({
schema: 'qinglong/run-attempt-log-retirement@v1',
projectId: text(row, 'projectId'),
runId: text(row, 'runId'),
attemptId: text(row, 'attemptId'),
logArtifactId: text(row, 'logArtifactId'),
executorType: text(row, 'executorType') as 'local_process',
finishedAtMs: integer(row, 'finishedAtMs'),
eligibleAtMs: integer(row, 'eligibleAtMs'),
retiredAtMs: integer(row, 'retiredAtMs'),
disposition: text(row, 'disposition') as 'deleted' | 'already_absent',
byteLength: integer(row, 'byteLength'),
truncation:
truncated === 'unknown'
? Object.freeze({ truncated: 'unknown' as const })
: Object.freeze({
truncated: truncated === 'true',
maximumBytes: optionalInteger(row, 'maximumBytes')!,
observedAtMs: optionalInteger(row, 'truncationObservedAtMs')!,
}),
recordDigest: text(row, 'recordDigest'),
});
}
function unavailable(error?: unknown): RunAttemptLogRetentionUnavailableError {
return new RunAttemptLogRetentionUnavailableError(
error === undefined ? undefined : { cause: error },
);
}
export class LocalSqliteRunAttemptLogRetentionRepository
implements RunAttemptLogRetentionRepository
{
constructor(private readonly authority: LocalSqliteOperationAuthority) {
if (!(authority instanceof LocalSqliteOperationAuthority)) {
throw new TypeError(
'Local SQLite Run Attempt log retention authority is invalid',
);
}
}
inspect(rawIdentity: Readonly<RunAttemptLogReadIdentity>) {
const expected = identity(rawIdentity);
return this.authority.enqueue(
async () => {
try {
const row = this.authority.client
.prepare(
`SELECT ${TOMBSTONE_SELECT}
FROM "QingLong3RunAttemptLogArtifactTombstones" AS tombstone
WHERE tombstone."log_artifact_id" = ?`,
)
.get(expected.logArtifactId) as Row | undefined;
if (!row) return Object.freeze({ status: 'active' as const });
const record = tombstone(row);
if (
record.projectId !== expected.projectId ||
record.runId !== expected.runId ||
record.attemptId !== expected.attemptId ||
record.logArtifactId !== expected.logArtifactId
) {
throw unavailable();
}
return Object.freeze({ status: 'retired' as const, record });
} catch (error) {
if (error instanceof RunAttemptLogRetentionUnavailableError) {
throw error;
}
throw unavailable(error);
}
},
() => unavailable(),
);
}
loadCursor(): Promise<Readonly<RunAttemptLogRetentionCursor> | undefined> {
return this.authority.enqueue(
async () => {
try {
const row = this.authority.client
.prepare(
`SELECT cursor_finished_at_ms AS "finishedAtMs",
cursor_attempt_id AS "attemptId"
FROM "QingLong3RunAttemptLogRetentionState"
WHERE maintenance_id = 'local-run-attempt-log'`,
)
.get() as Row | undefined;
if (!row) throw unavailable();
if (row.finishedAtMs === null && row.attemptId === null) {
return undefined;
}
return normalizeRunAttemptLogRetentionCursor({
finishedAtMs: integer(row, 'finishedAtMs'),
attemptId: text(row, 'attemptId'),
});
} catch (error) {
if (error instanceof RunAttemptLogRetentionUnavailableError) {
throw error;
}
throw unavailable(error);
}
},
() => unavailable(),
);
}
list(input: {
readonly cutoffMs: number;
readonly limit: number;
readonly cursor?: Readonly<RunAttemptLogRetentionCursor>;
}): Promise<RunAttemptLogRetentionPage> {
if (
!input ||
typeof input !== 'object' ||
Array.isArray(input) ||
!Number.isSafeInteger(input.cutoffMs) ||
input.cutoffMs < 0 ||
!Number.isSafeInteger(input.limit) ||
input.limit < 1 ||
input.limit > MAX_RUN_ATTEMPT_LOG_RETENTION_PAGE_SIZE
) {
throw new InvalidRunAttemptLogRetentionError('list input is invalid');
}
const cursor =
input.cursor === undefined
? undefined
: normalizeRunAttemptLogRetentionCursor(input.cursor);
return this.authority.enqueue(
async () => {
try {
const rows = this.authority.client
.prepare(
`SELECT run.project_id AS "projectId",
attempt.run_id AS "runId",
attempt.id AS "attemptId",
attempt.log_artifact_id AS "logArtifactId",
attempt.executor_type AS "executorType",
attempt.finished_at_ms AS "finishedAtMs"
FROM "RunAttempts" AS attempt
JOIN "Runs" AS run ON run.id = attempt.run_id
WHERE run.execution_owner = 'runtime'
AND run.status IN ('succeeded','failed','cancelled','timed_out')
AND attempt.status IN ('succeeded','failed','cancelled','timed_out')
AND attempt.executor_type = 'local_process'
AND attempt.finished_at_ms IS NOT NULL
AND run.finished_at_ms IS NOT NULL
AND attempt.finished_at_ms <= ?
AND run.finished_at_ms <= ?
AND length(attempt.log_artifact_id) = 36
AND substr(attempt.log_artifact_id, 1, 6) = 'local-'
AND substr(attempt.log_artifact_id, 7) NOT GLOB '*[^0-9a-f]*'
AND NOT EXISTS (
SELECT 1 FROM "LocalCompletionReceiptJournal" AS receipt
WHERE receipt.attempt_id = attempt.id
)
AND NOT EXISTS (
SELECT 1
FROM "QingLong3RunAttemptLogArtifactTombstones" AS tombstone
WHERE tombstone.attempt_id = attempt.id
OR tombstone.log_artifact_id = attempt.log_artifact_id
)
AND (
? IS NULL OR attempt.finished_at_ms > ? OR
(attempt.finished_at_ms = ? AND attempt.id > ?)
)
ORDER BY attempt.finished_at_ms, attempt.id
LIMIT ?`,
)
.all(
input.cutoffMs,
input.cutoffMs,
cursor?.finishedAtMs ?? null,
cursor?.finishedAtMs ?? null,
cursor?.finishedAtMs ?? null,
cursor?.attemptId ?? null,
input.limit + 1,
) as Row[];
const truncated = rows.length > input.limit;
const candidates = rows.slice(0, input.limit).map((row) =>
normalizeRunAttemptLogRetentionCandidate({
projectId: text(row, 'projectId'),
runId: text(row, 'runId'),
attemptId: text(row, 'attemptId'),
logArtifactId: text(row, 'logArtifactId'),
executorType: text(row, 'executorType') as 'local_process',
finishedAtMs: integer(row, 'finishedAtMs'),
}),
);
const last = candidates.at(-1);
return Object.freeze({
candidates: Object.freeze(candidates),
truncated,
...(truncated && last
? {
nextCursor: Object.freeze({
finishedAtMs: last.finishedAtMs,
attemptId: last.attemptId,
}),
}
: {}),
});
} catch (error) {
throw unavailable(error);
}
},
() => unavailable(),
);
}
record(raw: Readonly<RunAttemptLogRetirementRecord>) {
const record = normalizeRunAttemptLogRetirementRecord(raw);
if (
record.executorType !== 'local_process' ||
!LOCAL_ARTIFACT_ID.test(record.logArtifactId)
) {
throw new InvalidRunAttemptLogRetentionError(
'Local retirement record is invalid',
);
}
return this.authority.enqueue(
async () => {
const client = this.authority.client;
client.exec('BEGIN IMMEDIATE');
try {
const replay = client
.prepare(
`SELECT ${TOMBSTONE_SELECT}
FROM "QingLong3RunAttemptLogArtifactTombstones" AS tombstone
WHERE tombstone.log_artifact_id = ? OR tombstone.attempt_id = ?`,
)
.get(record.logArtifactId, record.attemptId) as Row | undefined;
if (replay) {
const existing = tombstone(replay);
if (existing.recordDigest !== record.recordDigest) {
throw unavailable();
}
client.exec('COMMIT');
return 'existing' as const;
}
const eligible = client
.prepare(
`SELECT 1 AS eligible
FROM "RunAttempts" AS attempt
JOIN "Runs" AS run ON run.id = attempt.run_id
WHERE attempt.id = ?
AND attempt.run_id = ?
AND attempt.log_artifact_id = ?
AND attempt.executor_type = 'local_process'
AND attempt.finished_at_ms = ?
AND attempt.status IN ('succeeded','failed','cancelled','timed_out')
AND run.project_id = ?
AND run.execution_owner = 'runtime'
AND run.status IN ('succeeded','failed','cancelled','timed_out')
AND run.finished_at_ms IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "LocalCompletionReceiptJournal" AS receipt
WHERE receipt.attempt_id = attempt.id
)`,
)
.get(
record.attemptId,
record.runId,
record.logArtifactId,
record.finishedAtMs,
record.projectId,
);
if (!eligible) throw unavailable();
client
.prepare(
`INSERT INTO "QingLong3RunAttemptLogArtifactTombstones" (
log_artifact_id, project_id, run_id, attempt_id, executor_type,
finished_at_ms, eligible_at_ms, retired_at_ms, disposition,
byte_length, truncated, maximum_bytes,
truncation_observed_at_ms, record_digest
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
record.logArtifactId,
record.projectId,
record.runId,
record.attemptId,
record.executorType,
record.finishedAtMs,
record.eligibleAtMs,
record.retiredAtMs,
record.disposition,
record.byteLength,
String(record.truncation.truncated),
record.truncation.maximumBytes ?? null,
record.truncation.observedAtMs ?? null,
record.recordDigest,
);
client.exec('COMMIT');
return 'recorded' as const;
} catch (error) {
try {
client.exec('ROLLBACK');
} catch {
// Preserve the retention failure.
}
if (error instanceof RunAttemptLogRetentionUnavailableError) {
throw error;
}
throw unavailable(error);
}
},
() => unavailable(),
);
}
saveCursor(
rawCursor: Readonly<RunAttemptLogRetentionCursor> | undefined,
updatedAtMs: number,
): Promise<void> {
const cursor =
rawCursor === undefined
? undefined
: normalizeRunAttemptLogRetentionCursor(rawCursor);
if (!Number.isSafeInteger(updatedAtMs) || updatedAtMs < 0) {
throw new InvalidRunAttemptLogRetentionError(
'cursor update time is invalid',
);
}
return this.authority.enqueue(
async () => {
try {
const result = this.authority.client
.prepare(
`UPDATE "QingLong3RunAttemptLogRetentionState"
SET cursor_finished_at_ms = ?, cursor_attempt_id = ?, updated_at_ms = ?
WHERE maintenance_id = 'local-run-attempt-log'`,
)
.run(
cursor?.finishedAtMs ?? null,
cursor?.attemptId ?? null,
updatedAtMs,
);
if (result.changes !== 1) throw unavailable();
} catch (error) {
if (error instanceof RunAttemptLogRetentionUnavailableError) {
throw error;
}
throw unavailable(error);
}
},
() => unavailable(),
);
}
}
@@ -62,6 +62,7 @@ import type { LocalSqliteWorkflowTaskExecutionRepository } from '../plugin-packa
import type { LocalSqlitePluginPackageWorkflowFrontierRepository } from '../plugin-package/workflow/pluginPackageWorkflowFrontierRepository';
import type { LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository } from '../plugin-package/workflow/pluginPackageWorkflowTaskAttemptAdmissionRepository';
import type { LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository } from '../plugin-package/workflow/pluginPackageWorkflowCancellationConvergenceRepository';
import { LocalSqliteRunAttemptLogRetentionRepository } from '../run/runAttemptLogRetentionRepository';
export interface LocalSqliteRuntimeDependencies {
readonly taskSpecSemanticRegistry?: TaskSpecSemanticRegistry;
@@ -97,6 +98,7 @@ export interface LocalSqliteRuntimeDatabase {
readonly localDispatch: LocalDispatchStore;
readonly executionControl: LocalExecutionControlSource;
readonly completionReceipts: LocalCompletionReceiptJournal;
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
readonly projectPolicy: ProjectPolicyRepository;
@@ -179,6 +181,8 @@ export async function openLocalSqliteRuntimeDatabase(
const schedules = new LocalSqliteScheduleRepository(authority);
const apiCredentials = new LocalSqliteApiCredentialRepository(authority);
const ownerPepper = new LocalSqliteOwnerPepperRepository(authority);
const runAttemptLogRetention =
new LocalSqliteRunAttemptLogRetentionRepository(authority);
let pluginPackageInstallsPromise:
| Promise<PluginPackageInstallRepository>
| undefined;
@@ -232,6 +236,7 @@ export async function openLocalSqliteRuntimeDatabase(
localDispatch: runRuntimeCapabilities.dispatch,
executionControl: runRuntimeCapabilities.executionControl,
completionReceipts: runRuntimeCapabilities.completionReceipts,
runAttemptLogRetention,
localSecrets: securityAuthority,
localSecretAdministration: securityAuthority,
projectPolicy,
@@ -331,6 +331,9 @@ export const runAttempts = sqliteTable(
table.deadlineAtMs,
table.id,
),
index('ql3_run_log_retention_candidate_idx')
.on(table.executorType, table.status, table.finishedAtMs, table.id)
.where(sql`${table.logArtifactId} is not null`),
],
);
@@ -511,6 +514,93 @@ export const localCompletionReceiptJournal = sqliteTable(
],
);
export const runAttemptLogArtifactTombstones = sqliteTable(
'QingLong3RunAttemptLogArtifactTombstones',
{
logArtifactId: text('log_artifact_id').primaryKey(),
projectId: text('project_id').notNull(),
runId: text('run_id')
.notNull()
.references(() => runs.id, { onDelete: 'cascade' }),
attemptId: text('attempt_id')
.notNull()
.references(() => runAttempts.id, { onDelete: 'cascade' }),
executorType: text('executor_type').notNull(),
finishedAtMs: integer('finished_at_ms').notNull(),
eligibleAtMs: integer('eligible_at_ms').notNull(),
retiredAtMs: integer('retired_at_ms').notNull(),
disposition: text('disposition').notNull(),
byteLength: integer('byte_length').notNull(),
truncated: text('truncated').notNull(),
maximumBytes: integer('maximum_bytes'),
truncationObservedAtMs: integer('truncation_observed_at_ms'),
recordDigest: text('record_digest').notNull(),
},
(table) => [
uniqueIndex('ql3_run_log_tombstone_attempt_uidx').on(table.attemptId),
index('ql3_run_log_tombstone_retired_idx').on(
table.retiredAtMs,
table.attemptId,
),
check(
'ql3_run_log_tombstone_executor_check',
sql`${table.executorType} = 'local_process'`,
),
check(
'ql3_run_log_tombstone_disposition_check',
sql`${table.disposition} in ('deleted','already_absent')`,
),
check(
'ql3_run_log_tombstone_truncated_check',
sql`${table.truncated} in ('true','false','unknown')`,
),
check(
'ql3_run_log_tombstone_identity_check',
sql`length(${table.projectId}) between 1 and 128 and length(${table.runId}) between 1 and 128 and length(${table.attemptId}) between 1 and 128 and length(${table.logArtifactId}) = 36 and substr(${table.logArtifactId}, 1, 6) = 'local-' and substr(${table.logArtifactId}, 7) not glob '*[^0-9a-f]*'`,
),
check(
'ql3_run_log_tombstone_time_check',
sql`${table.finishedAtMs} >= 0 and ${table.eligibleAtMs} >= ${table.finishedAtMs} and ${table.retiredAtMs} >= ${table.eligibleAtMs}`,
),
check(
'ql3_run_log_tombstone_size_check',
sql`${table.byteLength} between 0 and 1073741824 and (${table.disposition} <> 'already_absent' or ${table.byteLength} = 0)`,
),
check(
'ql3_run_log_tombstone_truncation_shape_check',
sql`(${table.truncated} = 'unknown' and ${table.maximumBytes} is null and ${table.truncationObservedAtMs} is null) or (${table.truncated} in ('true','false') and ${table.maximumBytes} >= 1 and ${table.truncationObservedAtMs} >= 0)`,
),
check(
'ql3_run_log_tombstone_digest_check',
sql`length(${table.recordDigest}) = 64 and ${table.recordDigest} not glob '*[^0-9a-f]*'`,
),
],
);
export const runAttemptLogRetentionState = sqliteTable(
'QingLong3RunAttemptLogRetentionState',
{
maintenanceId: text('maintenance_id').primaryKey(),
cursorFinishedAtMs: integer('cursor_finished_at_ms'),
cursorAttemptId: text('cursor_attempt_id'),
updatedAtMs: integer('updated_at_ms').notNull(),
},
(table) => [
check(
'ql3_run_log_retention_state_id_check',
sql`${table.maintenanceId} = 'local-run-attempt-log'`,
),
check(
'ql3_run_log_retention_state_cursor_check',
sql`(${table.cursorFinishedAtMs} is null and ${table.cursorAttemptId} is null) or (${table.cursorFinishedAtMs} >= 0 and length(${table.cursorAttemptId}) between 1 and 128)`,
),
check(
'ql3_run_log_retention_state_time_check',
sql`${table.updatedAtMs} >= 0`,
),
],
);
export const localExecutionContextRecipes = sqliteTable(
'QingLong3LocalExecutionContextRecipes',
{
@@ -4702,16 +4792,12 @@ export const pluginPackageWorkflowTaskAttemptAdmissions = sqliteTable(
.onDelete('restrict')
.onUpdate('restrict'),
foreignKey({
columns: [
table.generationDigest,
table.taskReconciliationReceiptDigest,
],
columns: [table.generationDigest, table.taskReconciliationReceiptDigest],
foreignColumns: [
pluginPackageTaskReconciliations.generationDigest,
pluginPackageTaskReconciliations.receiptDigest,
],
name:
'ql3_plugin_package_workflow_task_attempt_admission_reconciliation_fk',
name: 'ql3_plugin_package_workflow_task_attempt_admission_reconciliation_fk',
})
.onDelete('restrict')
.onUpdate('restrict'),
@@ -4770,6 +4856,8 @@ export const localSqliteSchema = Object.freeze({
stepRunMutations,
runRetryPolicies,
localCompletionReceiptJournal,
runAttemptLogArtifactTombstones,
runAttemptLogRetentionState,
localExecutionContextRecipes,
localTaskExecutionRevisions,
localSecretEnvelopes,