mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add local run log retention
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user