import { InvalidPluginPackageResourceMaterializationError, PluginPackageResourceMaterializationConflictError, PluginPackageResourceMaterializationUnavailableError, materializeActivePluginPackageResources, normalizePluginPackageMaterializedRevision, type PluginPackageMaterializedRevisionRepository, type PluginPackageResourceByteSource, type PluginPackageResourceLockSource, } from './pluginPackageResourceMaterialization'; import type { PluginPackageResourceGenerationSource } from './pluginPackageResourceGeneration'; import type { PluginPackageSecretBindingRepository } from './secret-binding/binding'; import { InvalidPluginPackageTaskReconciliationError, PluginPackageTaskReconciliationConflictError, PluginPackageTaskReconciliationUnavailableError, type PluginPackageTaskReconciliationReceipt, type PluginPackageTaskReconciliationRepository, } from './pluginPackageTaskReconciliation'; import { TaskSpecSemanticRegistry } from '../task-definition/taskSpecSemantic'; export const MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE = 64; export const MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES = 64; export interface PluginPackageTaskPublicationPendingCandidate { readonly projectId: string; readonly packageName: string; } export interface PluginPackageTaskPublicationRecoveryCursor { readonly projectId: string; readonly packageName: string; } export interface PluginPackageTaskPublicationRecoveryPage { readonly candidates: readonly Readonly[]; readonly truncated: boolean; readonly next?: Readonly; } export interface PluginPackageTaskPublicationRecoverySource { listPendingPage(options: { readonly limit: number; readonly after?: Readonly; }): Promise>; } export interface PluginPackageTaskPublicationCurrentResult { readonly status: 'current'; readonly materialized: 'created' | 'existing'; readonly reconciled: 'created' | 'existing'; readonly generationDigest: string; readonly revisionDigest: string; readonly receipt: Readonly; } export interface PluginPackageTaskPublicationAbsentResult { readonly status: 'absent'; } export interface PluginPackageTaskPublicationSupersededResult { readonly status: 'superseded'; readonly generationDigest: string; } export type PluginPackageTaskPublicationResult = | Readonly | Readonly | Readonly; export type PluginPackageTaskPublicationRecoveryItemStatus = | 'manual_required' | 'retry' | 'settled' | 'superseded'; export interface PluginPackageTaskPublicationRecoveryItem { readonly projectId: string; readonly packageName: string; readonly status: PluginPackageTaskPublicationRecoveryItemStatus; } export interface PluginPackageTaskPublicationRecoveryCycleResult { readonly pages: number; readonly scanned: number; readonly settled: number; readonly retry: number; readonly manualRequired: number; readonly superseded: number; readonly remaining: boolean; readonly safeToAdmit: boolean; } export class InvalidPluginPackageTaskPublicationError extends TypeError { readonly code = 'PLUGIN_PACKAGE_TASK_PUBLICATION_INVALID'; constructor(message: string) { super(`Plugin Package Task publication is invalid: ${message}`); this.name = 'InvalidPluginPackageTaskPublicationError'; } } export class PluginPackageTaskPublicationConflictError extends Error { readonly code = 'PLUGIN_PACKAGE_TASK_PUBLICATION_CONFLICT'; constructor(message: string, options?: ErrorOptions) { super( `Plugin Package Task publication conflicts with state: ${message}`, options, ); this.name = 'PluginPackageTaskPublicationConflictError'; } } export class PluginPackageTaskPublicationUnavailableError extends Error { readonly code = 'PLUGIN_PACKAGE_TASK_PUBLICATION_UNAVAILABLE'; constructor(options?: ErrorOptions) { super('Plugin Package Task publication is unavailable', options); this.name = 'PluginPackageTaskPublicationUnavailableError'; } } const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; function invalid(message: string): never { throw new InvalidPluginPackageTaskPublicationError(message); } function record(value: unknown, label: string): Record { if ( !value || typeof value !== 'object' || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) ) { return invalid(`${label} must be an object`); } return value as Record; } function exactKeys( value: object, required: readonly string[], optional: readonly string[], label: string, ): void { const keys = Reflect.ownKeys(value); const allowed = new Set([...required, ...optional]); if ( keys.some((key) => typeof key !== 'string') || required.some((key) => !keys.includes(key)) || keys.some((key) => typeof key === 'string' && !allowed.has(key)) ) { invalid(`${label} shape is invalid`); } } function projectId(value: unknown): string { if ( typeof value !== 'string' || value.length < 1 || value.includes('\0') || Buffer.byteLength(value, 'utf8') > 128 ) { return invalid('projectId is invalid'); } return value; } function packageName(value: unknown): string { if (typeof value !== 'string' || !PACKAGE_NAME.test(value)) { return invalid('packageName is invalid'); } return value; } function positiveInteger( value: unknown, maximum: number, label: string, ): number { if ( !Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum ) { return invalid(`${label} is invalid`); } return value as number; } function normalizeCandidate( value: PluginPackageTaskPublicationPendingCandidate, ): Readonly { const candidate = record(value, 'pending candidate'); exactKeys(candidate, ['packageName', 'projectId'], [], 'pending candidate'); return Object.freeze({ projectId: projectId(value.projectId), packageName: packageName(value.packageName), }); } export function normalizePluginPackageTaskPublicationRecoveryCursor( value: PluginPackageTaskPublicationRecoveryCursor, ): Readonly { const cursor = record(value, 'recovery cursor'); exactKeys(cursor, ['packageName', 'projectId'], [], 'recovery cursor'); return Object.freeze({ projectId: projectId(value.projectId), packageName: packageName(value.packageName), }); } export function assertPluginPackageTaskPublicationRecoveryPageSize( value: number, ): void { positiveInteger( value, MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE, 'recovery page size', ); } function compareCandidates( left: Readonly, right: Readonly, ): number { return ( left.projectId.localeCompare(right.projectId) || left.packageName.localeCompare(right.packageName) ); } function normalizePage( value: PluginPackageTaskPublicationRecoveryPage, limit: number, ): Readonly { const page = record(value, 'recovery page'); exactKeys(page, ['candidates', 'truncated'], ['next'], 'recovery page'); if ( !Array.isArray(value.candidates) || value.candidates.length > limit || typeof value.truncated !== 'boolean' ) { return invalid('recovery page is invalid'); } const candidates = Object.freeze(value.candidates.map(normalizeCandidate)); if ( candidates.some( (candidate, index) => index > 0 && compareCandidates(candidates[index - 1]!, candidate) >= 0, ) ) { return invalid('recovery candidates must be uniquely sorted'); } const next = value.next === undefined ? undefined : normalizePluginPackageTaskPublicationRecoveryCursor(value.next); const last = candidates.at(-1); if ( value.truncated !== (next !== undefined) || (next && (!last || next.projectId !== last.projectId || next.packageName !== last.packageName)) ) { return invalid('recovery continuation is invalid'); } return Object.freeze({ candidates, truncated: value.truncated, ...(next ? { next } : {}), }); } function publicationAuthorities(options: { readonly generationSource: PluginPackageResourceGenerationSource; readonly lockSource: PluginPackageResourceLockSource; readonly byteSource: PluginPackageResourceByteSource; readonly materializedRepository: PluginPackageMaterializedRevisionRepository; readonly secretBindingSource?: Pick< PluginPackageSecretBindingRepository, 'find' >; readonly reconciliationRepository: PluginPackageTaskReconciliationRepository; readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; }): void { const value = record(options, 'publication coordinator options'); exactKeys( value, [ 'byteSource', 'generationSource', 'lockSource', 'materializedRepository', 'reconciliationRepository', 'taskSpecSemanticRegistry', ], ['secretBindingSource'], 'publication coordinator options', ); if ( !options.generationSource || typeof options.generationSource.findActiveResourceGeneration !== 'function' || !options.lockSource || typeof options.lockSource.findLock !== 'function' || !options.byteSource || typeof options.byteSource.open !== 'function' || !options.materializedRepository || typeof options.materializedRepository.find !== 'function' || typeof options.materializedRepository.publish !== 'function' || (options.secretBindingSource !== undefined && (!options.secretBindingSource || typeof options.secretBindingSource.find !== 'function')) || !options.reconciliationRepository || typeof options.reconciliationRepository.find !== 'function' || typeof options.reconciliationRepository.reconcile !== 'function' || !(options.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry) ) { invalid('publication coordinator authority is invalid'); } } export class PluginPackageTaskPublicationCoordinator { readonly #generationSource: PluginPackageResourceGenerationSource; readonly #lockSource: PluginPackageResourceLockSource; readonly #byteSource: PluginPackageResourceByteSource; readonly #materializedRepository: PluginPackageMaterializedRevisionRepository; readonly #reconciliationRepository: PluginPackageTaskReconciliationRepository; readonly #secretBindingSource: | Pick | undefined; readonly #registry: TaskSpecSemanticRegistry; constructor(options: { readonly generationSource: PluginPackageResourceGenerationSource; readonly lockSource: PluginPackageResourceLockSource; readonly byteSource: PluginPackageResourceByteSource; readonly materializedRepository: PluginPackageMaterializedRevisionRepository; readonly secretBindingSource?: Pick< PluginPackageSecretBindingRepository, 'find' >; readonly reconciliationRepository: PluginPackageTaskReconciliationRepository; readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; }) { publicationAuthorities(options); this.#generationSource = options.generationSource; this.#lockSource = options.lockSource; this.#byteSource = options.byteSource; this.#materializedRepository = options.materializedRepository; this.#secretBindingSource = options.secretBindingSource; this.#reconciliationRepository = options.reconciliationRepository; this.#registry = options.taskSpecSemanticRegistry; } async publishActive( projectIdValue: string, packageNameValue: string, ): Promise> { const identity = { projectId: projectId(projectIdValue), packageName: packageName(packageNameValue), }; try { const first = await this.#generationSource.findActiveResourceGeneration( identity.projectId, identity.packageName, ); if (first === null) return Object.freeze({ status: 'absent' }); if ( first.projectId !== identity.projectId || first.packageName !== identity.packageName ) { throw new PluginPackageTaskPublicationConflictError( 'generation source returned another Package identity', ); } let revision = await this.#materializedRepository.find( first.generationDigest, ); let materialized: 'created' | 'existing' = 'existing'; if (revision === null) { const materializedValue = await materializeActivePluginPackageResources( { ...identity, generationSource: this.#generationSource, lockSource: this.#lockSource, byteSource: this.#byteSource, ...(this.#secretBindingSource === undefined ? {} : { secretBindingSource: this.#secretBindingSource }), taskSpecSemanticRegistry: this.#registry, }, ); if (materializedValue === null) { return Object.freeze({ status: 'superseded', generationDigest: first.generationDigest, }); } if ( materializedValue.generation.generationDigest !== first.generationDigest ) { return Object.freeze({ status: 'superseded', generationDigest: first.generationDigest, }); } const publication = await this.#materializedRepository.publish( materializedValue, ); materialized = publication.status; revision = publication.revision; } const normalizedRevision = normalizePluginPackageMaterializedRevision( revision, this.#registry, ); if ( normalizedRevision.generation.generationDigest !== first.generationDigest || normalizedRevision.generation.projectId !== identity.projectId || normalizedRevision.generation.packageName !== identity.packageName ) { throw new PluginPackageTaskPublicationConflictError( 'durable materialized revision does not match the active generation', ); } const reconciliation = await this.#reconciliationRepository.reconcile( normalizedRevision, this.#generationSource, ); const finalGeneration = await this.#generationSource.findActiveResourceGeneration( identity.projectId, identity.packageName, ); if ( finalGeneration === null || finalGeneration.generationDigest !== first.generationDigest ) { return Object.freeze({ status: 'superseded', generationDigest: first.generationDigest, }); } return Object.freeze({ status: 'current', materialized, reconciled: reconciliation.status, generationDigest: first.generationDigest, revisionDigest: normalizedRevision.revisionDigest, receipt: reconciliation.receipt, }); } catch (error) { if ( error instanceof InvalidPluginPackageTaskPublicationError || error instanceof PluginPackageTaskPublicationConflictError || error instanceof PluginPackageTaskPublicationUnavailableError ) { throw error; } if ( error instanceof InvalidPluginPackageResourceMaterializationError || error instanceof InvalidPluginPackageTaskReconciliationError ) { throw new InvalidPluginPackageTaskPublicationError(error.message); } if ( error instanceof PluginPackageResourceMaterializationConflictError || error instanceof PluginPackageTaskReconciliationConflictError ) { throw new PluginPackageTaskPublicationConflictError( 'active generation publication fence changed', { cause: error }, ); } if ( error instanceof PluginPackageResourceMaterializationUnavailableError || error instanceof PluginPackageTaskReconciliationUnavailableError ) { throw new PluginPackageTaskPublicationUnavailableError({ cause: error, }); } throw new PluginPackageTaskPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, }); } } } export class PluginPackageTaskPublicationRecoveryCoordinator { readonly #source: PluginPackageTaskPublicationRecoverySource; readonly #publisher: PluginPackageTaskPublicationCoordinator; constructor(options: { readonly source: PluginPackageTaskPublicationRecoverySource; readonly publisher: PluginPackageTaskPublicationCoordinator; }) { const value = record(options, 'recovery coordinator options'); exactKeys( value, ['publisher', 'source'], [], 'recovery coordinator options', ); if ( !options.source || typeof options.source.listPendingPage !== 'function' || !(options.publisher instanceof PluginPackageTaskPublicationCoordinator) ) { invalid('recovery coordinator authority is invalid'); } this.#source = options.source; this.#publisher = options.publisher; } async recoverPage(options: { readonly limit: number; readonly after?: Readonly; }): Promise< Readonly<{ items: readonly Readonly[]; truncated: boolean; next?: Readonly; }> > { const value = record(options, 'recovery page options'); exactKeys(value, ['limit'], ['after'], 'recovery page options'); assertPluginPackageTaskPublicationRecoveryPageSize(options.limit); const after = options.after === undefined ? undefined : normalizePluginPackageTaskPublicationRecoveryCursor(options.after); let page: Readonly; try { page = normalizePage( await this.#source.listPendingPage({ limit: options.limit, ...(after ? { after } : {}), }), options.limit, ); } catch (error) { if (error instanceof InvalidPluginPackageTaskPublicationError) throw error; throw new PluginPackageTaskPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, }); } const items: Readonly[] = []; for (const candidate of page.candidates) { let status: PluginPackageTaskPublicationRecoveryItemStatus; try { const published = await this.#publisher.publishActive( candidate.projectId, candidate.packageName, ); status = published.status === 'current' ? 'settled' : published.status === 'superseded' ? 'superseded' : 'manual_required'; } catch (error) { status = error instanceof InvalidPluginPackageTaskPublicationError || error instanceof PluginPackageTaskPublicationConflictError ? 'manual_required' : 'retry'; } items.push(Object.freeze({ ...candidate, status })); } return Object.freeze({ items: Object.freeze(items), truncated: page.truncated, ...(page.next ? { next: page.next } : {}), }); } async recover( options: { readonly pageSize?: number; readonly maxPages?: number; } = {}, ): Promise> { const value = record(options, 'recovery cycle options'); exactKeys(value, [], ['maxPages', 'pageSize'], 'recovery cycle options'); const pageSize = positiveInteger( options.pageSize ?? 8, MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE, 'recovery page size', ); const maxPages = positiveInteger( options.maxPages ?? 8, MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES, 'recovery page count', ); const counts = { pages: 0, scanned: 0, settled: 0, retry: 0, manualRequired: 0, superseded: 0, }; let after: Readonly | undefined; let exhausted = false; while (counts.pages < maxPages) { const page = await this.recoverPage({ limit: pageSize, ...(after ? { after } : {}), }); counts.pages += 1; counts.scanned += page.items.length; for (const item of page.items) { if (item.status === 'settled') counts.settled += 1; else if (item.status === 'retry') counts.retry += 1; else if (item.status === 'manual_required') counts.manualRequired += 1; else counts.superseded += 1; } if (!page.truncated) { exhausted = true; break; } after = page.next; } let probe: Readonly; try { probe = normalizePage( await this.#source.listPendingPage({ limit: 1 }), 1, ); } catch (error) { throw new PluginPackageTaskPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, }); } const remaining = !exhausted || probe.candidates.length > 0; return Object.freeze({ ...counts, remaining, safeToAdmit: !remaining && counts.retry === 0 && counts.manualRequired === 0, }); } }