Queue native collection launches before async placement
This commit is contained in:
@@ -2,12 +2,46 @@ import { db } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { AGENT_PROTOCOL_VERSION, getHost, queueHostOperation } from './index.js';
|
||||
import { getHostLibraryCache, latestPublishedLibraries, queueLibraryDownload } from '../game-files/index.js';
|
||||
import { GAME_FILE_GAMES } from '../../game-files.js';
|
||||
import type { Host, HostOperationState, HostPreparation, HostPreparationItem } from '../../hosts.js';
|
||||
|
||||
const activeStates = ['pending', 'accepted', 'running', 'unknown'];
|
||||
const supersededDownload = 'Superseded by newer game files before delivery to the host.';
|
||||
type PreparationOperation = { id: string; state: HostOperationState; updated_at: string; error: string | null };
|
||||
type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation };
|
||||
type QueuedSelection = { libraryId: string; image: string | null };
|
||||
type LibraryTarget = Pick<ReturnType<typeof latestPublishedLibraries>[number], 'id' | 'sizeBytes' | 'manifestSha256'> & { name: string; queued?: boolean };
|
||||
|
||||
/** Accepted requests retain their prerequisites until placement owns an instance. */
|
||||
function queuedSelections(hostId: string): QueuedSelection[] {
|
||||
// Standalone host/library readers can initialize before provisioning migrations.
|
||||
if (!db().prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='native_creation_intents'").get()) return [];
|
||||
return db().prepare(`SELECT DISTINCT json_extract(q.selection,'$.libraryId') AS libraryId,
|
||||
json_extract(q.selection,'$.image') AS image FROM native_creation_intents q
|
||||
JOIN server_deployments d ON d.id=q.deployment_id
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
WHERE d.action='create' AND d.server_deleted_at IS NULL AND n.deployment_id IS NULL
|
||||
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')
|
||||
AND (json_extract(q.selection,'$.automatic')=1 OR json_extract(q.selection,'$.hostId') IS NULL
|
||||
OR json_extract(q.selection,'$.hostId')=?)`).all(hostId) as QueuedSelection[];
|
||||
}
|
||||
|
||||
function targetLibraries(selections: QueuedSelection[]): LibraryTarget[] {
|
||||
const latest = latestPublishedLibraries();
|
||||
const latestIds = new Set(latest.map(version => version.id));
|
||||
const queued = new Map<string, LibraryTarget>();
|
||||
const get = db().prepare(`SELECT v.id,l.game,v.version,v.size_bytes AS sizeBytes,v.manifest_sha256 AS manifestSha256
|
||||
FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
|
||||
WHERE v.id=? AND v.state='ready' AND NOT EXISTS(SELECT 1 FROM game_file_cleanup c WHERE c.version_id=v.id)`);
|
||||
for (const selection of selections) {
|
||||
if (latestIds.has(selection.libraryId) || queued.has(selection.libraryId)) continue;
|
||||
const version = get.get(selection.libraryId) as { id: string; game: string; version: string; sizeBytes: number; manifestSha256: string } | undefined;
|
||||
if (version) queued.set(version.id, { ...version, queued: true,
|
||||
name: `${GAME_FILE_GAMES.find(game => game.id === version.game)?.name ?? version.game} · Queued version ${version.version}` });
|
||||
}
|
||||
// Finish prerequisites for accepted work before downloading newer optional files.
|
||||
return [...queued.values(), ...latest];
|
||||
}
|
||||
|
||||
function latestOperation(hostId: string, kind: string, field: string, value: string): PreparationOperation | undefined {
|
||||
return db().prepare(`SELECT id,state,updated_at,error FROM ec_host_operations
|
||||
@@ -43,19 +77,24 @@ function hostBlocker(host: Host): { state: 'waiting' | 'blocked'; detail: string
|
||||
}
|
||||
|
||||
function targets(host: Host): Target[] {
|
||||
const image = config.komodo.defaultImage;
|
||||
const runtimeReady = host.inventory.runtimeImages?.includes(image);
|
||||
const result: Target[] = [{ image, requiredBytes: host.minimumFreeBytes,
|
||||
operation: latestOperation(host.id, 'runtime.pull', 'image', image),
|
||||
item: { kind: 'runtime', label: image, state: runtimeReady ? 'ready' : 'waiting', detail: runtimeReady ? 'Runtime is cached.' : 'Runtime will be downloaded automatically.' } }];
|
||||
for (const version of latestPublishedLibraries()) {
|
||||
const selections = queuedSelections(host.id);
|
||||
const images = new Set([config.komodo.defaultImage, ...selections.flatMap(selection => selection.image ? [selection.image] : [])]);
|
||||
const result: Target[] = [...images].map(image => {
|
||||
const runtimeReady = host.inventory.runtimeImages?.includes(image);
|
||||
return { image, requiredBytes: host.minimumFreeBytes,
|
||||
operation: latestOperation(host.id, 'runtime.pull', 'image', image),
|
||||
item: { kind: 'runtime', label: image, state: runtimeReady ? 'ready' : 'waiting', detail: runtimeReady ? 'Runtime is cached.' : 'Runtime will be downloaded automatically.' } };
|
||||
});
|
||||
for (const version of targetLibraries(selections)) {
|
||||
const cache = getHostLibraryCache(host.id, version.id);
|
||||
const matches = cache?.manifestSha256 === version.manifestSha256;
|
||||
const ready = matches && ['ready', 'outdated'].includes(cache!.status) && cache!.bytesDownloaded === version.sizeBytes;
|
||||
result.push({ versionId: version.id, requiredBytes: version.sizeBytes - (matches ? cache!.bytesDownloaded : 0) + host.minimumFreeBytes,
|
||||
operation: latestOperation(host.id, 'library.download', 'libraryVersionId', version.id),
|
||||
item: { kind: 'library', label: version.name, state: ready ? 'ready' : 'waiting',
|
||||
detail: ready ? 'The latest game files are downloaded and verified.' : 'The latest game files will be downloaded and verified automatically.',
|
||||
detail: version.queued
|
||||
? ready ? 'Game files selected by queued launches are downloaded and verified.' : 'Game files selected by queued launches will be downloaded and verified automatically.'
|
||||
: ready ? 'The latest game files are downloaded and verified.' : 'The latest game files will be downloaded and verified automatically.',
|
||||
bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes, ...(cache ? { cache } : {}) } });
|
||||
}
|
||||
const blocker = hostBlocker(host);
|
||||
@@ -100,13 +139,13 @@ export function presentHostPreparation(host: Host): Host {
|
||||
}
|
||||
|
||||
function supersedeUndeliveredDownloads(hostId: string): void {
|
||||
const latest = new Set(latestPublishedLibraries().map(version => version.id));
|
||||
const required = new Set(targetLibraries(queuedSelections(hostId)).map(version => version.id));
|
||||
const pending = db().prepare(`SELECT id,json_extract(payload,'$.libraryVersionId') AS versionId
|
||||
FROM ec_host_operations WHERE host_id=? AND kind='library.download' AND state='pending'
|
||||
AND last_delivered_at IS NULL AND json_extract(payload,'$.automatic')=1`).all(hostId) as { id: string; versionId: string }[];
|
||||
const now = new Date().toISOString();
|
||||
for (const operation of pending) {
|
||||
if (latest.has(operation.versionId)) continue;
|
||||
if (required.has(operation.versionId)) continue;
|
||||
// Delivered operations may already be writing files. Only unsubmitted
|
||||
// automatic work can be replaced without consulting the agent journal.
|
||||
db().prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE id=?").run(supersededDownload, now, operation.id);
|
||||
|
||||
Reference in New Issue
Block a user