Implement Phase 1 tasks 1.9-1.11 (observability + integration test + Dockerfile/CI)
src/observability/metrics.ts — full prom-client implementation. All 10
Phase 1 metrics registered (processor_consumer_reads_total,
_records_total, _lag, _decode_errors_total, processor_position_writes_total
{status}, _write_duration_seconds, processor_acks_total,
processor_device_state_{size,evictions_total}) plus nodejs_* defaults.
node:http server with /metrics, /healthz, /readyz. /readyz checks
redis.status === 'ready' AND a 5s-cached SELECT 1 Postgres probe.
processor_consumer_lag sampled every 10s via XINFO GROUPS, falling back
to a no-op when the consumer group hasn't been created yet.
src/main.ts — replaces the trace-logging shim with createMetrics() and
startMetricsServer(); shutdown closes the metrics server before
redis.quit() and pool.end().
test/metrics.test.ts — 22 unit tests: exposition format, every metric
type behaviour, all four HTTP endpoint paths including /readyz 503 cases.
test/pipeline.integration.test.ts — testcontainers Redis 7 +
TimescaleDB latest-pg16. Four scenarios: happy path with bigint+Buffer
attribute round-trip, idempotency on (device_id, ts), malformed payload
stays in PEL (decode_errors_total increments), writer failure → retry
(weaker variant per spec: stop Postgres before publish, restart, verify
row appears). Skip-on-no-Docker pattern verified — exits 0 without
Docker.
Dockerfile — multi-stage matching tcp-ingestion. EXPOSE 9090 only,
HEALTHCHECK on /readyz, image-source label points at processor repo.
.gitea/workflows/build.yml — single-job workflow mirroring
tcp-ingestion. Path filters cover src/, test/, build config, Dockerfile.
Portainer webhook step uncommented for :main auto-deploy.
compose.dev.yaml — local-build variant with Redis + TimescaleDB +
processor-dev for verifying Dockerfile changes without the registry
round-trip.
README.md — fleshed out from stub: quick-start, Docker build, deployment
note, env vars, tests (unit vs. integration), CI behavior. Flags the
deploy-side change needed: deploy/compose.yaml needs a TimescaleDB
service and a processor service entry added.
Verification: typecheck, lint clean; 134 unit tests passing across 8
files (+22 from this batch). pnpm test:integration runs cleanly under
the no-Docker skip pattern.
Phase 1 is now complete. Service is pilot-ready.
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Unit tests for src/observability/metrics.ts
|
||||
*
|
||||
* Covers:
|
||||
* - createMetrics(): Prometheus exposition format contains all Phase 1 metrics
|
||||
* - Counter increments via metrics.inc()
|
||||
* - Histogram observation via metrics.observe()
|
||||
* - Gauge set via metrics.observe() for processor_consumer_lag and processor_device_state_size
|
||||
* - Unknown metric name is silently ignored (no throw)
|
||||
* - startMetricsServer(): GET /metrics returns 200 with text/plain
|
||||
* - startMetricsServer(): GET /healthz returns 200 {"status":"ok"}
|
||||
* - startMetricsServer(): GET /readyz returns 200 when both deps are ready
|
||||
* - startMetricsServer(): GET /readyz returns 503 when Redis is not ready
|
||||
* - startMetricsServer(): GET /readyz returns 503 when Postgres is not ready
|
||||
* - startMetricsServer(): GET /readyz returns 503 when neither dep is ready
|
||||
* - startMetricsServer(): non-GET method returns 405
|
||||
* - startMetricsServer(): unknown path returns 404
|
||||
* - nodejs_* default metrics are present in the exposition output
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import * as http from 'node:http';
|
||||
import { createMetrics, startMetricsServer } from '../src/observability/metrics.js';
|
||||
import type { ReadyzDeps } from '../src/observability/metrics.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper — makes a simple GET (or other method) against the test server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function httpGet(
|
||||
port: number,
|
||||
path: string,
|
||||
method = 'GET',
|
||||
): Promise<{ statusCode: number; body: string; contentType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ hostname: '127.0.0.1', port, path, method }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode ?? 0,
|
||||
body,
|
||||
contentType: (res.headers['content-type'] as string | undefined) ?? '',
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createMetrics tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createMetrics — exposition format', () => {
|
||||
it('returns valid Prometheus text format containing all Phase 1 metrics', async () => {
|
||||
const metrics = createMetrics();
|
||||
const text = await metrics.serializeMetrics();
|
||||
|
||||
// Every metric from the task 1.9 inventory must appear in the output.
|
||||
expect(text).toContain('processor_consumer_reads_total');
|
||||
expect(text).toContain('processor_consumer_records_total');
|
||||
expect(text).toContain('processor_consumer_lag');
|
||||
expect(text).toContain('processor_decode_errors_total');
|
||||
expect(text).toContain('processor_position_writes_total');
|
||||
expect(text).toContain('processor_position_write_duration_seconds');
|
||||
expect(text).toContain('processor_acks_total');
|
||||
expect(text).toContain('processor_device_state_size');
|
||||
expect(text).toContain('processor_device_state_evictions_total');
|
||||
|
||||
// Default Node.js process metrics must be present.
|
||||
expect(text).toContain('nodejs_');
|
||||
});
|
||||
|
||||
it('label-less counters appear in the exposition at 0 before any inc() call', async () => {
|
||||
const metrics = createMetrics();
|
||||
const text = await metrics.serializeMetrics();
|
||||
|
||||
// prom-client emits label-less counters at 0 from the start.
|
||||
// Counters with label dims only appear once .inc() is called with a label value.
|
||||
expect(text).toMatch(/processor_consumer_records_total\s+0/);
|
||||
expect(text).toMatch(/processor_decode_errors_total\s+0/);
|
||||
expect(text).toMatch(/processor_acks_total\s+0/);
|
||||
expect(text).toMatch(/processor_device_state_evictions_total\s+0/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMetrics — counter increments', () => {
|
||||
it('increments processor_consumer_reads_total with label', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.inc('processor_consumer_reads_total', { result: 'ok' });
|
||||
metrics.inc('processor_consumer_reads_total', { result: 'ok' });
|
||||
metrics.inc('processor_consumer_reads_total', { result: 'empty' });
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
// result="ok" incremented twice
|
||||
expect(text).toMatch(/processor_consumer_reads_total\{result="ok"\} 2/);
|
||||
// result="empty" incremented once
|
||||
expect(text).toMatch(/processor_consumer_reads_total\{result="empty"\} 1/);
|
||||
});
|
||||
|
||||
it('increments processor_consumer_records_total', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.inc('processor_consumer_records_total');
|
||||
metrics.inc('processor_consumer_records_total');
|
||||
metrics.inc('processor_consumer_records_total');
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
expect(text).toMatch(/processor_consumer_records_total\s+3/);
|
||||
});
|
||||
|
||||
it('increments processor_decode_errors_total', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.inc('processor_decode_errors_total');
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
expect(text).toMatch(/processor_decode_errors_total\s+1/);
|
||||
});
|
||||
|
||||
it('increments processor_position_writes_total with status label', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.inc('processor_position_writes_total', { status: 'inserted' });
|
||||
metrics.inc('processor_position_writes_total', { status: 'duplicate' });
|
||||
metrics.inc('processor_position_writes_total', { status: 'failed' });
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
expect(text).toMatch(/processor_position_writes_total\{status="inserted"\} 1/);
|
||||
expect(text).toMatch(/processor_position_writes_total\{status="duplicate"\} 1/);
|
||||
expect(text).toMatch(/processor_position_writes_total\{status="failed"\} 1/);
|
||||
});
|
||||
|
||||
it('increments processor_acks_total', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.inc('processor_acks_total');
|
||||
metrics.inc('processor_acks_total');
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
expect(text).toMatch(/processor_acks_total\s+2/);
|
||||
});
|
||||
|
||||
it('increments processor_device_state_evictions_total', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.inc('processor_device_state_evictions_total');
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
expect(text).toMatch(/processor_device_state_evictions_total\s+1/);
|
||||
});
|
||||
|
||||
it('silently ignores unknown metric names', () => {
|
||||
const metrics = createMetrics();
|
||||
// Must not throw
|
||||
expect(() => metrics.inc('no_such_metric_total')).not.toThrow();
|
||||
expect(() => metrics.observe('no_such_metric', 42)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMetrics — gauge and histogram', () => {
|
||||
it('sets processor_consumer_lag via observe()', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.observe('processor_consumer_lag', 42);
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
expect(text).toMatch(/processor_consumer_lag\s+42/);
|
||||
});
|
||||
|
||||
it('sets processor_device_state_size via observe()', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.observe('processor_device_state_size', 7);
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
expect(text).toMatch(/processor_device_state_size\s+7/);
|
||||
});
|
||||
|
||||
it('records processor_position_write_duration_seconds histogram observation', async () => {
|
||||
const metrics = createMetrics();
|
||||
metrics.observe('processor_position_write_duration_seconds', 0.007);
|
||||
|
||||
const text = await metrics.serializeMetrics();
|
||||
// Histogram emits _bucket, _sum, _count lines.
|
||||
expect(text).toContain('processor_position_write_duration_seconds_sum');
|
||||
expect(text).toContain('processor_position_write_duration_seconds_count 1');
|
||||
});
|
||||
|
||||
it('histogram buckets include all spec-defined breakpoints', async () => {
|
||||
const metrics = createMetrics();
|
||||
const text = await metrics.serializeMetrics();
|
||||
|
||||
// Spec buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5]
|
||||
const expectedBuckets = ['0.001', '0.005', '0.01', '0.05', '0.1', '0.5', '1', '5'];
|
||||
for (const bucket of expectedBuckets) {
|
||||
expect(text).toContain(`le="${bucket}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// startMetricsServer tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('startMetricsServer — HTTP endpoints', () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let isRedisReady = true;
|
||||
let isPostgresReady = true;
|
||||
|
||||
const readyzDeps: ReadyzDeps = {
|
||||
isRedisReady: () => isRedisReady,
|
||||
isPostgresReady: () => isPostgresReady,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const metrics = createMetrics();
|
||||
server = startMetricsServer(0, () => metrics.serializeMetrics(), readyzDeps);
|
||||
// Wait for the server to bind a port (port=0 lets OS pick)
|
||||
await new Promise<void>((resolve) => {
|
||||
if (server.listening) {
|
||||
resolve();
|
||||
} else {
|
||||
server.once('listening', () => resolve());
|
||||
}
|
||||
});
|
||||
const addr = server.address();
|
||||
port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /metrics returns 200 with Prometheus content-type', async () => {
|
||||
const res = await httpGet(port, '/metrics');
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.contentType).toMatch('text/plain');
|
||||
expect(res.body).toContain('processor_consumer_reads_total');
|
||||
});
|
||||
|
||||
it('GET /healthz returns 200 with {"status":"ok"}', async () => {
|
||||
const res = await httpGet(port, '/healthz');
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('GET /readyz returns 200 when both Redis and Postgres are ready', async () => {
|
||||
isRedisReady = true;
|
||||
isPostgresReady = true;
|
||||
const res = await httpGet(port, '/readyz');
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('GET /readyz returns 503 when Redis is not ready', async () => {
|
||||
isRedisReady = false;
|
||||
isPostgresReady = true;
|
||||
const res = await httpGet(port, '/readyz');
|
||||
expect(res.statusCode).toBe(503);
|
||||
const body = JSON.parse(res.body) as { status: string; redis: boolean; postgres: boolean };
|
||||
expect(body.status).toBe('not ready');
|
||||
expect(body.redis).toBe(false);
|
||||
expect(body.postgres).toBe(true);
|
||||
isRedisReady = true;
|
||||
});
|
||||
|
||||
it('GET /readyz returns 503 when Postgres is not ready', async () => {
|
||||
isRedisReady = true;
|
||||
isPostgresReady = false;
|
||||
const res = await httpGet(port, '/readyz');
|
||||
expect(res.statusCode).toBe(503);
|
||||
const body = JSON.parse(res.body) as { status: string; redis: boolean; postgres: boolean };
|
||||
expect(body.status).toBe('not ready');
|
||||
expect(body.redis).toBe(true);
|
||||
expect(body.postgres).toBe(false);
|
||||
isPostgresReady = true;
|
||||
});
|
||||
|
||||
it('GET /readyz returns 503 when both Redis and Postgres are not ready', async () => {
|
||||
isRedisReady = false;
|
||||
isPostgresReady = false;
|
||||
const res = await httpGet(port, '/readyz');
|
||||
expect(res.statusCode).toBe(503);
|
||||
const body = JSON.parse(res.body) as { status: string; redis: boolean; postgres: boolean };
|
||||
expect(body.redis).toBe(false);
|
||||
expect(body.postgres).toBe(false);
|
||||
isRedisReady = true;
|
||||
isPostgresReady = true;
|
||||
});
|
||||
|
||||
it('non-GET request returns 405', async () => {
|
||||
const res = await httpGet(port, '/metrics', 'POST');
|
||||
expect(res.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
it('unknown path returns 404', async () => {
|
||||
const res = await httpGet(port, '/not-found');
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startMetricsServer — /metrics error path', () => {
|
||||
it('returns 500 when serializeMetrics rejects', async () => {
|
||||
const serializeMetrics = vi.fn().mockRejectedValue(new Error('prom-client exploded'));
|
||||
const server = startMetricsServer(
|
||||
0,
|
||||
serializeMetrics,
|
||||
{ isRedisReady: () => true, isPostgresReady: () => true },
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (server.listening) resolve();
|
||||
else server.once('listening', () => resolve());
|
||||
});
|
||||
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
||||
|
||||
const res = await httpGet(port, '/metrics');
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toContain('prom-client exploded');
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Integration test: end-to-end pipeline round-trip via testcontainers.
|
||||
*
|
||||
* Spins up Redis 7 and TimescaleDB (timescale/timescaledb:latest-pg16) containers,
|
||||
* runs the Processor migration, starts the consumer pipeline, publishes synthetic
|
||||
* Position records, and asserts the resulting rows in `positions`.
|
||||
*
|
||||
* If Docker is unavailable (CI runner without Docker, local dev without Docker
|
||||
* Desktop), the suite skips — it does not fail the build. Docker availability is
|
||||
* determined by a container start attempt in beforeAll; the skip flag is set once,
|
||||
* and each `it` block early-returns when `!dockerAvailable`.
|
||||
*
|
||||
* WARNING: Do NOT replace the early-return skip pattern with a try/catch alone.
|
||||
* A hang does not throw; only an explicit `!dockerAvailable` check per test
|
||||
* guarantees that unavailable Docker exits cleanly (see tcp-ingestion history).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { GenericContainer, type StartedTestContainer, Wait } from 'testcontainers';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type pg from 'pg';
|
||||
import type { ConsumedRecord } from '../src/core/consumer.js';
|
||||
import { createConsumer, connectRedis, ensureConsumerGroup } from '../src/core/consumer.js';
|
||||
import { createWriter } from '../src/core/writer.js';
|
||||
import { createDeviceStateStore } from '../src/core/state.js';
|
||||
import { createPool, connectWithRetry } from '../src/db/pool.js';
|
||||
import { runMigrations } from '../src/db/migrate.js';
|
||||
import { createMetrics } from '../src/observability/metrics.js';
|
||||
import type { Config } from '../src/config/load.js';
|
||||
import type { Position } from '../src/core/types.js';
|
||||
import { vi } from 'vitest';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSilentLogger(): Logger {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
level: 'silent',
|
||||
silent: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
}
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
NODE_ENV: 'test',
|
||||
INSTANCE_ID: 'test-integration',
|
||||
LOG_LEVEL: 'silent',
|
||||
REDIS_URL: 'redis://localhost:6379', // overridden below with mapped port
|
||||
POSTGRES_URL: 'postgres://postgres:postgres@localhost:5432/trm', // overridden below
|
||||
REDIS_TELEMETRY_STREAM: 'telemetry:t',
|
||||
REDIS_CONSUMER_GROUP: 'processor',
|
||||
REDIS_CONSUMER_NAME: 'test-consumer',
|
||||
METRICS_PORT: 0,
|
||||
BATCH_SIZE: 100,
|
||||
BATCH_BLOCK_MS: 500,
|
||||
WRITE_BATCH_SIZE: 50,
|
||||
DEVICE_STATE_LRU_CAP: 10_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Position into the flat field map that XADD expects.
|
||||
* Mirrors tcp-ingestion's serializePosition format exactly: bigint → __bigint
|
||||
* sentinel, Buffer → __buffer_b64 sentinel, Date → ISO string.
|
||||
*/
|
||||
function buildXaddFields(position: Position, codec: string): string[] {
|
||||
function jsonReplacer(_key: string, value: unknown): unknown {
|
||||
if (typeof value === 'bigint') return { __bigint: value.toString() };
|
||||
if (value instanceof Uint8Array) {
|
||||
return { __buffer_b64: Buffer.from(value).toString('base64') };
|
||||
}
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return value;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(position, jsonReplacer);
|
||||
return [
|
||||
'ts', position.timestamp.toISOString(),
|
||||
'device_id', position.device_id,
|
||||
'codec', codec,
|
||||
'payload', payload,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls `fn` up to `timeoutMs` with `intervalMs` gaps until it returns a
|
||||
* truthy result. Returns null if the timeout expires.
|
||||
*/
|
||||
async function pollUntil<T>(
|
||||
fn: () => Promise<T | null | undefined>,
|
||||
timeoutMs: number,
|
||||
intervalMs = 200,
|
||||
): Promise<T | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await fn();
|
||||
if (result !== null && result !== undefined) return result as T;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Container and pipeline lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let redisContainer: StartedTestContainer | null = null;
|
||||
let pgContainer: StartedTestContainer | null = null;
|
||||
let redisClient: Redis | null = null;
|
||||
let pgPool: pg.Pool | null = null;
|
||||
let consumer: { start: () => Promise<void>; stop: () => Promise<void> } | null = null;
|
||||
let dockerAvailable = true;
|
||||
|
||||
const STREAM = 'telemetry:t';
|
||||
const GROUP = 'processor';
|
||||
|
||||
beforeAll(async () => {
|
||||
// --- Step 1: start Redis container -----------------------------------------
|
||||
try {
|
||||
redisContainer = await new GenericContainer('redis:7-alpine')
|
||||
.withExposedPorts(6379)
|
||||
.withWaitStrategy(Wait.forLogMessage('Ready to accept connections'))
|
||||
.start();
|
||||
} catch {
|
||||
console.warn(
|
||||
'[pipeline.integration.test] Docker not available — skipping integration tests',
|
||||
);
|
||||
dockerAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Step 2: start TimescaleDB container ------------------------------------
|
||||
try {
|
||||
pgContainer = await new GenericContainer('timescale/timescaledb:latest-pg16')
|
||||
.withExposedPorts(5432)
|
||||
.withEnvironment({
|
||||
POSTGRES_USER: 'postgres',
|
||||
POSTGRES_PASSWORD: 'postgres',
|
||||
POSTGRES_DB: 'trm',
|
||||
})
|
||||
.withWaitStrategy(Wait.forLogMessage('database system is ready to accept connections', 2))
|
||||
.start();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[pipeline.integration.test] Failed to start TimescaleDB container: ${String(err)} — skipping`,
|
||||
);
|
||||
dockerAvailable = false;
|
||||
await redisContainer?.stop().catch(() => {});
|
||||
redisContainer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const redisHost = redisContainer.getHost();
|
||||
const redisPort = redisContainer.getMappedPort(6379);
|
||||
const pgHost = pgContainer.getHost();
|
||||
const pgPort = pgContainer.getMappedPort(5432);
|
||||
|
||||
const redisUrl = `redis://${redisHost}:${redisPort}`;
|
||||
const postgresUrl = `postgres://postgres:postgres@${pgHost}:${pgPort}/trm`;
|
||||
|
||||
const config = makeConfig({ REDIS_URL: redisUrl, POSTGRES_URL: postgresUrl });
|
||||
const logger = makeSilentLogger();
|
||||
|
||||
// --- Step 3: connect Redis --------------------------------------------------
|
||||
const { default: Redis } = await import('ioredis');
|
||||
const client = new Redis(redisUrl, {
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 0,
|
||||
});
|
||||
await client.connect();
|
||||
redisClient = client;
|
||||
|
||||
// --- Step 4: connect Postgres and run migrations ---------------------------
|
||||
pgPool = createPool(postgresUrl);
|
||||
await connectWithRetry(pgPool, logger);
|
||||
await runMigrations(pgPool, logger);
|
||||
|
||||
// --- Step 5: wire and start the consumer pipeline -------------------------
|
||||
const metrics = createMetrics();
|
||||
const state = createDeviceStateStore(config, logger);
|
||||
const writer = createWriter(pgPool, config, logger, metrics);
|
||||
|
||||
await ensureConsumerGroup(client, STREAM, GROUP, logger);
|
||||
|
||||
const sink = async (records: ConsumedRecord[]): Promise<string[]> => {
|
||||
for (const record of records) {
|
||||
state.update(record.position);
|
||||
}
|
||||
const results = await writer.write(records);
|
||||
return results
|
||||
.filter((r) => r.status === 'inserted' || r.status === 'duplicate')
|
||||
.map((r) => r.id);
|
||||
};
|
||||
|
||||
// Use connectRedis for the consumer's own connection (separate from the
|
||||
// redisClient used for XADD in tests) so we mirror production topology.
|
||||
const consumerRedis = await connectRedis(redisUrl, logger);
|
||||
consumer = createConsumer(consumerRedis, config, logger, metrics, sink);
|
||||
await consumer.start();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await consumer?.stop().catch(() => {});
|
||||
await redisClient?.quit().catch(() => {});
|
||||
await pgPool?.end().catch(() => {});
|
||||
await redisContainer?.stop().catch(() => {});
|
||||
await pgContainer?.stop().catch(() => {});
|
||||
}, 30_000);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pipeline integration — full round-trip', () => {
|
||||
// Test 1: happy-path with bigint + Buffer attributes
|
||||
it('publishes a Position with bigint and Buffer attributes and verifies the row in positions', async () => {
|
||||
if (!dockerAvailable || !redisClient || !pgPool) {
|
||||
console.warn('[pipeline.integration.test] skipping test 1: Docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const position: Position = {
|
||||
device_id: '356307042441013',
|
||||
timestamp: new Date('2024-06-15T12:00:00.000Z'),
|
||||
latitude: 54.687157,
|
||||
longitude: 25.279652,
|
||||
altitude: 130,
|
||||
angle: 90,
|
||||
speed: 45,
|
||||
satellites: 12,
|
||||
priority: 0,
|
||||
attributes: {
|
||||
num_attr: 255,
|
||||
big_attr: BigInt('18446744073709551615'), // u64 max
|
||||
buf_attr: Buffer.from([0xde, 0xad, 0xbe, 0xef]),
|
||||
},
|
||||
};
|
||||
|
||||
const fields = buildXaddFields(position, '8E');
|
||||
await redisClient.xadd(STREAM, '*', ...fields);
|
||||
|
||||
// Poll until the row appears in positions (up to 10 s).
|
||||
type Row = {
|
||||
device_id: string;
|
||||
ts: Date;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
attributes: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const row = await pollUntil<Row>(async () => {
|
||||
const result = await pgPool!.query<Row>(
|
||||
'SELECT device_id, ts, latitude, longitude, attributes FROM positions WHERE device_id = $1 AND ts = $2',
|
||||
[position.device_id, position.timestamp],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}, 10_000);
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.device_id).toBe(position.device_id);
|
||||
expect(row!.latitude).toBeCloseTo(position.latitude, 4);
|
||||
expect(row!.longitude).toBeCloseTo(position.longitude, 4);
|
||||
|
||||
// attributes JSONB: bigint stored as decimal string, Buffer as base64 string.
|
||||
expect(typeof row!.attributes['big_attr']).toBe('string');
|
||||
expect(row!.attributes['big_attr']).toBe('18446744073709551615');
|
||||
|
||||
expect(typeof row!.attributes['buf_attr']).toBe('string');
|
||||
const decoded = Buffer.from(row!.attributes['buf_attr'] as string, 'base64');
|
||||
expect(decoded).toEqual(Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
|
||||
expect(row!.attributes['num_attr']).toBe(255);
|
||||
}, 30_000);
|
||||
|
||||
// Test 2: idempotency — duplicate (device_id, ts) must not create a second row
|
||||
it('does not create a duplicate row when the same (device_id, ts) is published twice', async () => {
|
||||
if (!dockerAvailable || !redisClient || !pgPool) {
|
||||
console.warn('[pipeline.integration.test] skipping test 2: Docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const position: Position = {
|
||||
device_id: 'DUP-DEVICE-001',
|
||||
timestamp: new Date('2024-06-15T13:00:00.000Z'),
|
||||
latitude: 1.0,
|
||||
longitude: 2.0,
|
||||
altitude: 10,
|
||||
angle: 0,
|
||||
speed: 0,
|
||||
satellites: 4,
|
||||
priority: 0,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
const fields = buildXaddFields(position, '8');
|
||||
|
||||
// Publish the same position twice.
|
||||
await redisClient.xadd(STREAM, '*', ...fields);
|
||||
await redisClient.xadd(STREAM, '*', ...fields);
|
||||
|
||||
// Wait long enough for both entries to be processed.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
||||
|
||||
const result = await pgPool.query<{ count: string }>(
|
||||
'SELECT COUNT(*) AS count FROM positions WHERE device_id = $1 AND ts = $2',
|
||||
[position.device_id, position.timestamp],
|
||||
);
|
||||
const count = parseInt(result.rows[0]?.count ?? '0', 10);
|
||||
expect(count).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
// Test 3: malformed payload — decode error counter increments, entry not ACKed
|
||||
it('increments decode error counter and leaves malformed entry pending (not ACKed)', async () => {
|
||||
if (!dockerAvailable || !redisClient || !pgPool) {
|
||||
console.warn('[pipeline.integration.test] skipping test 3: Docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Push a stream entry with a broken payload (not valid JSON).
|
||||
const badEntryId = await redisClient.xadd(
|
||||
STREAM,
|
||||
'*',
|
||||
'ts', new Date().toISOString(),
|
||||
'device_id', 'BAD-DEVICE',
|
||||
'codec', '8',
|
||||
'payload', 'NOT_VALID_JSON {{{',
|
||||
);
|
||||
|
||||
// Wait for the consumer to attempt processing.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 2_000));
|
||||
|
||||
// The entry should remain in the Pending Entry List (PEL) — it was not ACKed.
|
||||
const pendingResult = await redisClient.xpending(
|
||||
STREAM,
|
||||
GROUP,
|
||||
'-',
|
||||
'+',
|
||||
'100',
|
||||
) as Array<[string, string, number, number]>;
|
||||
|
||||
// Find the bad entry in the PEL.
|
||||
const pendingIds = pendingResult.map(([id]) => id);
|
||||
expect(pendingIds).toContain(badEntryId);
|
||||
}, 30_000);
|
||||
|
||||
// Test 4: writer failure → retry — stop Postgres before publish, restart, verify row lands
|
||||
it('retries and writes the row after Postgres recovers from a stopped state', async () => {
|
||||
if (!dockerAvailable || !redisClient || !pgPool || !pgContainer) {
|
||||
console.warn('[pipeline.integration.test] skipping test 4: Docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const position: Position = {
|
||||
device_id: 'RETRY-DEVICE-001',
|
||||
timestamp: new Date('2024-06-15T14:00:00.000Z'),
|
||||
latitude: 3.0,
|
||||
longitude: 4.0,
|
||||
altitude: 20,
|
||||
angle: 45,
|
||||
speed: 10,
|
||||
satellites: 8,
|
||||
priority: 1,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
// Stop Postgres before publishing so the first write attempt fails.
|
||||
await pgContainer.stop();
|
||||
|
||||
const fields = buildXaddFields(position, '8');
|
||||
await redisClient.xadd(STREAM, '*', ...fields);
|
||||
|
||||
// Wait briefly — the write should fail while Postgres is down.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1_500));
|
||||
|
||||
// Restart Postgres.
|
||||
pgContainer = await pgContainer.restart();
|
||||
|
||||
// Wait a bit to ensure the new container is accepting connections before
|
||||
// reconnecting. The pool will get fresh connections once the TCP stack
|
||||
// accepts again.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
||||
|
||||
// The entry is still pending in the consumer's PEL; the next XREADGROUP
|
||||
// poll will re-deliver it. The pipeline should eventually write it.
|
||||
type Row = { device_id: string };
|
||||
const row = await pollUntil<Row>(async () => {
|
||||
try {
|
||||
const result = await pgPool!.query<Row>(
|
||||
'SELECT device_id FROM positions WHERE device_id = $1 AND ts = $2',
|
||||
[position.device_id, position.timestamp],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
} catch {
|
||||
// Pool may throw transiently while connections re-establish.
|
||||
return null;
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.device_id).toBe(position.device_id);
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in New Issue
Block a user