Phase6
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 13:11:28 +02:00
parent d29a02f2a4
commit 49a98ee31d
81 changed files with 1462 additions and 53 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/scheduler",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -0,0 +1,98 @@
import type { NodeCapacity, NodeSnapshot } from './types';
export const HEARTBEAT_STALE_MS = 90_000;
/** Nodes that accept new server allocations (provisioning). */
export const ALLOCATION_ELIGIBLE_STATUSES = ['ONLINE'] as const;
/** Nodes that can start already-provisioned servers. */
export const START_ELIGIBLE_STATUSES = ['ONLINE', 'DRAINING'] as const;
export function isHeartbeatFresh(
lastHeartbeatAt: Date | null,
now = Date.now(),
): boolean {
if (!lastHeartbeatAt) {
return false;
}
return now - lastHeartbeatAt.getTime() <= HEARTBEAT_STALE_MS;
}
export function effectiveMaxRamMb(node: NodeSnapshot): number {
return Math.max(node.maxRamMb - node.platformRamMb, 0);
}
export function computeAvailableRamMb(node: NodeSnapshot): number {
const budget = effectiveMaxRamMb(node);
if (
node.memoryTotalBytes != null &&
node.memoryUsedBytes != null &&
node.memoryTotalBytes > 0
) {
const hostFreeMb = Math.floor(
(node.memoryTotalBytes - node.memoryUsedBytes) / (1024 * 1024),
);
return Math.max(0, Math.min(budget - node.reservedRamMb, hostFreeMb));
}
return Math.max(0, budget - node.reservedRamMb);
}
export function computeAvailableSlots(node: NodeSnapshot): number {
const byMax = Math.max(0, node.maxServers - node.activeAllocationCount);
const byActive = Math.max(0, node.maxServers - node.activeServers);
return Math.min(byMax, byActive);
}
export function canAllocateOnNode(node: NodeSnapshot, requiredRamMb: number): boolean {
if (!ALLOCATION_ELIGIBLE_STATUSES.includes(node.status as (typeof ALLOCATION_ELIGIBLE_STATUSES)[number])) {
return false;
}
if (!isHeartbeatFresh(node.lastHeartbeatAt)) {
return false;
}
const capacity = evaluateNodeCapacity(node, requiredRamMb);
return capacity.schedulable;
}
export function canStartOnNode(node: NodeSnapshot, requiredRamMb: number): boolean {
if (!START_ELIGIBLE_STATUSES.includes(node.status as (typeof START_ELIGIBLE_STATUSES)[number])) {
return false;
}
if (!isHeartbeatFresh(node.lastHeartbeatAt)) {
return false;
}
const capacity = evaluateNodeCapacity(node, requiredRamMb);
return capacity.schedulable;
}
export function evaluateNodeCapacity(
node: NodeSnapshot,
requiredRamMb: number,
): NodeCapacity {
const availableRamMb = computeAvailableRamMb(node);
const availableSlots = computeAvailableSlots(node);
const schedulable =
requiredRamMb > 0 &&
availableRamMb >= requiredRamMb &&
availableSlots > 0;
const ramHeadroom = availableRamMb - requiredRamMb;
const slotHeadroom = availableSlots - 1;
const score = schedulable
? ramHeadroom * 10 + slotHeadroom * 100 - node.reservedRamMb
: -1;
return {
schedulable,
availableRamMb,
availableSlots,
score,
};
}

View File

@@ -0,0 +1,19 @@
export type { GameNodeStatus, NodeCapacity, NodeSnapshot } from './types';
export {
ALLOCATION_ELIGIBLE_STATUSES,
START_ELIGIBLE_STATUSES,
HEARTBEAT_STALE_MS,
isHeartbeatFresh,
effectiveMaxRamMb,
computeAvailableRamMb,
computeAvailableSlots,
canAllocateOnNode,
canStartOnNode,
evaluateNodeCapacity,
} from './capacity';
export {
selectNodeForAllocation,
findNodeSnapshot,
} from './select-node';

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
canAllocateOnNode,
canStartOnNode,
selectNodeForAllocation,
} from './index';
import type { NodeSnapshot } from './types';
function node(partial: Partial<NodeSnapshot> & Pick<NodeSnapshot, 'id'>): NodeSnapshot {
return {
status: 'ONLINE',
maxServers: 10,
maxRamMb: 16384,
platformRamMb: 2048,
activeServers: 0,
lastHeartbeatAt: new Date(),
reservedRamMb: 0,
activeAllocationCount: 0,
...partial,
};
}
describe('scheduler capacity', () => {
it('rejects maintenance and draining nodes for new allocations', () => {
const maintenance = node({ id: 'a', status: 'MAINTENANCE' });
const draining = node({ id: 'b', status: 'DRAINING' });
expect(canAllocateOnNode(maintenance, 1024)).toBe(false);
expect(canAllocateOnNode(draining, 1024)).toBe(false);
expect(canStartOnNode(draining, 1024)).toBe(true);
});
it('picks the least loaded node for placement', () => {
const lightlyLoaded = node({ id: 'light', reservedRamMb: 1024 });
const heavilyLoaded = node({ id: 'heavy', reservedRamMb: 8192 });
const selected = selectNodeForAllocation(
[heavilyLoaded, lightlyLoaded],
2048,
);
expect(selected?.id).toBe('light');
});
it('returns null when no node has enough RAM', () => {
const saturated = node({
id: 'full',
reservedRamMb: 14000,
maxRamMb: 16384,
platformRamMb: 2048,
});
expect(selectNodeForAllocation([saturated], 4096)).toBeNull();
});
});

View File

@@ -0,0 +1,30 @@
import { canAllocateOnNode, evaluateNodeCapacity } from './capacity';
import type { NodeSnapshot } from './types';
export function selectNodeForAllocation(
nodes: NodeSnapshot[],
requiredRamMb: number,
): NodeSnapshot | null {
const candidates = nodes
.filter((node) => canAllocateOnNode(node, requiredRamMb))
.map((node) => ({
node,
capacity: evaluateNodeCapacity(node, requiredRamMb),
}))
.sort((left, right) => {
if (right.capacity.score !== left.capacity.score) {
return right.capacity.score - left.capacity.score;
}
return left.node.reservedRamMb - right.node.reservedRamMb;
});
return candidates[0]?.node ?? null;
}
export function findNodeSnapshot(
nodes: NodeSnapshot[],
nodeId: string,
): NodeSnapshot | null {
return nodes.find((node) => node.id === nodeId) ?? null;
}

View File

@@ -0,0 +1,27 @@
export type GameNodeStatus =
| 'ONLINE'
| 'OFFLINE'
| 'MAINTENANCE'
| 'DRAINING'
| 'UNREACHABLE';
export interface NodeSnapshot {
id: string;
status: GameNodeStatus;
maxServers: number;
maxRamMb: number;
platformRamMb: number;
activeServers: number;
lastHeartbeatAt: Date | null;
memoryTotalBytes?: number | null;
memoryUsedBytes?: number | null;
reservedRamMb: number;
activeAllocationCount: number;
}
export interface NodeCapacity {
schedulable: boolean;
availableRamMb: number;
availableSlots: number;
score: number;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}