openvibers/openvibe.billing Commits

Move Billing's shutdown onto openvibe-sdk/service gracefulStop (#15)

OP OpenVibers committed 5 Oct 2026 · cd637bcee4 · 7 files, +161 −16

* Move Billing's shutdown onto openvibe-sdk/service gracefulStop

**Files changed**

- `package.json:31` — bumped the `openvibe-sdk` pin from v0.21.2 to v0.28.0 (gracefulStop lives in `openvibe-sdk/service` from 0.26.0).
- `package-lock.json:17,1078-1080` — updated the root dependency spec plus the `node_modules/openvibe-sdk` entry (version/resolved/integrity) to v0.28.0, copied from a repo already at that version (the OpenVibe.Tips deps cache; `npm ci` accepted it, so the integrity is valid). `openvibe-contracts` v0.85.0 and `openvibe-shared` v2.5.0 left untouched.
- `server/index.js:23,66-84` — replaced the bespoke `shutdown()` and the two `process.on` handlers with `const { gracefulStop } = require('openvibe-sdk/service')` and one `gracefulStop({ name:'Billing', server, deadlineMs:5000, deadlineExitCode:0, stop:[clear timers, relay.stop(), keys.stop()], close:[app.locals.valkey.close(), ctx.db.close()] })`, matching `manifests/services/billing.json` lifecycle.shutdown (deadlineSeconds 5). `server.keepAliveTimeout = 65_000` kept.
- `README.md:66` — `openvibe-sdk` v0.28.0 in the "Depends on" line (the existing `dependency-pins.test.js` requires it to track the pin) and noted `openvibe-sdk/service`.
- `STATUS.json:30` — added the usage-rating feature line (rating shipped in PR #8: `server/ops/rating.js`, `migrations/0004_rating.sql`, the README's "Rates" section); `updated` was already 2026-10-04 and `contracts` stays v0.85.0.
- `test/service-kit.test.js` (new, 133 lines) — mirrors `OpenVibe.Sources/test/service-kit.test.js`: static checks (imports `gracefulStop`, no `process.on('SIGTERM'|'SIGINT')`, exactly one call naming Billing with deadlineMs 5000 / deadlineExitCode 0, keep-alive 65 s, stop/close step order) and behavioural checks against a real `http.Server` with an injected exit (in-flight request drains with `Connection: close`, steps close in order, exit called once with 0, a second stop is a no-op).

**Tests run**

- `ov test service-kit` → `dependencies: openvibe-sdk 0.21.2 → 0.28.0: refreshing` (harness bootstrapped its shared dep cache), `1 passed in 3 s`.
- `ov test --all` → `28 passed in 84 s` (includes `dependency-pins`, `capabilities`, `rating`, `hygiene`).

**Notes / not done**

- No CHANGELOG exists in this repo, so nothing to update there.
- I didn't commit/push/install manually; `ov test` refreshed `node_modules` through its own lockfile-keyed dep cache as part of running the tests. `?? node_modules` is the pre-existing untracked symlink, not a new artifact.
- `server/index.js` still calls `gracefulStop` inside the async IIFE (no exported `main`/shutdown handle), exactly as the brief specified; the behavioural half of the test therefore drives the kit with the same options rather than the live instance, as the Sources reference does.

* Stop tracking the node_modules link and ignore it as a link too

Changes

ddiff --git a/.gitignore b/.gitignore
iindex 0f84c2e..bc0cd87 100644
---- a/.gitignore
++++ b/.gitignore
@@@ -10,3 +10,4 @@ dist/
*.log
*.pem
*.key
++node_modules
ddiff --git a/README.md b/README.md
iindex 56e3a1e..19aab7b 100644
---- a/README.md
++++ b/README.md
@@@ -63,7 +63,7 @@ promo write changes both in one transaction.
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Events (the outbox relay, only when `EVENTS_URL` is set)
- the payment providers whose secrets are configured (none in production today)
--- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v2.5.0, pinned by release tarball
++- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.28.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`), `openvibe-shared` v2.5.0, pinned by release tarball
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
ddiff --git a/STATUS.json b/STATUS.json
iindex 6a52c0f..8a5dc2c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -27,6 +27,7 @@
"outbox events billing.*",
"per-person limits (openvibe-sdk/limits)",
"usage readings (billing.usage.record)",
++ "usage rating: a rate card -> the subject's promo allowance -> one usage transaction per reading, under hard budgets (BILLING_RATING_INTERVAL_MS off by default; nothing charged without a loaded card, key usage:<reading key>)",
"promo allowance ledger"
]
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 656a8d3..5e3a477 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0"
},
@@@ -1075,9 +1075,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.21.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
-- "integrity": "sha512-k04b9r2FCYVIhb8KLNrr5y43eb1l3UKzYNKl9m/vqMRAjZGh4p3pI2I/8UFU9bosKD8KAS121OO4rH8NUtx4sg==",
++ "version": "0.28.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
++ "integrity": "sha512-jjLisFDWBN+xDVsixa5BJRr3355kRijmvOh1/9rz4s1yVb3wZWCbpTLNcOvXm57ksnRoqK57393W38G0Ey3PFw==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex c00c1ac..df53797 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0"
},
ddiff --git a/server/index.js b/server/index.js
iindex b2e5ee2..47384fc 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -20,6 +20,7 @@ const rating = require('./ops/rating');
const { createRelay } = require('./outbox');
const { isFrozen } = require('./ops/common');
const { runScheduled } = require('./reconcile');
++const { gracefulStop } = require('openvibe-sdk/service');
(async () => {
const config = loadConfig();
@@@ -62,16 +63,25 @@ const { runScheduled } = require('./reconcile');
});
server.keepAliveTimeout = 65_000;
-- function shutdown(signal) {
-- console.log(`[Billing] ${signal} — closing`);
-- timers.forEach((t) => { clearInterval(t); clearTimeout(t); });
-- relay.stop();
-- keys.stop();
-- server.close(() => { if (app.locals.valkey) app.locals.valkey.close().catch(() => {}); ctx.db.close().catch(() => {}).finally(() => process.exit(0)); });
-- setTimeout(() => process.exit(0), 5000).unref();
-- }
-- process.on('SIGTERM', () => shutdown('SIGTERM'));
-- process.on('SIGINT', () => shutdown('SIGINT'));
++ // SIGTERM/SIGINT (openvibe-sdk/service): the kit installs the handlers, stops the job timers, the outbox
++ // relay and the Network key refresh, drains the HTTP server (requests in flight get Connection: close),
++ // then closes Valkey and the database. Past 5 s the process exits 0 (deadlineExitCode), matching the
++ // manifest's lifecycle.shutdown (deadlineSeconds 5); a second signal is a no-op.
++ gracefulStop({
++ name: 'Billing',
++ server,
++ deadlineMs: 5000,
++ deadlineExitCode: 0,
++ stop: [
++ () => { for (const t of timers) { clearInterval(t); clearTimeout(t); } },
++ () => relay.stop(),
++ () => keys.stop(),
++ ],
++ close: [
++ () => app.locals.valkey && app.locals.valkey.close().catch(() => {}),
++ () => ctx.db.close(),
++ ],
++ });
})().catch((err) => {
console.error('[Billing] failed to start:', err);
process.exit(1);
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..1f146d6
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * openvibe-sdk/service in Billing (plan T1): the SIGTERM/SIGINT shutdown is the kit's gracefulStop, not a
++ * hand-written handler. The job timers, the outbox relay and the Network key refresh stop; the HTTP server
++ * drains (requests in flight get Connection: close); then Valkey and the database close, and the process
++ * exits 0 — past 5 s too, matching manifests/services/billing.json lifecycle.shutdown (deadlineSeconds 5).
++ * The static half reads server/index.js; the behavioural half drives gracefulStop with the same options
++ * Billing passes, exits stubbed.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const { gracefulStop } = require('openvibe-sdk/service');
++const { check, done } = require('./helpers/app');
++
++const source = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
++const quiet = { log() {}, warn() {}, error() {} };
++
++const callAt = source.indexOf('gracefulStop({');
++const call = callAt === -1 ? '' : source.slice(callAt, callAt + 1000);
++
++/** GET with node:http (the Connection header and the socket are visible). */
++function request(url) {
++ return new Promise((resolve, reject) => {
++ const req = http.get(url, { agent: false }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString() }));
++ res.on('error', reject);
++ });
++ req.on('error', reject);
++ });
++}
++
++async function waitFor(fn, ms = 2000) {
++ const t0 = Date.now();
++ while (!fn()) {
++ if (Date.now() - t0 > ms) throw new Error('timed out waiting');
++ await new Promise((r) => setTimeout(r, 5));
++ }
++}
++
++(async () => {
++ console.log('service-kit');
++
++ await check('server/index.js imports gracefulStop from openvibe-sdk/service and owns no signal handler', () => {
++ assert.match(source, /const \{ gracefulStop \} = require\('openvibe-sdk\/service'\)/);
++ assert.doesNotMatch(source, /process\.on\('SIGTERM'/, 'server/index.js still installs its own SIGTERM handler');
++ assert.doesNotMatch(source, /process\.on\('SIGINT'/, 'server/index.js still installs its own SIGINT handler');
++ assert.doesNotMatch(source, /process\.exit\(0\), 5000\)/, 'the old 5 s exit-0 timer is gone');
++ });
++
++ await check('the one stop names Billing and keeps the 5 s deadline, exit 0 and the 65 s keep-alive', () => {
++ assert.notStrictEqual(callAt, -1, 'gracefulStop is not called in the entry point');
++ assert.strictEqual(source.indexOf('gracefulStop({', callAt + 1), -1, 'more than one gracefulStop call');
++ assert.match(call, /name: 'Billing'/);
++ assert.match(call, /deadlineMs: 5000/);
++ assert.match(call, /deadlineExitCode: 0/);
++ assert.match(source, /server\.keepAliveTimeout = 65_000/);
++ });
++
++ await check('the stop steps stop the timers, the relay then the key refresh; the close steps close Valkey then the database', () => {
++ const stop = call.slice(call.indexOf('stop: ['), call.indexOf('close: ['));
++ const close = call.slice(call.indexOf('close: ['));
++ const a = stop.indexOf('timers');
++ const b = stop.indexOf('relay.stop()');
++ const c = stop.indexOf('keys.stop()');
++ assert.ok(a !== -1 && b !== -1 && c !== -1 && a < b && b < c, 'stop steps must be the timers, relay.stop(), keys.stop()');
++ const v = close.indexOf('valkey');
++ const d = close.indexOf('ctx.db.close()');
++ assert.ok(v !== -1 && d !== -1 && v < d, 'close steps must be Valkey, then the database');
++ });
++
++ await check('the kit drains the server, closes in order, exits once, and a second stop is a no-op', async () => {
++ const order = [];
++ let release;
++ const gate = new Promise((r) => { release = r; });
++ let slowArrived = false;
++ const server = http.createServer((req, res) => {
++ if (req.url === '/fast') return res.end('fast');
++ slowArrived = true;
++ res.on('finish', () => order.push('slow finished'));
++ return gate.then(() => res.end('done'));
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ server.keepAliveTimeout = 65000;
++ const url = `http://127.0.0.1:${server.address().port}`;
++
++ const exits = [];
++ const kit = gracefulStop({
++ name: 'Billing', server, deadlineMs: 5000, deadlineExitCode: 0, signals: false, log: quiet, exit: (c) => exits.push(c),
++ stop: [
++ () => order.push('timers'),
++ () => order.push('relay.stop'),
++ () => order.push('keys.stop'),
++ ],
++ close: [
++ () => order.push('valkey.close'),
++ () => order.push('db.close'),
++ ],
++ });
++ try {
++ const inflight = request(`${url}/slow`);
++ await waitFor(() => slowArrived);
++
++ assert.strictEqual(kit.stopping(), false);
++ const stopped = kit.stop('SIGTERM');
++ assert.strictEqual(kit.stopping(), true, 'stopping() turns true at once');
++ await waitFor(() => order.includes('keys.stop'));
++ assert.ok(!order.includes('db.close'), 'close steps wait for the drain');
++
++ release();
++ const r = await inflight;
++ assert.strictEqual(r.text, 'done');
++ assert.strictEqual(r.headers.connection, 'close', 'a request in flight is answered with Connection: close');
++ assert.strictEqual(await stopped, 0, 'the stop resolves with exit code 0');
++ assert.deepStrictEqual(order, ['timers', 'relay.stop', 'keys.stop', 'slow finished', 'valkey.close', 'db.close']);
++ assert.deepStrictEqual(exits, [0], 'exit is called once, with 0');
++ assert.strictEqual(server.listening, false, 'the HTTP server stops listening');
++
++ const again = await kit.stop('SIGINT');
++ assert.strictEqual(again, 0, 'a second stop resolves with the same code');
++ assert.deepStrictEqual(order, ['timers', 'relay.stop', 'keys.stop', 'slow finished', 'valkey.close', 'db.close'], 'a second stop runs no step again');
++ assert.deepStrictEqual(exits, [0], 'exit stays called once');
++ } finally {
++ server.closeAllConnections();
++ server.close();
++ }
++ });
++
++ done();
++})();