Files
taskbuffer/test/test.14.servicemanager.ts

895 lines
26 KiB
TypeScript
Raw Normal View History

import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as taskbuffer from '../ts/index.js';
import * as smartdelay from '@push.rocks/smartdelay';
// ── Test 1: Basic service start/stop lifecycle ─────────────
tap.test('should start and stop a simple service', async () => {
let started = false;
let stopped = false;
const service = new taskbuffer.Service('TestService')
.withStart(async () => { started = true; })
.withStop(async () => { stopped = true; });
expect(service.state).toEqual('stopped');
await service.start();
expect(service.state).toEqual('running');
expect(started).toBeTrue();
await service.stop();
expect(service.state).toEqual('stopped');
expect(stopped).toBeTrue();
});
// ── Test 2: Builder pattern chaining ───────────────────────
tap.test('should support builder pattern chaining', async () => {
const service = new taskbuffer.Service('ChainedService')
.critical()
.dependsOn('Dep1', 'Dep2')
.withStart(async () => 'result')
.withStop(async () => {})
.withRetry({ maxRetries: 5, baseDelayMs: 100 });
expect(service.criticality).toEqual('critical');
expect(service.dependencies).toEqual(['Dep1', 'Dep2']);
expect(service.retryConfig).toBeTruthy();
expect(service.retryConfig!.maxRetries).toEqual(5);
});
// ── Test 3: Subclass pattern ───────────────────────────────
tap.test('should support subclass pattern', async () => {
class MyService extends taskbuffer.Service<string> {
public startCalled = false;
public stopCalled = false;
constructor() {
super('MySubclassService');
this.optional();
}
protected async serviceStart(): Promise<string> {
this.startCalled = true;
return 'hello';
}
protected async serviceStop(): Promise<void> {
this.stopCalled = true;
}
}
const service = new MyService();
const result = await service.start();
expect(service.startCalled).toBeTrue();
expect(service.state).toEqual('running');
await service.stop();
expect(service.stopCalled).toBeTrue();
expect(service.state).toEqual('stopped');
});
// ── Test 4: Constructor with options object ────────────────
tap.test('should accept options object in constructor', async () => {
let started = false;
const service = new taskbuffer.Service({
name: 'OptionsService',
criticality: 'critical',
dependencies: ['A', 'B'],
start: async () => { started = true; },
stop: async () => {},
retry: { maxRetries: 10 },
});
expect(service.name).toEqual('OptionsService');
expect(service.criticality).toEqual('critical');
expect(service.dependencies).toEqual(['A', 'B']);
await service.start();
expect(started).toBeTrue();
await service.stop();
});
// ── Test 5: ServiceManager dependency ordering ─────────────
tap.test('should start services in dependency order', async () => {
const order: string[] = [];
const manager = new taskbuffer.ServiceManager({ name: 'TestManager' });
manager.addService(
new taskbuffer.Service('C')
.dependsOn('B')
.withStart(async () => { order.push('C'); })
.withStop(async () => {}),
);
manager.addService(
new taskbuffer.Service('A')
.withStart(async () => { order.push('A'); })
.withStop(async () => {}),
);
manager.addService(
new taskbuffer.Service('B')
.dependsOn('A')
.withStart(async () => { order.push('B'); })
.withStop(async () => {}),
);
await manager.start();
// A must come before B, B must come before C
expect(order.indexOf('A')).toBeLessThan(order.indexOf('B'));
expect(order.indexOf('B')).toBeLessThan(order.indexOf('C'));
await manager.stop();
});
// ── Test 6: Critical service failure aborts startup ────────
tap.test('should abort startup when a critical service fails', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'CriticalTest' });
let serviceCStopped = false;
manager.addService(
new taskbuffer.Service('Working')
.critical()
.withStart(async () => {})
.withStop(async () => {}),
);
manager.addService(
new taskbuffer.Service('Broken')
.critical()
.withStart(async () => { throw new Error('boom'); })
.withStop(async () => {})
.withRetry({ maxRetries: 0 }),
);
manager.addService(
new taskbuffer.Service('AfterBroken')
.dependsOn('Broken')
.withStart(async () => { serviceCStopped = true; })
.withStop(async () => {}),
);
let caught = false;
try {
await manager.start();
} catch (err) {
caught = true;
expect((err as Error).message).toInclude('Broken');
}
expect(caught).toBeTrue();
// AfterBroken should never have started because Broken (its dep) failed
expect(serviceCStopped).toBeFalse();
});
// ── Test 7: Optional service failure continues startup ─────
tap.test('should continue startup when an optional service fails', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'OptionalTest' });
let criticalStarted = false;
manager.addService(
new taskbuffer.Service('Critical')
.critical()
.withStart(async () => { criticalStarted = true; })
.withStop(async () => {})
.withRetry({ maxRetries: 0 }),
);
manager.addService(
new taskbuffer.Service('Optional')
.optional()
.withStart(async () => { throw new Error('oops'); })
.withStop(async () => {})
.withRetry({ maxRetries: 0 }),
);
// Should NOT throw
await manager.start();
expect(criticalStarted).toBeTrue();
const health = manager.getHealth();
expect(health.overall).toEqual('degraded');
const optionalStatus = manager.getServiceStatus('Optional');
expect(optionalStatus!.state).toEqual('failed');
await manager.stop();
});
// ── Test 8: Retry with backoff for optional services ───────
tap.test('should retry failed optional services', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'RetryTest' });
let attempts = 0;
manager.addService(
new taskbuffer.Service('Flaky')
.optional()
.withStart(async () => {
attempts++;
if (attempts < 3) {
throw new Error(`attempt ${attempts} failed`);
}
})
.withStop(async () => {})
.withRetry({ maxRetries: 5, baseDelayMs: 50, maxDelayMs: 100, backoffFactor: 1 }),
);
await manager.start();
expect(attempts).toEqual(3);
const status = manager.getServiceStatus('Flaky');
expect(status!.state).toEqual('running');
await manager.stop();
});
// ── Test 9: Reverse-order shutdown ─────────────────────────
tap.test('should stop services in reverse dependency order', async () => {
const order: string[] = [];
const manager = new taskbuffer.ServiceManager({ name: 'ShutdownTest' });
manager.addService(
new taskbuffer.Service('Base')
.withStart(async () => {})
.withStop(async () => { order.push('Base'); }),
);
manager.addService(
new taskbuffer.Service('Middle')
.dependsOn('Base')
.withStart(async () => {})
.withStop(async () => { order.push('Middle'); }),
);
manager.addService(
new taskbuffer.Service('Top')
.dependsOn('Middle')
.withStart(async () => {})
.withStop(async () => { order.push('Top'); }),
);
await manager.start();
await manager.stop();
// Top should stop before Middle, Middle before Base
expect(order.indexOf('Top')).toBeLessThan(order.indexOf('Middle'));
expect(order.indexOf('Middle')).toBeLessThan(order.indexOf('Base'));
});
// ── Test 10: Circular dependency detection ─────────────────
tap.test('should throw on circular dependency', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'CycleTest' });
manager.addService(
new taskbuffer.Service('A')
.dependsOn('B')
.withStart(async () => {})
.withStop(async () => {}),
);
manager.addService(
new taskbuffer.Service('B')
.dependsOn('A')
.withStart(async () => {})
.withStop(async () => {}),
);
let caught = false;
try {
await manager.start();
} catch (err) {
caught = true;
expect((err as Error).message).toInclude('Circular dependency');
}
expect(caught).toBeTrue();
});
// ── Test 11: restartService cascades to dependents ─────────
tap.test('should restart service and its dependents', async () => {
const startOrder: string[] = [];
const stopOrder: string[] = [];
const manager = new taskbuffer.ServiceManager({ name: 'RestartTest' });
manager.addService(
new taskbuffer.Service('Base')
.withStart(async () => { startOrder.push('Base'); })
.withStop(async () => { stopOrder.push('Base'); })
.withRetry({ maxRetries: 0 }),
);
manager.addService(
new taskbuffer.Service('Dep')
.dependsOn('Base')
.withStart(async () => { startOrder.push('Dep'); })
.withStop(async () => { stopOrder.push('Dep'); })
.withRetry({ maxRetries: 0 }),
);
await manager.start();
expect(startOrder).toEqual(['Base', 'Dep']);
// Clear tracking
startOrder.length = 0;
stopOrder.length = 0;
await manager.restartService('Base');
// Dep should be stopped first, then Base, then Base restarted, then Dep
expect(stopOrder).toEqual(['Dep', 'Base']);
expect(startOrder).toEqual(['Base', 'Dep']);
await manager.stop();
});
// ── Test 12: getHealth returns correct aggregated status ───
tap.test('should return correct health aggregation', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'HealthTest' });
manager.addService(
new taskbuffer.Service('OK')
.critical()
.withStart(async () => {})
.withStop(async () => {})
.withRetry({ maxRetries: 0 }),
);
manager.addService(
new taskbuffer.Service('AlsoOK')
.optional()
.withStart(async () => {})
.withStop(async () => {})
.withRetry({ maxRetries: 0 }),
);
await manager.start();
const health = manager.getHealth();
expect(health.overall).toEqual('healthy');
expect(health.services.length).toEqual(2);
expect(health.startedAt).toBeTruthy();
expect(health.uptime).toBeGreaterThanOrEqual(0);
await manager.stop();
});
// ── Test 13: Events emitted on state transitions ───────────
tap.test('should emit events on state transitions', async () => {
const events: taskbuffer.IServiceEvent[] = [];
const manager = new taskbuffer.ServiceManager({ name: 'EventTest' });
manager.addService(
new taskbuffer.Service('Svc')
.withStart(async () => {})
.withStop(async () => {})
.withRetry({ maxRetries: 0 }),
);
manager.serviceSubject.subscribe((event) => {
events.push(event);
});
await manager.start();
await manager.stop();
const types = events.map((e) => e.type);
expect(types).toContain('started');
expect(types).toContain('stopped');
});
// ── Test 14: Parallel startup of independent services ──────
tap.test('should start independent services in parallel', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'ParallelTest' });
const startTimes: Record<string, number> = {};
manager.addService(
new taskbuffer.Service('A')
.withStart(async () => {
startTimes['A'] = Date.now();
await smartdelay.delayFor(100);
})
.withStop(async () => {}),
);
manager.addService(
new taskbuffer.Service('B')
.withStart(async () => {
startTimes['B'] = Date.now();
await smartdelay.delayFor(100);
})
.withStop(async () => {}),
);
await manager.start();
// Both should start at roughly the same time (within 50ms)
const diff = Math.abs(startTimes['A'] - startTimes['B']);
expect(diff).toBeLessThan(50);
await manager.stop();
});
// ── Test 15: getStatus snapshot ────────────────────────────
tap.test('should return accurate status snapshot', async () => {
const service = new taskbuffer.Service('StatusTest')
.critical()
.dependsOn('X')
.withStart(async () => {})
.withStop(async () => {});
const statusBefore = service.getStatus();
expect(statusBefore.state).toEqual('stopped');
expect(statusBefore.name).toEqual('StatusTest');
expect(statusBefore.criticality).toEqual('critical');
expect(statusBefore.dependencies).toEqual(['X']);
expect(statusBefore.errorCount).toEqual(0);
await service.start();
const statusAfter = service.getStatus();
expect(statusAfter.state).toEqual('running');
expect(statusAfter.startedAt).toBeTruthy();
expect(statusAfter.uptime).toBeGreaterThanOrEqual(0);
await service.stop();
});
// ── Test 16: Missing dependency detection ──────────────────
tap.test('should throw when a dependency is not registered', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'MissingDepTest' });
manager.addService(
new taskbuffer.Service('Lonely')
.dependsOn('Ghost')
.withStart(async () => {})
.withStop(async () => {}),
);
let caught = false;
try {
await manager.start();
} catch (err) {
caught = true;
expect((err as Error).message).toInclude('Ghost');
expect((err as Error).message).toInclude('not registered');
}
expect(caught).toBeTrue();
});
// ── Test 17: No-op on double start/stop ────────────────────
tap.test('should be a no-op when starting an already-running service', async () => {
let startCount = 0;
const service = new taskbuffer.Service('DoubleStart')
.withStart(async () => { startCount++; })
.withStop(async () => {});
await service.start();
await service.start(); // should be no-op
expect(startCount).toEqual(1);
await service.stop();
await service.stop(); // should be no-op
});
// ── Test 18: addServiceFromOptions convenience ─────────────
tap.test('should support addServiceFromOptions', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'ConvenienceTest' });
let started = false;
const service = manager.addServiceFromOptions({
name: 'Easy',
start: async () => { started = true; },
stop: async () => {},
});
expect(service).toBeInstanceOf(taskbuffer.Service);
expect(service.name).toEqual('Easy');
await manager.start();
expect(started).toBeTrue();
await manager.stop();
});
// ═══════════════════════════════════════════════════════════
// NEW TESTS: Improvements 1-5
// ═══════════════════════════════════════════════════════════
// ── Test 19: service.instance stores start result ──────────
tap.test('should store start result as service.instance', async () => {
const pool = { query: (sql: string) => `result: ${sql}` };
const service = new taskbuffer.Service<typeof pool>('DB')
.withStart(async () => pool)
.withStop(async (inst) => {});
expect(service.instance).toBeUndefined();
const result = await service.start();
expect(result).toEqual(pool);
expect(service.instance).toEqual(pool);
expect(service.instance!.query('SELECT 1')).toEqual('result: SELECT 1');
// Status should report hasInstance
expect(service.getStatus().hasInstance).toBeTrue();
await service.stop();
expect(service.instance).toBeUndefined();
expect(service.getStatus().hasInstance).toBeFalse();
});
// ── Test 20: stop receives the instance ────────────────────
tap.test('should pass instance to stop function', async () => {
let receivedInstance: any = null;
const service = new taskbuffer.Service<{ id: number }>('InstanceStop')
.withStart(async () => ({ id: 42 }))
.withStop(async (inst) => { receivedInstance = inst; });
await service.start();
await service.stop();
expect(receivedInstance).toBeTruthy();
expect(receivedInstance.id).toEqual(42);
});
// ── Test 21: healthCheck receives the instance ─────────────
tap.test('should pass instance to health check function', async () => {
let checkedInstance: any = null;
const service = new taskbuffer.Service<{ healthy: boolean }>('InstanceHealth')
.withStart(async () => ({ healthy: true }))
.withStop(async () => {})
.withHealthCheck(async (inst) => {
checkedInstance = inst;
return inst.healthy;
}, { intervalMs: 60000 }); // long interval so it doesn't auto-run
await service.start();
// Manually trigger health check
const ok = await service.checkHealth();
expect(ok).toBeTrue();
expect(checkedInstance).toBeTruthy();
expect(checkedInstance.healthy).toBeTrue();
await service.stop();
});
// ── Test 22: double start returns existing instance ────────
tap.test('should return existing instance on double start', async () => {
let callCount = 0;
const service = new taskbuffer.Service<number>('DoubleInstance')
.withStart(async () => { callCount++; return callCount; })
.withStop(async () => {});
const first = await service.start();
const second = await service.start();
expect(first).toEqual(1);
expect(second).toEqual(1); // should return same instance, not call start again
expect(callCount).toEqual(1);
await service.stop();
});
// ── Test 23: Labels on Service ─────────────────────────────
tap.test('should support labels on services', async () => {
const service = new taskbuffer.Service('LabeledService')
.withLabels({ type: 'database', env: 'production' })
.withStart(async () => {})
.withStop(async () => {});
expect(service.hasLabel('type')).toBeTrue();
expect(service.hasLabel('type', 'database')).toBeTrue();
expect(service.hasLabel('type', 'cache')).toBeFalse();
expect(service.getLabel('env')).toEqual('production');
service.setLabel('region', 'eu-west');
expect(service.hasLabel('region')).toBeTrue();
service.removeLabel('env');
expect(service.hasLabel('env')).toBeFalse();
// Labels in status
const status = service.getStatus();
expect(status.labels).toBeTruthy();
expect(status.labels!.type).toEqual('database');
expect(status.labels!.region).toEqual('eu-west');
});
// ── Test 24: Labels via IServiceOptions ────────────────────
tap.test('should accept labels in options constructor', async () => {
const service = new taskbuffer.Service({
name: 'OptionsLabeled',
start: async () => {},
stop: async () => {},
labels: { kind: 'cache' },
});
expect(service.hasLabel('kind', 'cache')).toBeTrue();
});
// ── Test 25: ServiceManager.getServicesByLabel ─────────────
tap.test('should query services by label in ServiceManager', async () => {
const manager = new taskbuffer.ServiceManager({ name: 'LabelQueryTest' });
manager.addService(
new taskbuffer.Service('Redis')
.withLabels({ type: 'cache', tier: 'fast' })
.withStart(async () => {})
.withStop(async () => {}),
);
manager.addService(
new taskbuffer.Service('Memcached')
.withLabels({ type: 'cache', tier: 'fast' })
.withStart(async () => {})
.withStop(async () => {}),
);
manager.addService(
new taskbuffer.Service('Postgres')
.withLabels({ type: 'database', tier: 'slow' })
.withStart(async () => {})
.withStop(async () => {}),
);
const caches = manager.getServicesByLabel('type', 'cache');
expect(caches.length).toEqual(2);
const databases = manager.getServicesByLabel('type', 'database');
expect(databases.length).toEqual(1);
expect(databases[0].name).toEqual('Postgres');
const statuses = manager.getServicesStatusByLabel('tier', 'fast');
expect(statuses.length).toEqual(2);
await manager.stop();
});
// ── Test 26: waitForState / waitForRunning ──────────────────
tap.test('should support waitForState and waitForRunning', async () => {
const service = new taskbuffer.Service('WaitService')
.withStart(async () => {
await smartdelay.delayFor(50);
})
.withStop(async () => {});
// Start in background
const startPromise = service.start();
// Wait for running state
await service.waitForRunning(2000);
expect(service.state).toEqual('running');
await startPromise;
// Now wait for stopped
const stopPromise = service.stop();
await service.waitForStopped(2000);
expect(service.state).toEqual('stopped');
await stopPromise;
});
// ── Test 27: waitForState timeout ──────────────────────────
tap.test('should timeout when waiting for a state that never comes', async () => {
const service = new taskbuffer.Service('TimeoutWait')
.withStart(async () => {})
.withStop(async () => {});
// Service is stopped, wait for 'running' with a short timeout
let caught = false;
try {
await service.waitForState('running', 100);
} catch (err) {
caught = true;
expect((err as Error).message).toInclude('timed out');
expect((err as Error).message).toInclude('running');
}
expect(caught).toBeTrue();
});
// ── Test 28: waitForState already in target state ──────────
tap.test('should resolve immediately if already in target state', async () => {
const service = new taskbuffer.Service('AlreadyThere')
.withStart(async () => {})
.withStop(async () => {});
// Already stopped
await service.waitForState('stopped', 100); // should not throw
await service.start();
await service.waitForState('running', 100); // should not throw
await service.stop();
});
// ── Test 29: Per-service startup timeout ───────────────────
tap.test('should timeout when serviceStart hangs', async () => {
const service = new taskbuffer.Service('SlowStart')
.withStart(async () => {
await smartdelay.delayFor(5000); // very slow
})
.withStop(async () => {})
.withStartupTimeout(100); // 100ms timeout
let caught = false;
try {
await service.start();
} catch (err) {
caught = true;
expect((err as Error).message).toInclude('startup timed out');
expect((err as Error).message).toInclude('100ms');
}
expect(caught).toBeTrue();
expect(service.state).toEqual('failed');
});
// ── Test 30: Startup timeout via options ────────────────────
tap.test('should accept startupTimeoutMs in options constructor', async () => {
const service = new taskbuffer.Service({
name: 'TimeoutOptions',
start: async () => { await smartdelay.delayFor(5000); },
stop: async () => {},
startupTimeoutMs: 100,
});
let caught = false;
try {
await service.start();
} catch (err) {
caught = true;
expect((err as Error).message).toInclude('startup timed out');
}
expect(caught).toBeTrue();
});
// ── Test 31: Auto-restart on health check failure ──────────
tap.test('should auto-restart when health checks fail', async () => {
let startCount = 0;
let healthy = false;
const service = new taskbuffer.Service('AutoRestart')
.withStart(async () => {
startCount++;
// After first restart, become healthy
if (startCount >= 2) {
healthy = true;
}
})
.withStop(async () => {})
.withHealthCheck(async () => healthy, {
intervalMs: 30000, // we'll call checkHealth manually
failuresBeforeDegraded: 1,
failuresBeforeFailed: 2,
autoRestart: true,
maxAutoRestarts: 3,
autoRestartDelayMs: 50,
autoRestartBackoffFactor: 1,
});
await service.start();
expect(startCount).toEqual(1);
// Fail health checks manually to push to failed
await service.checkHealth(); // 1 failure -> degraded
expect(service.state).toEqual('degraded');
await service.checkHealth(); // 2 failures -> failed + auto-restart scheduled
expect(service.state).toEqual('failed');
// Wait for auto-restart to complete
await service.waitForRunning(2000);
expect(service.state).toEqual('running');
expect(startCount).toEqual(2);
await service.stop();
});
// ── Test 32: Auto-restart max attempts ─────────────────────
tap.test('should stop auto-restarting after max attempts', async () => {
let startCount = 0;
const events: string[] = [];
const service = new taskbuffer.Service('MaxRestart')
.withStart(async () => {
startCount++;
if (startCount > 1) {
throw new Error('always fails');
}
})
.withStop(async () => {})
.withHealthCheck(async () => false, {
intervalMs: 60000,
failuresBeforeDegraded: 1,
failuresBeforeFailed: 2,
autoRestart: true,
maxAutoRestarts: 2,
autoRestartDelayMs: 50,
autoRestartBackoffFactor: 1,
});
service.eventSubject.subscribe((e) => events.push(e.type));
await service.start();
expect(startCount).toEqual(1);
// Push to failed
await service.checkHealth();
await service.checkHealth();
// Wait for auto-restart attempts to exhaust
await smartdelay.delayFor(500);
// Should have emitted autoRestarting events
expect(events).toContain('autoRestarting');
// Service should be in failed state (restarts exhausted)
expect(service.state).toEqual('failed');
// Clean up
await service.stop();
});
// ── Test 33: Builder chaining with new methods ─────────────
tap.test('should chain all new builder methods', async () => {
const service = new taskbuffer.Service('FullBuilder')
.critical()
.dependsOn('A')
.withStart(async () => ({ conn: true }))
.withStop(async (inst) => {})
.withHealthCheck(async (inst) => inst.conn)
.withRetry({ maxRetries: 3 })
.withStartupTimeout(5000)
.withLabels({ env: 'test' });
expect(service.criticality).toEqual('critical');
expect(service.startupTimeoutMs).toEqual(5000);
expect(service.hasLabel('env', 'test')).toBeTrue();
});
export default tap.start();