Merge fix/start-warteschlange in main

This commit is contained in:
mregosar committed 2026-11-01 18:50:35 +01:00
commit 6a922f80de
6 files changed
+83 -8

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
let manager; let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { const adapter = adapterOverride ?? createServerAdapter(config, {
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()), getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
getStarting: (host, exclude) => manager.startingOn(host, exclude),
}); });
manager = new ServerManager({ db, service, adapter, config, logger }); manager = new ServerManager({ db, service, adapter, config, logger });
manager.init(); manager.init();
+3
View File
@@ -555,6 +555,9 @@ export class MatchService extends EventEmitter {
if (!server || !safeEqual(String(key ?? ''), server.config_key)) { if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden'); throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
} }
// Erst zählen, wenn die Map eingerichtet ist. Während des Einrichtens kommen noch Zeilen vom vorigen
// Spiel auf dem Server (z. B. dem Ende der Messerrunde), die nicht zur neuen Map gehören.
if (server.status !== 'configured') return;
this.applyLiveLog(matchId, splitLogBody(body)); this.applyLiveLog(matchId, splitLogBody(body));
} }
+27 -7
View File
@@ -141,6 +141,8 @@ export class DockerServerAdapter {
this.runDocker = runDocker; // nur für Tests this.runDocker = runDocker; // nur für Tests
this.lanMode = !options.gslt; this.lanMode = !options.gslt;
this.startQueue = Promise.resolve(); this.startQueue = Promise.resolve();
// Starts, die gerade warten oder deren Container noch SteamCMD laufen lässt
this.pendingStarts = 0;
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST'); if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
} }
@@ -169,8 +171,13 @@ export class DockerServerAdapter {
async start(args) { async start(args) {
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat. // In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
const previous = this.startQueue; const previous = this.startQueue;
let release; let releaseQueue;
this.startQueue = new Promise((resolve) => { release = resolve; }); this.startQueue = new Promise((resolve) => { releaseQueue = resolve; });
this.pendingStarts += 1;
const release = () => {
this.pendingStarts -= 1;
releaseQueue();
};
await previous; await previous;
let result; let result;
try { try {
@@ -368,10 +375,11 @@ export class DockerServerAdapter {
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen. * immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
*/ */
export class DockerHostPool { export class DockerHostPool {
constructor(options, { getUsedPorts, runDocker } = {}) { constructor(options, { getUsedPorts, getStarting, runDocker } = {}) {
this.name = 'docker'; this.name = 'docker';
this.simulated = false; this.simulated = false;
this.depsGetUsedPorts = getUsedPorts; this.depsGetUsedPorts = getUsedPorts;
this.depsGetStarting = getStarting;
this.runDocker = runDocker; this.runDocker = runDocker;
this.options = options; this.options = options;
this.diskCache = new Map(); this.diskCache = new Map();
@@ -594,19 +602,31 @@ export class DockerHostPool {
return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []); return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []);
} }
pickHost() { /**
* Wie viele Server auf einem Host gerade hochfahren: Starts in der Warteschlange dieses Prozesses, und
* (nach einem Neustart der App) Server, die laut Datenbank noch starten. Ein Container lädt beim Start
* per SteamCMD, nach einem großen CS2-Update dauert das lange; weitere Starts dort müssten so lange warten.
*/
startingOn(host, exclude) {
const queued = this.adapters.get(host.name)?.pendingStarts ?? 0;
const stored = this.depsGetStarting?.(host.publicHost, exclude) ?? 0;
return Math.max(queued, stored);
}
pickHost(exclude) {
const updating = this.updatingHosts(); const updating = this.updatingHosts();
const best = this.hosts const best = this.hosts
.filter((h) => !updating.has(h.name)) .filter((h) => !updating.has(h.name))
.map((h) => ({ host: h, free: this.freeSlots(h) })) .map((h) => ({ host: h, free: this.freeSlots(h), starting: this.startingOn(h, exclude) }))
.filter((x) => x.free > 0) .filter((x) => x.free > 0)
.sort((a, b) => b.free - a.free)[0]; // Zuerst Hosts, auf denen nichts hochfährt, dann die mit den meisten freien Plätzen
.sort((a, b) => a.starting - b.starting || b.free - a.free)[0];
if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports'); if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports');
return best.host; return best.host;
} }
async start(args) { async start(args) {
return this.adapters.get(this.pickHost().name).start(args); return this.adapters.get(this.pickHost(args?.matchId).name).start(args);
} }
adapterFor(handle) { adapterFor(handle) {
+14 -1
View File
@@ -258,6 +258,14 @@ export class ServerManager {
return result; return result;
} }
/** Server (Matches und Messer-Server), die auf einem Host gerade hochfahren. `exclude`: der, der gerade startet. */
startingOn(host, exclude = null) {
return this.db.prepare(`
SELECT (SELECT COUNT(*) FROM servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND match_id IS NOT @exclude)
+ (SELECT COUNT(*) FROM knife_servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND id IS NOT @exclude) AS n
`).get({ host, exclude }).n;
}
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */ /** Ports, die auf einem bestimmten Docker-Host belegt sind. */
usedPortsOn(host) { usedPortsOn(host) {
const used = new Set(); const used = new Set();
@@ -500,7 +508,12 @@ export class ServerManager {
this.simulator.stop(matchId); this.simulator.stop(matchId);
this.service.resetLiveScore(matchId, seriesMap.number); this.service.resetLiveScore(matchId, seriesMap.number);
// Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen // Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen
if (row.knife_at) plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange]; if (row.knife_at) {
// Eine noch laufende Messerrunde zuerst anhalten: sonst endet sie (mp_maxrounds 1) mitten im Einrichten,
// und ihr Ergebnis würde als Ende der ersten Map gezählt
plan.beforeMapChange = ['mp_warmup_pausetimer 1', 'mp_warmup_start', ...plan.beforeMapChange];
plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
}
try { try {
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
} catch (err) { } catch (err) {
+12
View File
@@ -753,7 +753,19 @@ test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, d
const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1'; const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1';
const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1'; const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1';
await playVeto(call, id, { A: tokens[capA], B: tokens[capB] }); await playVeto(call, id, { A: tokens[capA], B: tokens[capB] });
// Das Ende der Messerrunde kommt während des Einrichtens an: darf nicht als Ende von Map 1 zählen
await waitFor(() => app.manager.row(id).status === 'configuring', { label: 'Map 1 wird eingerichtet' });
const firstMap = app.service.seriesMaps(id)[0].map;
const knifeEnd = ['Team "CT" triggered "SFUI_Notice_Target_Saved" (CT "1") (T "0")', 'World triggered "Round_End"',
`Game Over: scrimcomp2v2 ${firstMap} score 1:0 after 2 min`].join('\n');
const key = app.manager.row(id).config_key;
assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body: knifeEnd })).status, 200);
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' }); await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' });
const map1 = app.service.seriesMaps(id)[0];
assert.notEqual(map1.status, 'done', 'Messerrunde zählt nicht als Map-Ende');
assert.equal(map1.score_a, 0);
assert.equal(app.service.getMatch(id).current_map_number, 1);
assert.deepEqual(plans.at(-1).beforeMapChange.slice(0, 2), ['mp_warmup_pausetimer 1', 'mp_warmup_start'], 'laufende Messerrunde wird zuerst angehalten');
const row = app.manager.row(id); const row = app.manager.row(id);
assert.equal(row.handle, handle, 'derselbe Server'); assert.equal(row.handle, handle, 'derselbe Server');
assert.equal(row.knife_at, null); assert.equal(row.knife_at, null);
+26
View File
@@ -107,3 +107,29 @@ test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtige
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b'); assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/); assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
}); });
test('Neue Server meiden Hosts, auf denen gerade ein Server hochfährt (SteamCMD)', async () => {
const starting = { '10.0.0.2': 1 };
const p = new DockerHostPool(options([hostA, hostB]), {
getUsedPorts: () => new Set(),
getStarting: (publicHost, exclude) => (exclude === 'self' ? 0 : starting[publicHost] ?? 0),
runDocker: fakeDocker().run,
});
// b hat mehr Plätze, aber dort lädt gerade ein Container: a gewinnt
assert.equal(p.pickHost().name, 'a');
// Der hochfahrende Server selbst zählt nicht gegen seinen eigenen Neustart
assert.equal(p.pickHost('self').name, 'b');
// Warteschlange im Prozess zählt genauso
starting['10.0.0.2'] = 0;
p.adapters.get('b').pendingStarts = 1;
assert.equal(p.pickHost().name, 'a');
// Starts zählen hoch und nach dem Update-Check wieder herunter
const a = p.adapters.get('a');
let done;
a.waitForUpdateCheck = () => new Promise((r) => { done = r; });
await a.start({ matchId: 'm1', mode: 'competitive', secrets });
assert.equal(a.pendingStarts, 1);
done();
await new Promise((r) => setImmediate(r));
assert.equal(a.pendingStarts, 0);
});