Fix controller stalls during large collection launches

This commit is contained in:
2026-10-05 09:50:11 -04:00
parent 9472bb5184
commit b3929fc771
5 changed files with 192 additions and 20 deletions
+20 -8
View File
@@ -43,9 +43,14 @@ export function getLibraryFile(versionId: string, path: string) {
FROM game_file_entries f JOIN game_file_versions v ON v.id=f.version_id WHERE f.version_id=? AND f.path=?`)
.get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null;
}
/** Launch selection needs only the ID; manifests can contain tens of thousands of files. */
export function newestReadyLibraryId(game: string): string | null {
const row = database().prepare("SELECT v.id FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.published_at DESC, v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as { id: string } | undefined;
return row?.id ?? null;
}
export function newestReadyLibrary(game: string): GameLibraryVersion | null {
const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.published_at DESC, v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined;
return row ? versionFromRow(row) : null;
const id = newestReadyLibraryId(game);
return id ? getLibraryVersion(id) : null;
}
/** Hosts follow the latest successful publication for each game, including legacy libraries. */
export function latestPublishedLibraries() {
@@ -79,13 +84,20 @@ export function getHostLibraryCache(hostId: string, versionId: string): HostLibr
const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined;
return row ? cacheFromRow(row) : null;
}
export function requireReadyLibrary(hostId: string, versionId: string): GameLibraryVersion {
const version = getLibraryVersion(versionId);
/** Admission and dispatch recheck readiness without loading or parsing the file manifest. */
export function assertReadyLibrary(hostId: string, versionId: string): void {
const handle = database();
const version = handle.prepare('SELECT state, manifest_sha256 FROM game_file_versions WHERE id=?')
.get(versionId) as Pick<VersionRow, 'state' | 'manifest_sha256'> | undefined;
if (!version || version.state !== 'ready' || libraryFilesRetired(versionId)) throw new Error('Select an available published game-file version.');
if (database().prepare("SELECT 1 FROM ec_host_operations WHERE host_id=? AND kind='library.remove' AND json_extract(payload,'$.libraryVersionId')=? AND state IN ('pending','accepted','running','unknown')").get(hostId, versionId)) throw new Error('This host copy is being removed.');
const cache = getHostLibraryCache(hostId, versionId);
if (!cache || !['ready', 'outdated'].includes(cache.status) || cache.manifestSha256 !== version.manifestSha256) throw new Error('The selected host must finish downloading and verifying this game-file version.');
return version;
if (handle.prepare("SELECT 1 FROM ec_host_operations WHERE host_id=? AND kind='library.remove' AND json_extract(payload,'$.libraryVersionId')=? AND state IN ('pending','accepted','running','unknown')").get(hostId, versionId)) throw new Error('This host copy is being removed.');
const cache = handle.prepare('SELECT status, manifest_sha256 FROM host_library_caches WHERE host_id=? AND version_id=?')
.get(hostId, versionId) as Pick<CacheRow, 'status' | 'manifest_sha256'> | undefined;
if (!cache || !['ready', 'outdated'].includes(cache.status) || cache.manifest_sha256 !== version.manifest_sha256) throw new Error('The selected host must finish downloading and verifying this game-file version.');
}
export function requireReadyLibrary(hostId: string, versionId: string): GameLibraryVersion {
assertReadyLibrary(hostId, versionId);
return getLibraryVersion(versionId)!;
}
function textField(input: unknown, label: string, max: number, required = true): string {
+2 -2
View File
@@ -1,6 +1,6 @@
import { db } from '../db/index.js';
import { listHosts, requireReadyHost } from './index.js';
import { requireReadyLibrary } from '../game-files/index.js';
import { assertReadyLibrary } from '../game-files/index.js';
import type { Host } from '../../hosts.js';
import { capacityCalibration } from './calibration.js';
@@ -101,7 +101,7 @@ export function choosePlacement(options: PlacementOptions): { host: Host; port:
for (const host of listHosts().filter(host => !options.hostId || host.id === options.hostId)) {
try {
requireReadyHost(host.id, { image: options.image, requiredBytes: INSTANCE_STORAGE_BYTES });
requireReadyLibrary(host.id, options.libraryId);
assertReadyLibrary(host.id, options.libraryId);
const port = availableHostPort(host, options);
if (port === null) throw new Error('No available UDP/TCP game port.');
const load = projectedLoad(host, options);
+8 -8
View File
@@ -6,7 +6,7 @@ import { db } from '../db/index.js';
import { config, relDestForKind, serverDir } from '../config.js';
import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js';
import { getHost, getHostOperation, listHosts, queueHostOperation, releaseHostPorts, reserveHostPort } from '../hosts/index.js';
import { newestReadyLibrary, requireReadyLibrary } from '../game-files/index.js';
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
@@ -34,9 +34,9 @@ interface FilePlan { id: number; path: string; contentBase64: string | null; sha
export function nativeSelection(input: Record<string, unknown>, game: string, options: Pick<PlacementOptions, 'planned'> = {}): NativeSelection {
if (input.hostId !== undefined && typeof input.hostId !== 'string') throw new Error('Invalid host.');
const version = newestReadyLibrary(game);
if (!version) throw new Error(`Upload ${game.toUpperCase()} game files before launching a server.`);
if (input.libraryId !== undefined && input.libraryId !== '' && input.libraryId !== version.id) {
const libraryId = newestReadyLibraryId(game);
if (!libraryId) throw new Error(`Upload ${game.toUpperCase()} game files before launching a server.`);
if (input.libraryId !== undefined && input.libraryId !== '' && input.libraryId !== libraryId) {
throw new Error(`The current ${game.toUpperCase()} game files changed. Refresh the launch preview.`);
}
const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image;
@@ -47,10 +47,10 @@ export function nativeSelection(input: Record<string, unknown>, game: string, op
const network = rawNetwork as NativeSelection;
const bindAddress = network.bindAddress || undefined;
if (bindAddress !== undefined && (typeof bindAddress !== 'string' || !isIP(bindAddress))) throw new Error('Invalid bind address.');
const { host } = choosePlacement({ hostId: automatic ? undefined : input.hostId as string, libraryId: version.id, image, bindAddress, ...options });
const { host } = choosePlacement({ hostId: automatic ? undefined : input.hostId as string, libraryId, image, bindAddress, ...options });
if (network.publicAddress !== undefined && (typeof network.publicAddress !== 'string' || network.publicAddress.length > 253)) throw new Error('Invalid public address.');
if (network.publicPort !== undefined && (!Number.isInteger(network.publicPort) || Number(network.publicPort) < 1 || Number(network.publicPort) > 65535)) throw new Error('Invalid public port.');
return { hostId: host.id, libraryId: version.id, image, bindAddress: bindAddress ?? host.bindAddress, publicAddress: network.publicAddress ?? host.publicAddress,
return { hostId: host.id, libraryId, image, bindAddress: bindAddress ?? host.bindAddress, publicAddress: network.publicAddress ?? host.publicAddress,
publicPort: network.publicPort, automatic, network: { bindAddress, publicAddress: network.publicAddress, publicPort: network.publicPort } };
}
@@ -156,7 +156,7 @@ export function queueNativeRebuild(name: string, triggeredBy: string): CreateSer
const blocker = activeBetaRemovalBlocker(name);
if (blocker) throw new Error(blocker);
if (!server?.map_slug || !getServerKey(name)) throw new Error('A saved map and server key are required before rebuilding.');
requireReadyLibrary(instance.host_id, instance.library_id);
assertReadyLibrary(instance.host_id, instance.library_id);
const recipe: NativeRecipe = { ...captureRecipe(server.game as 't5' | 't6', server.map_slug), image: instance.image,
native: { hostId: instance.host_id, libraryId: instance.library_id, image: instance.image, bindAddress: instance.bind_address } };
return db().transaction(() => {
@@ -434,7 +434,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
deploymentStage(job.id, 'Checking host eligibility', { phase: 'eligibility', blocker: null });
if (job.action === 'create') choosePlacement({ hostId: instance.host_id, libraryId: instance.library_id, image: instance.image,
bindAddress: instance.bind_address, excludeInstanceId: instance.id, thorough: true, dispatch: true });
if (job.action === 'create' || job.action === 'rebuild') requireReadyLibrary(instance.host_id, instance.library_id);
if (job.action === 'create' || job.action === 'rebuild') assertReadyLibrary(instance.host_id, instance.library_id);
if (job.action !== 'create' && job.action !== 'start') {
// Fast controller check keeps occupied lobbies out of the operation slots.
// The agent repeats this immediately before its local mutation.