Adds two new classes: - Service: long-running component with start/stop lifecycle, health checks, builder pattern and subclass support - ServiceManager: orchestrates multiple services with dependency-ordered startup, failure isolation, retry with backoff, and reverse-order shutdown
518 lines
14 KiB
TypeScript
518 lines
14 KiB
TypeScript
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();
|
|
});
|
|
|
|
export default tap.start();
|