mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+650
@@ -0,0 +1,650 @@
|
||||
// PostgreSQL adapter owned by Plugin Package publication and recovery.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageAutomationPublicationError,
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES,
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
PluginPackageAutomationPublicationUnavailableError,
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize,
|
||||
assertPluginPackageAutomationPublicationSuccessor,
|
||||
normalizePluginPackageAutomationPublication,
|
||||
normalizePluginPackageAutomationPublicationRecoveryCursor,
|
||||
type PluginPackageAutomationPublication,
|
||||
type PluginPackageAutomationPublicationRecoveryPage,
|
||||
type PluginPackageAutomationPublicationRepository,
|
||||
type PluginPackageAutomationPublicationRecoverySource,
|
||||
type PluginPackageAutomationPublicationStartGuard,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageAutomationPublicationError(message);
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
error?: unknown,
|
||||
): PluginPackageAutomationPublicationUnavailableError {
|
||||
return new PluginPackageAutomationPublicationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function targetIdentity(projectId: unknown, packageName: unknown): {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
} {
|
||||
if (typeof projectId !== 'string' || !IDENTIFIER.test(projectId)) {
|
||||
return invalid('projectId is invalid');
|
||||
}
|
||||
if (typeof packageName !== 'string' || !PACKAGE_NAME.test(packageName)) {
|
||||
return invalid('packageName is invalid');
|
||||
}
|
||||
return { projectId, packageName };
|
||||
}
|
||||
|
||||
function publicationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('publicationDigest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(
|
||||
publication: Readonly<PluginPackageAutomationPublication>,
|
||||
): string {
|
||||
const value = JSON.stringify(publication);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES
|
||||
) {
|
||||
return invalid('publication exceeds the durable JSON budget');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageAutomationPublicationError ||
|
||||
error instanceof PluginPackageAutomationPublicationConflictError ||
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state === '23503' ||
|
||||
state === '23505' ||
|
||||
state === '23514' ||
|
||||
state === '40001'
|
||||
) {
|
||||
return new PluginPackageAutomationPublicationConflictError(
|
||||
'durable publication chain changed',
|
||||
);
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageAutomationPublicationRepository
|
||||
implements
|
||||
PluginPackageAutomationPublicationRepository,
|
||||
PluginPackageAutomationPublicationRecoverySource,
|
||||
PluginPackageAutomationPublicationStartGuard
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL automation publication repository is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#parse(row: Row): Readonly<PluginPackageAutomationPublication> {
|
||||
try {
|
||||
const publication = normalizePluginPackageAutomationPublication(
|
||||
postgresRequiredJsonObject(
|
||||
row.publicationJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageAutomationPublication,
|
||||
);
|
||||
if (
|
||||
publication.publicationDigest !==
|
||||
postgresRequiredString(row.publicationDigest, unavailable) ||
|
||||
publication.target.projectId !==
|
||||
postgresRequiredString(row.projectId, unavailable) ||
|
||||
publication.target.packageName !==
|
||||
postgresRequiredString(row.packageName, unavailable) ||
|
||||
publication.target.installationId !==
|
||||
postgresRequiredString(row.installationId, unavailable) ||
|
||||
publication.target.lockDigest !==
|
||||
postgresRequiredString(row.lockDigest, unavailable) ||
|
||||
publication.target.generation !==
|
||||
postgresRequiredInteger(row.generation, unavailable) ||
|
||||
publication.target.generationDigest !==
|
||||
postgresRequiredString(row.generationDigest, unavailable) ||
|
||||
publication.target.materializedRevisionDigest !==
|
||||
postgresRequiredString(row.materializedRevisionDigest, unavailable) ||
|
||||
publication.state !==
|
||||
postgresRequiredString(row.state, unavailable) ||
|
||||
publication.version !==
|
||||
postgresRequiredInteger(row.version, unavailable) ||
|
||||
publication.publishedAtMs !==
|
||||
postgresRequiredInteger(row.publishedAtMs, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return publication;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #findByDigest(
|
||||
queryable: Queryable,
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT publication_digest AS "publicationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest",
|
||||
generation,
|
||||
generation_digest AS "generationDigest",
|
||||
materialized_revision_digest AS "materializedRevisionDigest",
|
||||
state,
|
||||
version,
|
||||
published_at_ms AS "publishedAtMs",
|
||||
publication_json AS "publicationJson"
|
||||
FROM "ql3"."plugin_package_automation_publications"
|
||||
WHERE publication_digest = $1
|
||||
LIMIT 2`,
|
||||
[digest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return this.#parse(result.rows[0]!);
|
||||
}
|
||||
|
||||
async #findCurrent(
|
||||
queryable: Queryable,
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
lock = false,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT publication.publication_digest AS "publicationDigest",
|
||||
publication.project_id AS "projectId",
|
||||
publication.package_name AS "packageName",
|
||||
publication.installation_id AS "installationId",
|
||||
publication.lock_digest AS "lockDigest",
|
||||
publication.generation,
|
||||
publication.generation_digest AS "generationDigest",
|
||||
publication.materialized_revision_digest
|
||||
AS "materializedRevisionDigest",
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.published_at_ms AS "publishedAtMs",
|
||||
publication.publication_json AS "publicationJson"
|
||||
FROM "ql3"."plugin_package_automation_publication_heads" AS head
|
||||
JOIN "ql3"."plugin_package_automation_publications" AS publication
|
||||
ON publication.publication_digest = head.publication_digest
|
||||
WHERE head.project_id = $1 AND head.package_name = $2
|
||||
LIMIT 2${lock ? ' FOR UPDATE OF head' : ''}`,
|
||||
[projectId, packageName],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return this.#parse(result.rows[0]!);
|
||||
}
|
||||
|
||||
async findCurrent(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
try {
|
||||
return await this.#findCurrent(this.pool, projectId, packageName);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByDigest(
|
||||
publicationDigestValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
try {
|
||||
return await this.#findByDigest(this.pool, digest);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async isStartAllowed(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
publicationDigestValue: string,
|
||||
): Promise<boolean> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT "ql3"."plugin_package_automation_start_allowed"(
|
||||
$1::varchar, $2::varchar, $3::char(64)
|
||||
) AS "allowed"`,
|
||||
[projectId, packageName, digest],
|
||||
);
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
typeof result.rows[0]?.allowed !== 'boolean'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return result.rows[0].allowed;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listPendingPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
}>;
|
||||
}): Promise<Readonly<PluginPackageAutomationPublicationRecoveryPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new InvalidPluginPackageAutomationPublicationError(
|
||||
'pending page options are invalid',
|
||||
);
|
||||
}
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize(options.limit);
|
||||
const after =
|
||||
options.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageAutomationPublicationRecoveryCursor(
|
||||
options.after,
|
||||
);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT install.project_id AS "projectId",
|
||||
install.package_name AS "packageName"
|
||||
FROM "ql3"."plugin_package_install_heads" AS install_head
|
||||
JOIN "ql3"."plugin_package_installs" AS install
|
||||
ON install.installation_id = install_head.installation_id
|
||||
JOIN "ql3"."plugin_package_materialized_revisions" AS revision
|
||||
ON revision.project_id = install.project_id
|
||||
AND revision.package_name = install.package_name
|
||||
AND revision.generation = install.target_generation
|
||||
AND revision.lock_digest = install.lock_digest
|
||||
LEFT JOIN
|
||||
"ql3"."plugin_package_automation_publication_heads" AS publication
|
||||
ON publication.project_id = install.project_id
|
||||
AND publication.package_name = install.package_name
|
||||
WHERE install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_quarantine_events" AS quarantine
|
||||
WHERE quarantine.project_id = install.project_id
|
||||
AND quarantine.package_name = install.package_name
|
||||
AND quarantine.installation_id = install.installation_id
|
||||
AND quarantine.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
publication.generation_digest IS NULL OR
|
||||
publication.generation_digest <> revision.generation_digest
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_publisher_provenance" AS provenance
|
||||
JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked
|
||||
ON revoked.publisher = provenance.publisher
|
||||
AND revoked.key_id = provenance.key_id
|
||||
WHERE provenance.installation_id = install.installation_id
|
||||
AND provenance.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
$1::varchar IS NULL OR install.project_id > $1 OR
|
||||
(install.project_id = $1 AND install.package_name > $2)
|
||||
)
|
||||
ORDER BY install.project_id, install.package_name
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.projectId ?? null,
|
||||
after?.packageName ?? null,
|
||||
options.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = result.rows.length > options.limit;
|
||||
const candidates = result.rows.slice(0, options.limit).map((row) =>
|
||||
Object.freeze({
|
||||
projectId: postgresRequiredString(row.projectId, unavailable),
|
||||
packageName: postgresRequiredString(row.packageName, unavailable),
|
||||
}),
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findCurrentInTransaction(
|
||||
client: PostgresClient,
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
if (!client || typeof client.query !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL automation publication transaction is invalid',
|
||||
);
|
||||
}
|
||||
return this.#findCurrent(client, projectId, packageName, true);
|
||||
}
|
||||
|
||||
async publishInTransaction(
|
||||
client: PostgresClient,
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}>
|
||||
> {
|
||||
if (!client || typeof client.query !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL automation publication transaction is invalid',
|
||||
);
|
||||
}
|
||||
const publication = normalizePluginPackageAutomationPublication(value);
|
||||
const publicationJson = serialize(publication);
|
||||
const existing = await this.#findByDigest(
|
||||
client,
|
||||
publication.publicationDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (serialize(existing) !== publicationJson) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'publication digest is bound to another semantic publication',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication: existing,
|
||||
});
|
||||
}
|
||||
const current = await this.#findCurrent(
|
||||
client,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
true,
|
||||
);
|
||||
if (publication.version === 1) {
|
||||
if (current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'Package already has an automation publication head',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'previous automation publication head is absent',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertPluginPackageAutomationPublicationSuccessor(
|
||||
current,
|
||||
publication,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPluginPackageAutomationPublicationError) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication does not succeed the current head',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const revision = await client.query<Row>(
|
||||
`SELECT revision_digest AS "revisionDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
generation,
|
||||
lock_digest AS "lockDigest"
|
||||
FROM "ql3"."plugin_package_materialized_revisions"
|
||||
WHERE generation_digest = $1
|
||||
LIMIT 2`,
|
||||
[publication.target.generationDigest],
|
||||
);
|
||||
if (
|
||||
revision.rows.length !== 1 ||
|
||||
postgresRequiredString(
|
||||
revision.rows[0]!.revisionDigest,
|
||||
unavailable,
|
||||
) !== publication.target.materializedRevisionDigest ||
|
||||
postgresRequiredString(revision.rows[0]!.projectId, unavailable) !==
|
||||
publication.target.projectId ||
|
||||
postgresRequiredString(
|
||||
revision.rows[0]!.packageName,
|
||||
unavailable,
|
||||
) !== publication.target.packageName ||
|
||||
postgresRequiredInteger(
|
||||
revision.rows[0]!.generation,
|
||||
unavailable,
|
||||
) !== publication.target.generation ||
|
||||
postgresRequiredString(revision.rows[0]!.lockDigest, unavailable) !==
|
||||
publication.target.lockDigest
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'materialized revision fence does not match publication target',
|
||||
);
|
||||
}
|
||||
const securityFence = await client.query<Row>(
|
||||
`SELECT
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_quarantine_events" AS quarantine
|
||||
WHERE quarantine.project_id = $1
|
||||
AND quarantine.package_name = $2
|
||||
AND quarantine.installation_id = $3
|
||||
AND quarantine.lock_digest = $4
|
||||
) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_publisher_provenance" AS provenance
|
||||
JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked
|
||||
ON revoked.publisher = provenance.publisher
|
||||
AND revoked.key_id = provenance.key_id
|
||||
WHERE provenance.installation_id = $3
|
||||
AND provenance.lock_digest = $4
|
||||
) AS "blocked"`,
|
||||
[
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.target.installationId,
|
||||
publication.target.lockDigest,
|
||||
],
|
||||
);
|
||||
if (
|
||||
securityFence.rows.length !== 1 ||
|
||||
typeof securityFence.rows[0]?.blocked !== 'boolean'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (securityFence.rows[0].blocked) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'security-fenced Package generation cannot publish automation',
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_automation_publications" (
|
||||
publication_digest, project_id, package_name, installation_id,
|
||||
lock_digest, generation, generation_digest,
|
||||
materialized_revision_digest, state, version,
|
||||
previous_publication_digest, lifecycle_event_digest,
|
||||
published_at_ms, publication_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14::jsonb
|
||||
)`,
|
||||
[
|
||||
publication.publicationDigest,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.target.installationId,
|
||||
publication.target.lockDigest,
|
||||
publication.target.generation,
|
||||
publication.target.generationDigest,
|
||||
publication.target.materializedRevisionDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.previousPublicationDigest,
|
||||
publication.lifecycleEventDigest,
|
||||
publication.publishedAtMs,
|
||||
publicationJson,
|
||||
],
|
||||
);
|
||||
if (!current) {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_automation_publication_heads" (
|
||||
project_id, package_name, publication_digest,
|
||||
generation_digest, state, version, updated_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
const updated = await client.query(
|
||||
`UPDATE "ql3"."plugin_package_automation_publication_heads"
|
||||
SET publication_digest = $1, generation_digest = $2, state = $3,
|
||||
version = $4, updated_at_ms = $5
|
||||
WHERE project_id = $6 AND package_name = $7
|
||||
AND publication_digest = $8 AND version = $9`,
|
||||
[
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
current.publicationDigest,
|
||||
current.version,
|
||||
],
|
||||
);
|
||||
if (updated.rowCount !== 1) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication head changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
publication,
|
||||
});
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}>
|
||||
> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
if (attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS) continue;
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await this.publishInTransaction(client, value);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS &&
|
||||
(state === undefined ||
|
||||
state.startsWith('08') ||
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
+598
@@ -0,0 +1,598 @@
|
||||
// PostgreSQL adapter owned by Plugin Package publication and recovery.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageTaskReconciliationError,
|
||||
PluginPackageTaskReconciliationConflictError,
|
||||
PluginPackageTaskReconciliationUnavailableError,
|
||||
normalizePluginPackageTaskReconciliationReceipt,
|
||||
planPluginPackageTaskReconciliation,
|
||||
pluginPackageTaskReconciliationTaskIds,
|
||||
type PluginPackageTaskOwnershipFact,
|
||||
type PluginPackageTaskReconciliationReceipt,
|
||||
type PluginPackageTaskReconciliationRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-reconciliation';
|
||||
import {
|
||||
assertPluginPackageTaskPublicationRecoveryPageSize,
|
||||
normalizePluginPackageTaskPublicationRecoveryCursor,
|
||||
type PluginPackageTaskPublicationRecoveryPage,
|
||||
type PluginPackageTaskPublicationRecoverySource,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import {
|
||||
normalizePluginPackageMaterializedRevision,
|
||||
type PluginPackageMaterializedRevision,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGenerationSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import {
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
compileClusterCommandTaskDefinition,
|
||||
type ClusterTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/cluster-execution-revision';
|
||||
import {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
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>;
|
||||
|
||||
const SELECT_TASK_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(): PluginPackageTaskReconciliationUnavailableError {
|
||||
return new PluginPackageTaskReconciliationUnavailableError();
|
||||
}
|
||||
|
||||
function taskRecord(row: Row): Readonly<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 PluginPackageTaskReconciliationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function executionPlanJson(
|
||||
value: Readonly<ClusterTaskExecutionRevision>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
command: value.command,
|
||||
environment: value.environment,
|
||||
...(value.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: value.workingDirectory }),
|
||||
...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }),
|
||||
...(value.placement === undefined
|
||||
? {}
|
||||
: { placement: value.placement }),
|
||||
});
|
||||
}
|
||||
|
||||
function executionJson(
|
||||
value: Readonly<ClusterTaskExecutionRevision>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
taskId: value.taskId,
|
||||
sourceRevision: value.sourceRevision,
|
||||
taskRevision: value.taskRevision,
|
||||
sourceContentDigest: value.sourceContentDigest,
|
||||
executorType: value.executorType,
|
||||
planSchema: value.planSchema,
|
||||
planJson: executionPlanJson(value),
|
||||
contentDigest: value.contentDigest,
|
||||
createdAtMs: value.createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageTaskReconciliationError ||
|
||||
error instanceof PluginPackageTaskReconciliationConflictError ||
|
||||
error instanceof PluginPackageTaskReconciliationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state === '23503' ||
|
||||
state === '23505' ||
|
||||
state === '23514' ||
|
||||
state === '40001'
|
||||
) {
|
||||
return new PluginPackageTaskReconciliationConflictError(
|
||||
'durable generation or TaskDefinition fence changed',
|
||||
);
|
||||
}
|
||||
return new PluginPackageTaskReconciliationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageTaskReconciliationRepository
|
||||
implements
|
||||
PluginPackageTaskReconciliationRepository,
|
||||
PluginPackageTaskPublicationRecoverySource
|
||||
{
|
||||
readonly #registry: TaskSpecSemanticRegistry;
|
||||
|
||||
constructor(
|
||||
private readonly pool: PostgresPool,
|
||||
registry = createBuiltInTaskSpecSemanticRegistry(),
|
||||
) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function' ||
|
||||
!(registry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL Package Task reconciliation repository options are invalid',
|
||||
);
|
||||
}
|
||||
this.#registry = registry;
|
||||
}
|
||||
|
||||
async #findStored(
|
||||
queryable: Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>,
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageTaskReconciliationReceipt> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_task_reconciliations"
|
||||
WHERE generation_digest = $1
|
||||
LIMIT 2`,
|
||||
[generationDigest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
try {
|
||||
return normalizePluginPackageTaskReconciliationReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
result.rows[0]!.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageTaskReconciliationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async find(
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageTaskReconciliationReceipt> | null> {
|
||||
if (
|
||||
typeof generationDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(generationDigest)
|
||||
) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'generationDigest is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.#findStored(this.pool, generationDigest);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listPendingPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
}>;
|
||||
}): Promise<Readonly<PluginPackageTaskPublicationRecoveryPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'pending page options are invalid',
|
||||
);
|
||||
}
|
||||
assertPluginPackageTaskPublicationRecoveryPageSize(options.limit);
|
||||
const after =
|
||||
options.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageTaskPublicationRecoveryCursor(options.after);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT head.project_id AS "projectId",
|
||||
head.package_name AS "packageName"
|
||||
FROM "ql3"."plugin_package_install_heads" AS head
|
||||
JOIN "ql3"."plugin_package_installs" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
LEFT JOIN "ql3"."plugin_package_task_reconciliations" AS receipt
|
||||
ON receipt.project_id = install.project_id
|
||||
AND receipt.package_name = install.package_name
|
||||
AND receipt.generation = install.target_generation
|
||||
AND receipt.lock_digest = install.lock_digest
|
||||
WHERE install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND receipt.generation_digest IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_publisher_provenance" AS provenance
|
||||
JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked
|
||||
ON revoked.publisher = provenance.publisher
|
||||
AND revoked.key_id = provenance.key_id
|
||||
WHERE provenance.installation_id = install.installation_id
|
||||
AND provenance.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
$1::varchar IS NULL OR head.project_id > $1 OR
|
||||
(head.project_id = $1 AND head.package_name > $2)
|
||||
)
|
||||
ORDER BY head.project_id, head.package_name
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.projectId ?? null,
|
||||
after?.packageName ?? null,
|
||||
options.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = result.rows.length > options.limit;
|
||||
const candidates = result.rows.slice(0, options.limit).map((row) =>
|
||||
Object.freeze({
|
||||
projectId: postgresRequiredString(row.projectId, unavailable),
|
||||
packageName: postgresRequiredString(row.packageName, unavailable),
|
||||
}),
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
revisionValue: Readonly<PluginPackageMaterializedRevision>,
|
||||
activeGenerationSource: PluginPackageResourceGenerationSource,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageTaskReconciliationReceipt>;
|
||||
}>
|
||||
> {
|
||||
const revision = normalizePluginPackageMaterializedRevision(
|
||||
revisionValue,
|
||||
this.#registry,
|
||||
);
|
||||
if (
|
||||
!activeGenerationSource ||
|
||||
typeof activeGenerationSource.findActiveResourceGeneration !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'active generation source is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const existing = await this.#findStored(
|
||||
client,
|
||||
revision.generation.generationDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.materializedRevisionDigest !== revision.revisionDigest ||
|
||||
existing.projectId !== revision.generation.projectId ||
|
||||
existing.packageName !== revision.generation.packageName
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'generation is bound to another materialized revision',
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
receipt: existing,
|
||||
});
|
||||
}
|
||||
|
||||
const materialized = await client.query<Row>(
|
||||
`SELECT revision_digest AS "revisionDigest"
|
||||
FROM "ql3"."plugin_package_materialized_revisions"
|
||||
WHERE generation_digest = $1`,
|
||||
[revision.generation.generationDigest],
|
||||
);
|
||||
if (
|
||||
materialized.rows.length !== 1 ||
|
||||
postgresRequiredString(
|
||||
materialized.rows[0]!.revisionDigest,
|
||||
unavailable,
|
||||
) !== revision.revisionDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'materialized revision is not durably published',
|
||||
);
|
||||
}
|
||||
const install = await client.query<Row>(
|
||||
`SELECT install.installation_id AS "installationId",
|
||||
install.state,
|
||||
install.target_generation AS "targetGeneration",
|
||||
install.lock_digest AS "lockDigest",
|
||||
install.previous_active_lock_digest AS "previousLockDigest"
|
||||
FROM "ql3"."plugin_package_install_heads" AS head
|
||||
JOIN "ql3"."plugin_package_installs" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
WHERE head.project_id = $1 AND head.package_name = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_publisher_provenance" AS provenance
|
||||
JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked
|
||||
ON revoked.publisher = provenance.publisher
|
||||
AND revoked.key_id = provenance.key_id
|
||||
WHERE provenance.installation_id = install.installation_id
|
||||
AND provenance.lock_digest = install.lock_digest
|
||||
)
|
||||
FOR UPDATE OF install`,
|
||||
[
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
],
|
||||
);
|
||||
const installRow = install.rows[0];
|
||||
if (
|
||||
install.rows.length !== 1 ||
|
||||
!installRow ||
|
||||
postgresRequiredString(installRow.installationId, unavailable) !==
|
||||
revision.generation.installationId ||
|
||||
postgresRequiredString(installRow.state, unavailable) !== 'active' ||
|
||||
postgresRequiredInteger(installRow.targetGeneration, unavailable) !==
|
||||
revision.generation.generation ||
|
||||
postgresRequiredString(installRow.lockDigest, unavailable) !==
|
||||
revision.generation.lockDigest ||
|
||||
(installRow.previousLockDigest === null
|
||||
? null
|
||||
: postgresRequiredString(
|
||||
installRow.previousLockDigest,
|
||||
unavailable,
|
||||
)) !== revision.generation.previousActiveLockDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'Package install head is not the materialized generation',
|
||||
);
|
||||
}
|
||||
const previousResult =
|
||||
revision.generation.generation === 1
|
||||
? { rows: [] as Row[] }
|
||||
: await client.query<Row>(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_task_reconciliations"
|
||||
WHERE project_id = $1 AND package_name = $2
|
||||
AND generation = $3`,
|
||||
[
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
revision.generation.generation - 1,
|
||||
],
|
||||
);
|
||||
const previous =
|
||||
previousResult.rows.length === 0
|
||||
? null
|
||||
: normalizePluginPackageTaskReconciliationReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
previousResult.rows[0]!.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
if (
|
||||
previousResult.rows.length > 1 ||
|
||||
(revision.generation.generation > 1 && previous === null)
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'previous generation receipt is missing',
|
||||
);
|
||||
}
|
||||
const taskIds = pluginPackageTaskReconciliationTaskIds(
|
||||
revision,
|
||||
previous,
|
||||
this.#registry,
|
||||
);
|
||||
const facts: PluginPackageTaskOwnershipFact[] = [];
|
||||
for (const taskId of taskIds) {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT
|
||||
${SELECT_TASK_FIELDS},
|
||||
ownership.package_name AS "ownerPackageName"
|
||||
FROM (SELECT 1) AS seed
|
||||
LEFT JOIN "ql3"."task_definitions" AS head
|
||||
ON head.project_id = $1 AND head.task_id = $2
|
||||
LEFT JOIN "ql3"."task_definition_revisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
LEFT JOIN "ql3"."plugin_package_task_ownerships" AS ownership
|
||||
ON ownership.project_id = $1 AND ownership.task_id = $2`,
|
||||
[revision.generation.projectId, taskId],
|
||||
);
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
const row = result.rows[0]!;
|
||||
facts.push(
|
||||
Object.freeze({
|
||||
taskId,
|
||||
packageName:
|
||||
row.ownerPackageName === null
|
||||
? null
|
||||
: postgresRequiredString(
|
||||
row.ownerPackageName,
|
||||
unavailable,
|
||||
),
|
||||
current: row.revision === null ? null : taskRecord(row),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(
|
||||
extract(epoch FROM clock_timestamp()) * 1000
|
||||
)::bigint AS "nowMs"`,
|
||||
);
|
||||
if (clock.rows.length !== 1) throw unavailable();
|
||||
const plan = planPluginPackageTaskReconciliation({
|
||||
revision,
|
||||
previousReceipt: previous,
|
||||
facts: Object.freeze(facts),
|
||||
committedAtMs: postgresRequiredInteger(
|
||||
clock.rows[0]!.nowMs,
|
||||
unavailable,
|
||||
),
|
||||
taskSpecSemanticRegistry: this.#registry,
|
||||
});
|
||||
const activeValue =
|
||||
await activeGenerationSource.findActiveResourceGeneration(
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
);
|
||||
if (
|
||||
activeValue === null ||
|
||||
normalizePluginPackageResourceGeneration(activeValue)
|
||||
.generationDigest !== revision.generation.generationDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'active generation changed during reconciliation',
|
||||
);
|
||||
}
|
||||
const executions = plan.writes.flatMap(({ definition }) =>
|
||||
definition.enabled &&
|
||||
definition.kind === 'command' &&
|
||||
definition.spec.schema === BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
|
||||
? [
|
||||
executionJson(
|
||||
compileClusterCommandTaskDefinition(
|
||||
definition,
|
||||
this.#registry,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const committed = await client.query<Row>(
|
||||
`SELECT "ql3"."commit_plugin_package_task_reconciliation"(
|
||||
$1::char(64), $2::char(64), $3::jsonb, $4::jsonb, $5::jsonb
|
||||
) AS "created"`,
|
||||
[
|
||||
revision.generation.generationDigest,
|
||||
revision.revisionDigest,
|
||||
JSON.stringify(plan.receipt),
|
||||
JSON.stringify(plan.writes),
|
||||
JSON.stringify(executions),
|
||||
],
|
||||
);
|
||||
if (
|
||||
committed.rows.length !== 1 ||
|
||||
typeof committed.rows[0]!.created !== 'boolean'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: committed.rows[0]!.created ? 'created' : 'existing',
|
||||
receipt: plan.receipt,
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user