Phase6
This commit is contained in:
18
packages/scheduler/package.json
Normal file
18
packages/scheduler/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
98
packages/scheduler/src/capacity.ts
Normal file
98
packages/scheduler/src/capacity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
packages/scheduler/src/index.ts
Normal file
19
packages/scheduler/src/index.ts
Normal 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';
|
||||
56
packages/scheduler/src/scheduling.test.ts
Normal file
56
packages/scheduler/src/scheduling.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
packages/scheduler/src/select-node.ts
Normal file
30
packages/scheduler/src/select-node.ts
Normal 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;
|
||||
}
|
||||
27
packages/scheduler/src/types.ts
Normal file
27
packages/scheduler/src/types.ts
Normal 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;
|
||||
}
|
||||
9
packages/scheduler/tsconfig.json
Normal file
9
packages/scheduler/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user