openvibers/openvibe.sdk Commits

openvibe-sdk 0.82.0: the outbox's pending() and rejected() keep their count (#114)

OP OpenVibers committed 11 days ago · 8be2ecdce7 · 9 files, +70 −12

The metric gauges and readiness ask on every scrape and probe, and
rejected() has no index, so a large outbox was read whole each time (115 ms
on Deals, 45 ms on Search; among the host's top queries by total time).
Callers at the same moment now share one count, kept 30 s in production
(countsCacheMs; off elsewhere).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fb7da07..b070116 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,13 @@
# Changelog
++## 0.82.0 (2026-11-02)
++
++- **`openvibe-sdk/events` outbox: `pending()` and `rejected()` keep their count** (`countsCacheMs`, 30 s when
++ `NODE_ENV` is production, off elsewhere), and callers at the same moment share one count. The metric gauges and
++ readiness ask on every scrape and probe, and `rejected()` has no index, so a large outbox was read whole each time:
++ 115 ms on Deals, 56 ms on Trade, 49 ms on Sources, 45 ms on Search and 24 ms on Reviews (pg_stat_statements,
++ 2026-11-02). Pass `countsCacheMs: 0` to count on every call.
++
## 0.81.0 (2026-11-02)
- **`openvibe-sdk/vision`: familiar faces (`face.familiar`), F1 of the L24 plan.** The camera owner's own Vision
ddiff --git a/README.md b/README.md
iindex 31ec99c..65d6596 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.81.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.82.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.81.0 is tagged:
++After v0.82.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
ddiff --git a/STATUS.json b/STATUS.json
iindex 327e820..913ff20 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.81.0",
++ "version": "0.82.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex e842796..2889efc 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.81.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.82.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -47,7 +47,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.81.0';
++const SDK_VERSION = '0.82.0';
module.exports = {
createClient,
ddiff --git a/package-lock.json b/package-lock.json
iindex 4b6b0a3..3a18d6b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.81.0",
++ "version": "0.82.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.81.0",
++ "version": "0.82.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 3485b8c..f76fb26 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.81.0",
++ "version": "0.82.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex cb94fc6..8328ad1 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.81.0';
++const SDK_VERSION = '0.82.0';
module.exports = {
createClient,
ddiff --git a/src/outbox.js b/src/outbox.js
iindex 61573c7..2903cd5 100644
---- a/src/outbox.js
++++ b/src/outbox.js
@@@ -96,9 +96,29 @@ function inboxSchema(table = 'idempotency_receipts') {
* relay one table without double-sending; a relay that dies mid-batch leaves its rows to the next after the
* lease. Delivery stays at least once (Events answers a repeated event_id as a duplicate).
*/
++/**
++ * A count kept for keepMs, shared by callers at the same moment. pending() and rejected() count the outbox table
++ * (rejected rows have no index, so a large outbox is read whole: 115 ms on Deals, 45 ms on Search, 2026-11-02),
++ * and the metric gauges and readiness ask on every scrape and probe.
++ */
++function keptCount(read, keepMs, now) {
++ let kept = null;
++ return () => {
++ if (!(keepMs > 0)) return read();
++ const t = now();
++ if (kept && (kept.pending || t - kept.at < keepMs)) return kept.pending || Promise.resolve(kept.value);
++ const pending = Promise.resolve().then(read);
++ kept = { at: t, pending };
++ return pending.then((value) => { if (kept && kept.pending === pending) kept = { at: t, value }; return value; },
++ (err) => { if (kept && kept.pending === pending) kept = null; throw err; });
++ };
++}
++
function createPgOutbox(db, {
events, table = 'event_outbox', batchSize = 50, intervalMs = 1000, leaseMs = 60000,
backoffMs = [1000, 5000, 30000, 120000, 600000], now = () => Date.now(), onError = null,
++ // How long pending() and rejected() keep their count: 30 s in production (NODE_ENV), every call counts elsewhere.
++ countsCacheMs = process.env.NODE_ENV === 'production' ? 30000 : 0,
} = {}) {
if (!db || typeof db.query !== 'function' || typeof db.tx !== 'function') throw new TypeError('an openvibe-sdk/db handle is required');
if (!events || typeof events.publish !== 'function' || typeof events.prepare !== 'function') {
@@@ -201,8 +221,8 @@ function createPgOutbox(db, {
start() { if (!running) { running = true; schedule(0); } },
stop() { running = false; clearTimeout(timer); timer = null; return flush.idle(); },
kick() { schedule(0); },
-- pending: () => db.value(`SELECT count(*) FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL`),
-- rejected: () => db.value(`SELECT count(*) FROM ${table} WHERE rejected_at IS NOT NULL`),
++ pending: keptCount(() => db.value(`SELECT count(*) FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL`), countsCacheMs, now),
++ rejected: keptCount(() => db.value(`SELECT count(*) FROM ${table} WHERE rejected_at IS NOT NULL`), countsCacheMs, now),
prune: (olderThanMs = 7 * 24 * 60 * 60 * 1000) => db.exec(`DELETE FROM ${table} WHERE sent_at IS NOT NULL AND sent_at < $1`, [now() - olderThanMs]),
};
}
ddiff --git a/test/pg-outbox.test.js b/test/pg-outbox.test.js
iindex 5a851e7..518e16f 100644
---- a/test/pg-outbox.test.js
++++ b/test/pg-outbox.test.js
@@@ -26,6 +26,36 @@ function eventsClient() {
function cases(label, open) {
return [
++ [`${label}: pending() and rejected() keep their count for countsCacheMs, shared by callers at the same moment`, async () => {
++ const { db, done } = await open();
++ try {
++ const { events } = eventsClient();
++ let counts = 0;
++ const watched = new Proxy(db, {
++ get(target, key) {
++ if (key === 'value') return (...args) => { counts += 1; return target.value(...args); };
++ const v = target[key];
++ return typeof v === 'function' ? v.bind(target) : v;
++ },
++ });
++ let t = 1000;
++ const kept = createPgOutbox(watched, { events, now: () => t, countsCacheMs: 30000 });
++ const [a, b] = await Promise.all([kept.pending(), kept.pending()]);
++ assert.equal(counts, 1, 'two callers at once: one count');
++ assert.equal(a, b);
++ await db.tx((tx) => kept.enqueue(tx, { event_type: 'live.stream.started', actor, subject }));
++ assert.equal(await kept.pending(), 0, 'within the window: the kept count');
++ assert.equal(counts, 1);
++ t += 30000;
++ assert.equal(await kept.pending(), 1, 'past it: counted again');
++ assert.equal(await kept.rejected(), 0);
++ assert.equal(counts, 3, 'rejected() keeps its own count');
++ const every = createPgOutbox(watched, { events });
++ await every.pending();
++ await every.pending();
++ assert.equal(counts, 5, 'without countsCacheMs (and outside production) every call counts');
++ } finally { await done(); }
++ }],
[`${label}: a kick() from inside a transaction never hands the relay that transaction`, async () => {
const { db, done } = await open();
try {