BREAKING CHANGE(service): expand service lifecycle management with instance-aware hooks, startup timeouts, labels, readiness waits, and auto-restart support
This commit is contained in:
@@ -514,4 +514,381 @@ tap.test('should support addServiceFromOptions', async () => {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user