openvibers/openvibe.media Commits
Storage-tier policy as revisioned configuration (WS-C task 7): media.storage_tier on openvibe-shared/config 1.14.0: revision 1 imported from media_settings, PUT /tiers/settings is one validated revision (types, 1-100 percentages, interval floors, threshold order; refused changes change nothing, 422), the admin config routes (list, history, rollback), media_settings mirrors the active revision for older releases, the sweep restarts with a new policy; pins shared 1.14.0, contracts 0.52.0
Changes
@@@ -756,3 +756,12 @@ Not run against production yet: it needs the owner's go-ahead.operation moved onto the job system).- The segment-native timeline.- App assets (emotes and sounds, the `assets` table) are not projected yet. The `asset` kind exists for v2 uploads.++++## Storage-tier policy as configuration++++The tiering thresholds (`DEFAULTS` in `server/vod/vod-storage.js`) are the namespace `media.storage_tier` of `openvibe-shared/config` (`server/vod/tier-config.js`, WS-C task 7).++- **Revisions:** every change is a revision, recording who made it and why. `PUT /api/v1/:app/admin/storage/tiers/settings` merges its keys into one. The whole policy is validated first: types, percentages 1–100, interval floors, and the order the thresholds need (`localLowWaterPct` < `hotDiskPressurePct` ≤ `criticalDiskPct`, `minFreeGb` ≤ `targetFreeGb`). A refused change changes nothing and answers 422 `config.invalid` with the rules broken.++- **Admin routes:** `…/admin/storage/config` lists the namespace, `…/config/media.storage_tier/history` shows the history, and `…/config/media.storage_tier/rollback` rolls back as a new revision.++- **Revision 1** is what `media_settings` held.++- **Mirror:** every activation writes the active revision's explicit keys through to `media_settings` (`storage_tier.*`) and removes the rest, so a release that reads those rows sees the same policy. The rows go in the ADR-028 contract step.++- **Sweep:** a running sweep restarts with the new policy.@@@ -16,9 +16,9 @@"dotenv": "^16.4.5","express": "^4.19.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0","sharp": "^0.33.4"}},@@@ -1919,9 +1919,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.52.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",++ "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1944,9 +1944,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.13.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",-- "integrity": "sha512-aodJtwID5ZVMc8usa0hxf2YZoG8AivhI65mrk0sv6i3EHYCV9vTJCtaALpxvgsZnTzYtSg08CtzQh8GfXmcmtg==",++ "version": "1.14.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",++ "integrity": "sha512-pID6sc4YQVyI0AeSmQGms3KGWxBxvES8dOXdkYmitIieINwSjeBbFcmzBTwhWoob2i02Pt/FT3rBBrykajKTLA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -11,7 +11,7 @@},"license": "MIT","dependencies": {-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","@aws-sdk/client-s3": "^3.600.0","@aws-sdk/lib-storage": "^3.600.0",@@@ -20,7 +20,7 @@"dotenv": "^16.4.5","express": "^4.19.2","multer": "^1.4.5-lts.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0","sharp": "^0.33.4"}}@@@ -315,31 +315,42 @@ router.get('/tiers', (req, res) => {});// ── PUT /tiers/settings — update tier settings ───────────────--router.put('/tiers/settings', (req, res) => {++// One revision of the media.storage_tier configuration (server/vod/tier-config.js): validated as a whole++// (422 with the rules broken), recorded with the app that asked and the reason; the sweep restarts with it.++router.put('/tiers/settings', async (req, res) => {try {-- const allowed = Object.keys(vodStorage.DEFAULTS);const updates = {};-- for (const key of allowed) {-- if (req.body?.[key] !== undefined) {-- let val = req.body[key];-- // Coerce types to match defaults-- if (typeof vodStorage.DEFAULTS[key] === 'number') val = Number(val);-- if (typeof vodStorage.DEFAULTS[key] === 'boolean') val = !!val;-- vodStorage.setSetting(key, val);-- updates[key] = val;-- }++ for (const key of Object.keys(vodStorage.DEFAULTS)) {++ if (req.body?.[key] === undefined) continue;++ let val = req.body[key];++ if (typeof vodStorage.DEFAULTS[key] === 'number') val = Number(val);++ if (typeof vodStorage.DEFAULTS[key] === 'boolean') val = val === true || val === 'true' || val === 1 || val === '1';++ updates[key] = val;}-- // Restart sweep timer with new settings-- vodStorage.stop();-- vodStorage.start();-- console.log(`[Admin] Storage tier settings updated (${req.appId}):`, updates);-- res.json({ ok: true, settings: vodStorage.getSettings() });++ if (!Object.keys(updates).length) return res.status(400).json({ error: `Nothing to change; known settings: ${Object.keys(vodStorage.DEFAULTS).join(', ')}` });++ const snap = await vodStorage.setSettings(updates, { actor: { type: 'service', id: req.appId }, reason: String(req.body?.reason || 'PUT /tiers/settings').slice(0, 300) });++ console.log(`[Admin] Storage tier settings: revision ${snap.revision} (${req.appId}):`, updates);++ res.json({ ok: true, revision: snap.revision, settings: vodStorage.getSettings() });} catch (err) {++ if (err && err.status && err.code) return res.status(err.status).json({ error: err.message, code: err.code, errors: err.errors || undefined });console.error('[Admin] Storage tier settings error:', err.message);res.status(500).json({ error: 'Failed to update settings' });}});++// The configuration model's own routes for this namespace (openvibe-shared/config): current values,++// history, apply a whole revision, roll back. /api/v1/:app/admin/storage/config…++// Built on first use (the store reads the database), behind this router's app-key authentication.++let configRoutes = null;++router.use('/config', (req, res, next) => {++ if (!configRoutes) {++ configRoutes = require('openvibe-shared/config').adminRoutes([vodStorage.tierConfig.get(vodStorage.DEFAULTS)], {++ basePath: '/', requireAdmin: (_rq, _rs, nx) => nx(), actor: (rq) => ({ type: 'service', id: rq.appId }),++ });++ }++ configRoutes.handle(req, res, next);++});++// ── R2 policy and decision log (read-only) ──────────────────function _decisionPublic(r) {const parse = (v) => { try { return JSON.parse(v || '{}'); } catch { return {}; } };nnew file mode 100644@@@ -0,0 +1,100 @@++/**++ * OpenVibe.Media — the storage-tier policy as revisioned configuration (roadmap WS-C task 7).++ *++ * The thresholds vod-storage.js tiers by (DEFAULTS there: offload ages, disk pressure, R2 popularity…)++ * live in the namespace `media.storage_tier` of openvibe-shared/config: every change is a revision with++ * who and why, it is validated (types from the defaults, ranges, and the order the thresholds need) before++ * it applies, the last good revision is kept and restored when applying fails, and history and rollback++ * come with it (GET/POST /api/v1/:app/admin/config…, admin routes).++ *++ * Revision 1 is what media_settings held (storage_tier.* rows), or the defaults. Every activation still++ * writes the values through to media_settings, so a release that reads those rows (a rollback within the++ * supported window) sees the same policy; the rows go in the ADR-028 contract step. Activation then++ * restarts the tiering sweep if it is running, as the old PUT /tiers/settings did.++ */++'use strict';++const config = require('openvibe-shared/config');++const db = require('../db/database');++++let store = null;++let onApplied = null; // vod-storage: restart the sweep when it is running++++const PCT = new Set(['hotDiskPressurePct', 'localLowWaterPct', 'criticalDiskPct']);++const MS_FLOOR = { sweepIntervalMs: 60000, pressureRetryMs: 10000, uploadTimeoutFloorMs: 60000, alertCooldownMs: 60000 };++++/** A JSON Schema for the defaults: each key's type, non-negative numbers, percentages 1–100, sane intervals. */++function schemaFor(defaults) {++ const properties = {};++ for (const [k, v] of Object.entries(defaults)) {++ if (typeof v === 'boolean') properties[k] = { type: 'boolean' };++ else if (typeof v === 'number') {++ properties[k] = { type: 'number', minimum: MS_FLOOR[k] || 0 };++ if (PCT.has(k)) Object.assign(properties[k], { minimum: 1, maximum: 100 });++ } else properties[k] = { type: typeof v };++ }++ return { type: 'object', additionalProperties: false, properties };++}++++/** Rules across keys: the drain stops below where it starts, and the free-space budget is ordered. */++function validate(v) {++ const errors = [];++ if (v.localLowWaterPct >= v.hotDiskPressurePct) errors.push('localLowWaterPct must be below hotDiskPressurePct (the drain stops below where it starts)');++ if (v.hotDiskPressurePct > v.criticalDiskPct) errors.push('hotDiskPressurePct must not be above criticalDiskPct');++ if (v.minFreeGb > v.targetFreeGb) errors.push('minFreeGb must not be above targetFreeGb');++ return errors.length ? errors : true;++}++++function legacyRows() {++ const rows = db.all("SELECT key, value FROM media_settings WHERE key LIKE 'storage_tier.%'") || [];++ if (!rows.length) return {}; // nothing overridden: revision 1 sets nothing (the defaults apply)++ return config.fromRows(rows.map((r) => ({ key: r.key, value: r.value, type: 'json' })), { prefix: 'storage_tier.' });++}++++/** media_settings mirrors the active revision exactly: its explicit keys as rows, every other storage_tier.* row gone. */++function writeThrough(values, defaults) {++ const h = db.getDb();++ const up = h.prepare('INSERT INTO media_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');++ const del = h.prepare('DELETE FROM media_settings WHERE key = ?');++ h.transaction(() => {++ for (const k of Object.keys(defaults)) {++ if (values[k] !== undefined) up.run(`storage_tier.${k}`, JSON.stringify(values[k]));++ else del.run(`storage_tier.${k}`);++ }++ })();++}++++/** The store (created on first use, once the database is open). */++function get(defaults) {++ if (store) return store;++ const known = Object.keys(defaults);++ store = config.createConfigStore({++ db: db.getDb(), service: 'media', namespace: 'media.storage_tier',++ defaults, schema: schemaFor(defaults), validate,++ // Stored revisions only carry known keys: a key a later release dropped is ignored, not refused.++ // Revision 1 holds exactly what was overridden, so "set explicitly" stays distinguishable from a default.++ legacy: () => { const l = legacyRows(); const out = {}; for (const k of known) if (l[k] !== undefined) out[k] = l[k]; return out; },++ onActivate: async (values, _previous, { revision }) => {++ writeThrough(explicitValues(revision, values), defaults);++ if (onApplied) onApplied(values);++ },++ log: { info: (m) => console.log(`[Tiers] ${m}`), warn: (m) => console.warn(`[Tiers] ${m}`), error: (m) => console.error(`[Tiers] ${m}`) },++ });++ return store;++}++++/** The values a revision sets explicitly (written through to media_settings; defaults stay rowless). */++function explicitValues(revision, effective) {++ const snap = store && revision != null ? store.snapshot(revision) : null;++ const out = {};++ for (const k of Object.keys((snap && snap.values) || {})) out[k] = effective[k];++ return out;++}++++/** Which keys the active revision sets explicitly (anything else is its default). */++function explicitKeys() {++ if (!store || store.revision() == null) return new Set();++ const snap = store.snapshot(store.revision());++ return new Set(Object.keys((snap && snap.values) || {}));++}++++module.exports = { get, explicitKeys, schemaFor, validate, setOnApplied: (fn) => { onApplied = fn; }, _reset: () => { store = null; } };@@@ -116,11 +116,11 @@ let sweepRunning = false;const clients = {}; // providerName → S3Clientconst providerHealthy = {}; // providerName → bool++// The policy is revisioned configuration (server/vod/tier-config.js, openvibe-shared/config): read from++// memory, changed only through a validated revision (setSettings, the admin config routes).++const tierConfig = require('./tier-config');function getSetting(key) {-- try {-- const row = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${key}`]);-- if (row) return JSON.parse(row.value);-- } catch { /* use default */ }++ try { const v = tierConfig.get(DEFAULTS).get(key); if (v !== undefined) return v; } catch (err) { console.warn('[Tiers] config unavailable:', err.message); }return DEFAULTS[key];}@@@ -130,17 +130,14 @@ function getSettings() {return s;}--function setSetting(key, value) {-- if (!(key in DEFAULTS)) return;-- const dbKey = `storage_tier.${key}`;-- const existing = db.get('SELECT key FROM media_settings WHERE key = ?', [dbKey]);-- if (existing) db.run('UPDATE media_settings SET value = ? WHERE key = ?', [JSON.stringify(value), dbKey]);-- else db.run('INSERT INTO media_settings (key, value) VALUES (?, ?)', [dbKey, JSON.stringify(value)]);++/** Change some settings: one validated revision merged over the active one. → the new snapshot (throws ConfigError 422/409) */++function setSettings(updates, { actor = null, reason = null } = {}) {++ return tierConfig.get(DEFAULTS).apply(updates, { merge: true, actor, reason });}--/** Is this storage_tier.* setting overridden in media_settings? ('setting') or the built-in default ('default'). */++/** Does the active revision set this key explicitly ('setting'), or is it the built-in default ('default')? */function settingSource(key) {-- try { return db.get('SELECT 1 AS x FROM media_settings WHERE key = ?', [`storage_tier.${key}`]) ? 'setting' : 'default'; } catch { return 'default'; }++ try { tierConfig.get(DEFAULTS); return tierConfig.explicitKeys().has(key) ? 'setting' : 'default'; } catch { return 'default'; }}// r2Enabled, r2MinViews, r2RecentAccessDays, r2MaxIdleDays, r2MaxPerSweep@@@ -1195,6 +1192,9 @@ function scheduleNext(delayMs) {if (sweepTimer.unref) sweepTimer.unref();}++// A new policy revision restarts a running sweep with it (as PUT /tiers/settings always did).++tierConfig.setOnApplied(() => { if (sweepTimer) { stop(); start(); } });++function start() {stop();const settings = getSettings();@@@ -1437,7 +1437,8 @@ module.exports = {getBucketUsage,estimateCloudCosts,getSettings,-- setSetting,++ setSettings,++ tierConfig,diskUsage,dirStats,};nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++// The storage-tier policy as revisioned configuration (WS-C task 7; server/vod/tier-config.js on++// openvibe-shared/config): revision 1 is what media_settings held, PUT /tiers/settings is one validated++// revision (the rules across thresholds included; a refused change changes nothing), media_settings mirrors++// the active revision for older releases, the admin config routes list, show history and roll back, and++// a policy change never reads the database per setting.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-tiercfg-'));++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++Object.assign(process.env, {++ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),++ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'),++});++++(async () => {++ const db = require('../server/db/database');++ db.upsertApp({ app_id: 'live', api_key: 'live-key-tiercfg' });++ // An operator had overridden one threshold before this release.++ db.run("INSERT INTO media_settings (key, value) VALUES ('storage_tier.minAgeDays', '10')");++ const storage = require('../server/vod/vod-storage');++ const row = (k) => { const r = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${k}`]); return r ? JSON.parse(r.value) : undefined; };++++ // ── Revision 1: exactly what media_settings held ──++ assert.strictEqual(storage.getSettings().minAgeDays, 10, 'the override carries over');++ assert.strictEqual(storage.getSettings().maxViewsForCold, storage.DEFAULTS.maxViewsForCold, 'everything else is its default');++ const cfg = storage.tierConfig.get(storage.DEFAULTS);++ assert.strictEqual(cfg.revision(), 1);++ assert.deepStrictEqual(cfg.snapshot(1).values, { minAgeDays: 10 });++++ const express = require('express');++ const app = express();++ app.use(express.json());++ app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));++ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const call = async (method, p, body) => {++ const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { authorization: 'Bearer live-key-tiercfg', 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });++ return { status: res.status, body: await res.json().catch(() => null) };++ };++++ // ── PUT /tiers/settings: one validated revision, merged, recorded with the app and the reason ──++ let r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { r2MinViews: '30', r2Enabled: 'false', reason: 'fewer promotions' });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.body.revision, 2);++ assert.deepStrictEqual([r.body.settings.r2MinViews, r.body.settings.r2Enabled, r.body.settings.minAgeDays], [30, false, 10], 'coerced, and merged over the active revision');++ assert.deepStrictEqual([row('r2MinViews'), row('r2Enabled'), row('minAgeDays'), row('maxViewsForCold')], [30, false, 10, undefined], 'media_settings mirrors the revision (defaults stay rowless)');++++ // A change that breaks a rule across thresholds is refused whole, and nothing changes.++ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { localLowWaterPct: 80, r2MinViews: 40 });++ assert.strictEqual(r.status, 422, JSON.stringify(r.body));++ assert.strictEqual(r.body.code, 'config.invalid');++ assert.ok(JSON.stringify(r.body.errors).includes('localLowWaterPct must be below hotDiskPressurePct'), JSON.stringify(r.body.errors));++ assert.deepStrictEqual([cfg.revision(), storage.getSettings().r2MinViews, row('r2MinViews')], [2, 30, 30]);++ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { hotDiskPressurePct: 150 });++ assert.strictEqual(r.status, 422, 'a percentage above 100');++ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', {});++ assert.strictEqual(r.status, 400, 'nothing to change');++++ // The policy endpoint says where each threshold comes from.++ r = await call('GET', '/api/v1/live/admin/storage/tiers/policy');++ assert.deepStrictEqual(r.body.r2.thresholds.r2MinViews, { value: 30, default: storage.DEFAULTS.r2MinViews, source: 'setting' });++ assert.strictEqual(r.body.r2.thresholds.r2MaxIdleDays.source, 'default');++++ // ── The configuration model's routes: list, history, rollback ──++ r = await call('GET', '/api/v1/live/admin/storage/config');++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ const ns = r.body.namespaces.find((n) => n.namespace === 'media.storage_tier');++ assert.deepStrictEqual([ns.revision, ns.last_known_good && ns.last_known_good.revision], [2, 2]);++ r = await call('GET', '/api/v1/live/admin/storage/config/media.storage_tier/history');++ assert.deepStrictEqual(r.body.snapshots.map((x) => [x.revision, x.state]), [[2, 'active'], [1, 'superseded']]);++ assert.deepStrictEqual(r.body.snapshots[0].created_by, { type: 'service', id: 'live' });++ assert.strictEqual(r.body.snapshots[0].reason, 'fewer promotions');++ r = await call('POST', '/api/v1/live/admin/storage/config/media.storage_tier/rollback', { reason: 'back to what it was' });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.body.revision, 3);++ assert.deepStrictEqual([storage.getSettings().r2MinViews, storage.getSettings().r2Enabled, storage.getSettings().minAgeDays], [storage.DEFAULTS.r2MinViews, true, 10], 'revision 1 again, as a new revision');++ assert.deepStrictEqual([row('r2MinViews'), row('r2Enabled'), row('minAgeDays')], [undefined, undefined, 10], 'and media_settings with it');++ assert.strictEqual((await call('GET', '/api/v1/live/admin/storage/config/other.namespace')).status, 404);++++ // ── A restart reads the same active revision ──++ storage.tierConfig._reset();++ assert.deepStrictEqual([storage.tierConfig.get(storage.DEFAULTS).revision(), storage.getSettings().minAgeDays], [3, 10]);++++ server.close();++ console.log('tier config: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -78,9 +78,8 @@ const s3 = http.createServer((req, res) => {assert.deepStrictEqual([storage.DEFAULTS.r2Enabled, storage.DEFAULTS.r2MinViews, storage.DEFAULTS.r2RecentAccessDays, storage.DEFAULTS.r2MaxIdleDays, storage.DEFAULTS.r2MaxPerSweep],[true, 20, 3, 14, 5]);// Keep the sweep's disk-pressure drain out of this test (whatever this machine's disk looks like).-- storage.setSetting('hotDiskPressurePct', 101);-- storage.setSetting('criticalDiskPct', 101);-- storage.setSetting('minFreeGb', 0);++ // One validated revision of media.storage_tier (percentages are 1–100: at 100 the drain never starts).++ await storage.setSettings({ hotDiskPressurePct: 100, criticalDiskPct: 100, minFreeGb: 0 }, { reason: 'test: no pressure drain' });const bytes = crypto.randomBytes(200000);const addVod = (id, { app = 'live', provider = 'local', views = 0, accessed = null } = {}) => {
@@ -756,3 +756,12 @@ Not run against production yet: it needs the owner's go-ahead.
@@ -756,3 +756,12 @@ Not run against production yet: it needs the owner's go-ahead.
operation moved onto the job system).
operation moved onto the job system).
- The segment-native timeline.
- The segment-native timeline.
- App assets (emotes and sounds, the `assets` table) are not projected yet. The `asset` kind exists for v2 uploads.
- App assets (emotes and sounds, the `assets` table) are not projected yet. The `asset` kind exists for v2 uploads.
+
+## Storage-tier policy as configuration
+
+The tiering thresholds (`DEFAULTS` in `server/vod/vod-storage.js`) are the namespace `media.storage_tier` of `openvibe-shared/config` (`server/vod/tier-config.js`, WS-C task 7).
+- **Revisions:** every change is a revision, recording who made it and why. `PUT /api/v1/:app/admin/storage/tiers/settings` merges its keys into one. The whole policy is validated first: types, percentages 1–100, interval floors, and the order the thresholds need (`localLowWaterPct` < `hotDiskPressurePct` ≤ `criticalDiskPct`, `minFreeGb` ≤ `targetFreeGb`). A refused change changes nothing and answers 422 `config.invalid` with the rules broken.
+- **Admin routes:** `…/admin/storage/config` lists the namespace, `…/config/media.storage_tier/history` shows the history, and `…/config/media.storage_tier/rollback` rolls back as a new revision.
+- **Revision 1** is what `media_settings` held.
+- **Mirror:** every activation writes the active revision's explicit keys through to `media_settings` (`storage_tier.*`) and removes the rest, so a release that reads those rows sees the same policy. The rows go in the ADR-028 contract step.
+- **Sweep:** a running sweep restarts with the new policy.
@@ -16,9 +16,9 @@
@@ -16,9 +16,9 @@
"dotenv": "^16.4.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
}
}
},
},
@@ -1919,9 +1919,9 @@
@@ -1919,9 +1919,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.52.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
+ "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1944,9 +1944,9 @@
@@ -1944,9 +1944,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.13.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
- "integrity": "sha512-aodJtwID5ZVMc8usa0hxf2YZoG8AivhI65mrk0sv6i3EHYCV9vTJCtaALpxvgsZnTzYtSg08CtzQh8GfXmcmtg==",
+ "version": "1.14.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
+ "integrity": "sha512-pID6sc4YQVyI0AeSmQGms3KGWxBxvES8dOXdkYmitIieINwSjeBbFcmzBTwhWoob2i02Pt/FT3rBBrykajKTLA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"dotenv": "^16.4.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
}
}
}
}
@@ -315,31 +315,42 @@ router.get('/tiers', (req, res) => {
@@ -315,31 +315,42 @@ router.get('/tiers', (req, res) => {
});
});
// ── PUT /tiers/settings — update tier settings ───────────────
// ── PUT /tiers/settings — update tier settings ───────────────
-router.put('/tiers/settings', (req, res) => {
+// One revision of the media.storage_tier configuration (server/vod/tier-config.js): validated as a whole
+// (422 with the rules broken), recorded with the app that asked and the reason; the sweep restarts with it.
+router.put('/tiers/settings', async (req, res) => {
try {
try {
- const allowed = Object.keys(vodStorage.DEFAULTS);
const updates = {};
const updates = {};
- for (const key of allowed) {
- if (req.body?.[key] !== undefined) {
- let val = req.body[key];
- // Coerce types to match defaults
- if (typeof vodStorage.DEFAULTS[key] === 'number') val = Number(val);
- if (typeof vodStorage.DEFAULTS[key] === 'boolean') val = !!val;
- vodStorage.setSetting(key, val);
- updates[key] = val;
- }
+ for (const key of Object.keys(vodStorage.DEFAULTS)) {
+ if (req.body?.[key] === undefined) continue;
+ let val = req.body[key];
+ if (typeof vodStorage.DEFAULTS[key] === 'number') val = Number(val);
+ if (typeof vodStorage.DEFAULTS[key] === 'boolean') val = val === true || val === 'true' || val === 1 || val === '1';
+ updates[key] = val;
}
}
- // Restart sweep timer with new settings
- vodStorage.stop();
- vodStorage.start();
- console.log(`[Admin] Storage tier settings updated (${req.appId}):`, updates);
- res.json({ ok: true, settings: vodStorage.getSettings() });
+ if (!Object.keys(updates).length) return res.status(400).json({ error: `Nothing to change; known settings: ${Object.keys(vodStorage.DEFAULTS).join(', ')}` });
+ const snap = await vodStorage.setSettings(updates, { actor: { type: 'service', id: req.appId }, reason: String(req.body?.reason || 'PUT /tiers/settings').slice(0, 300) });
+ console.log(`[Admin] Storage tier settings: revision ${snap.revision} (${req.appId}):`, updates);
+ res.json({ ok: true, revision: snap.revision, settings: vodStorage.getSettings() });
} catch (err) {
} catch (err) {
+ if (err && err.status && err.code) return res.status(err.status).json({ error: err.message, code: err.code, errors: err.errors || undefined });
console.error('[Admin] Storage tier settings error:', err.message);
console.error('[Admin] Storage tier settings error:', err.message);
res.status(500).json({ error: 'Failed to update settings' });
res.status(500).json({ error: 'Failed to update settings' });
}
}
});
});
+// The configuration model's own routes for this namespace (openvibe-shared/config): current values,
+// history, apply a whole revision, roll back. /api/v1/:app/admin/storage/config…
+// Built on first use (the store reads the database), behind this router's app-key authentication.
+let configRoutes = null;
+router.use('/config', (req, res, next) => {
+ if (!configRoutes) {
+ configRoutes = require('openvibe-shared/config').adminRoutes([vodStorage.tierConfig.get(vodStorage.DEFAULTS)], {
+ basePath: '/', requireAdmin: (_rq, _rs, nx) => nx(), actor: (rq) => ({ type: 'service', id: rq.appId }),
+ });
+ }
+ configRoutes.handle(req, res, next);
+});
+
// ── R2 policy and decision log (read-only) ──────────────────
// ── R2 policy and decision log (read-only) ──────────────────
function _decisionPublic(r) {
function _decisionPublic(r) {
const parse = (v) => { try { return JSON.parse(v || '{}'); } catch { return {}; } };
const parse = (v) => { try { return JSON.parse(v || '{}'); } catch { return {}; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+/**
+ * OpenVibe.Media — the storage-tier policy as revisioned configuration (roadmap WS-C task 7).
+ *
+ * The thresholds vod-storage.js tiers by (DEFAULTS there: offload ages, disk pressure, R2 popularity…)
+ * live in the namespace `media.storage_tier` of openvibe-shared/config: every change is a revision with
+ * who and why, it is validated (types from the defaults, ranges, and the order the thresholds need) before
+ * it applies, the last good revision is kept and restored when applying fails, and history and rollback
+ * come with it (GET/POST /api/v1/:app/admin/config…, admin routes).
+ *
+ * Revision 1 is what media_settings held (storage_tier.* rows), or the defaults. Every activation still
+ * writes the values through to media_settings, so a release that reads those rows (a rollback within the
+ * supported window) sees the same policy; the rows go in the ADR-028 contract step. Activation then
+ * restarts the tiering sweep if it is running, as the old PUT /tiers/settings did.
+ */
+'use strict';
+const config = require('openvibe-shared/config');
+const db = require('../db/database');
+
+let store = null;
+let onApplied = null; // vod-storage: restart the sweep when it is running
+
+const PCT = new Set(['hotDiskPressurePct', 'localLowWaterPct', 'criticalDiskPct']);
+const MS_FLOOR = { sweepIntervalMs: 60000, pressureRetryMs: 10000, uploadTimeoutFloorMs: 60000, alertCooldownMs: 60000 };
+
+/** A JSON Schema for the defaults: each key's type, non-negative numbers, percentages 1–100, sane intervals. */
+function schemaFor(defaults) {
+ const properties = {};
+ for (const [k, v] of Object.entries(defaults)) {
+ if (typeof v === 'boolean') properties[k] = { type: 'boolean' };
+ else if (typeof v === 'number') {
+ properties[k] = { type: 'number', minimum: MS_FLOOR[k] || 0 };
+ if (PCT.has(k)) Object.assign(properties[k], { minimum: 1, maximum: 100 });
+ } else properties[k] = { type: typeof v };
+ }
+ return { type: 'object', additionalProperties: false, properties };
+}
+
+/** Rules across keys: the drain stops below where it starts, and the free-space budget is ordered. */
+function validate(v) {
+ const errors = [];
+ if (v.localLowWaterPct >= v.hotDiskPressurePct) errors.push('localLowWaterPct must be below hotDiskPressurePct (the drain stops below where it starts)');
+ if (v.hotDiskPressurePct > v.criticalDiskPct) errors.push('hotDiskPressurePct must not be above criticalDiskPct');
+ if (v.minFreeGb > v.targetFreeGb) errors.push('minFreeGb must not be above targetFreeGb');
+ return errors.length ? errors : true;
+}
+
+function legacyRows() {
+ const rows = db.all("SELECT key, value FROM media_settings WHERE key LIKE 'storage_tier.%'") || [];
+ if (!rows.length) return {}; // nothing overridden: revision 1 sets nothing (the defaults apply)
+ return config.fromRows(rows.map((r) => ({ key: r.key, value: r.value, type: 'json' })), { prefix: 'storage_tier.' });
+}
+
+/** media_settings mirrors the active revision exactly: its explicit keys as rows, every other storage_tier.* row gone. */
+function writeThrough(values, defaults) {
+ const h = db.getDb();
+ const up = h.prepare('INSERT INTO media_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
+ const del = h.prepare('DELETE FROM media_settings WHERE key = ?');
+ h.transaction(() => {
+ for (const k of Object.keys(defaults)) {
+ if (values[k] !== undefined) up.run(`storage_tier.${k}`, JSON.stringify(values[k]));
+ else del.run(`storage_tier.${k}`);
+ }
+ })();
+}
+
+/** The store (created on first use, once the database is open). */
+function get(defaults) {
+ if (store) return store;
+ const known = Object.keys(defaults);
+ store = config.createConfigStore({
+ db: db.getDb(), service: 'media', namespace: 'media.storage_tier',
+ defaults, schema: schemaFor(defaults), validate,
+ // Stored revisions only carry known keys: a key a later release dropped is ignored, not refused.
+ // Revision 1 holds exactly what was overridden, so "set explicitly" stays distinguishable from a default.
+ legacy: () => { const l = legacyRows(); const out = {}; for (const k of known) if (l[k] !== undefined) out[k] = l[k]; return out; },
+ onActivate: async (values, _previous, { revision }) => {
+ writeThrough(explicitValues(revision, values), defaults);
+ if (onApplied) onApplied(values);
+ },
+ log: { info: (m) => console.log(`[Tiers] ${m}`), warn: (m) => console.warn(`[Tiers] ${m}`), error: (m) => console.error(`[Tiers] ${m}`) },
+ });
+ return store;
+}
+
+/** The values a revision sets explicitly (written through to media_settings; defaults stay rowless). */
+function explicitValues(revision, effective) {
+ const snap = store && revision != null ? store.snapshot(revision) : null;
+ const out = {};
+ for (const k of Object.keys((snap && snap.values) || {})) out[k] = effective[k];
+ return out;
+}
+
+/** Which keys the active revision sets explicitly (anything else is its default). */
+function explicitKeys() {
+ if (!store || store.revision() == null) return new Set();
+ const snap = store.snapshot(store.revision());
+ return new Set(Object.keys((snap && snap.values) || {}));
+}
+
+module.exports = { get, explicitKeys, schemaFor, validate, setOnApplied: (fn) => { onApplied = fn; }, _reset: () => { store = null; } };
@@ -116,11 +116,11 @@ let sweepRunning = false;
@@ -116,11 +116,11 @@ let sweepRunning = false;
const clients = {}; // providerName → S3Client
const clients = {}; // providerName → S3Client
const providerHealthy = {}; // providerName → bool
const providerHealthy = {}; // providerName → bool
+// The policy is revisioned configuration (server/vod/tier-config.js, openvibe-shared/config): read from
+// memory, changed only through a validated revision (setSettings, the admin config routes).
+const tierConfig = require('./tier-config');
function getSetting(key) {
function getSetting(key) {
- try {
- const row = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${key}`]);
- if (row) return JSON.parse(row.value);
- } catch { /* use default */ }
+ try { const v = tierConfig.get(DEFAULTS).get(key); if (v !== undefined) return v; } catch (err) { console.warn('[Tiers] config unavailable:', err.message); }
return DEFAULTS[key];
return DEFAULTS[key];
}
}
@@ -130,17 +130,14 @@ function getSettings() {
@@ -130,17 +130,14 @@ function getSettings() {
return s;
return s;
}
}
-function setSetting(key, value) {
- if (!(key in DEFAULTS)) return;
- const dbKey = `storage_tier.${key}`;
- const existing = db.get('SELECT key FROM media_settings WHERE key = ?', [dbKey]);
- if (existing) db.run('UPDATE media_settings SET value = ? WHERE key = ?', [JSON.stringify(value), dbKey]);
- else db.run('INSERT INTO media_settings (key, value) VALUES (?, ?)', [dbKey, JSON.stringify(value)]);
+/** Change some settings: one validated revision merged over the active one. → the new snapshot (throws ConfigError 422/409) */
+function setSettings(updates, { actor = null, reason = null } = {}) {
+ return tierConfig.get(DEFAULTS).apply(updates, { merge: true, actor, reason });
}
}
-/** Is this storage_tier.* setting overridden in media_settings? ('setting') or the built-in default ('default'). */
+/** Does the active revision set this key explicitly ('setting'), or is it the built-in default ('default')? */
function settingSource(key) {
function settingSource(key) {
- try { return db.get('SELECT 1 AS x FROM media_settings WHERE key = ?', [`storage_tier.${key}`]) ? 'setting' : 'default'; } catch { return 'default'; }
+ try { tierConfig.get(DEFAULTS); return tierConfig.explicitKeys().has(key) ? 'setting' : 'default'; } catch { return 'default'; }
}
}
// r2Enabled, r2MinViews, r2RecentAccessDays, r2MaxIdleDays, r2MaxPerSweep
// r2Enabled, r2MinViews, r2RecentAccessDays, r2MaxIdleDays, r2MaxPerSweep
@@ -1195,6 +1192,9 @@ function scheduleNext(delayMs) {
@@ -1195,6 +1192,9 @@ function scheduleNext(delayMs) {
if (sweepTimer.unref) sweepTimer.unref();
if (sweepTimer.unref) sweepTimer.unref();
}
}
+// A new policy revision restarts a running sweep with it (as PUT /tiers/settings always did).
+tierConfig.setOnApplied(() => { if (sweepTimer) { stop(); start(); } });
+
function start() {
function start() {
stop();
stop();
const settings = getSettings();
const settings = getSettings();
@@ -1437,7 +1437,8 @@ module.exports = {
@@ -1437,7 +1437,8 @@ module.exports = {
getBucketUsage,
getBucketUsage,
estimateCloudCosts,
estimateCloudCosts,
getSettings,
getSettings,
- setSetting,
+ setSettings,
+ tierConfig,
diskUsage,
diskUsage,
dirStats,
dirStats,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+// The storage-tier policy as revisioned configuration (WS-C task 7; server/vod/tier-config.js on
+// openvibe-shared/config): revision 1 is what media_settings held, PUT /tiers/settings is one validated
+// revision (the rules across thresholds included; a refused change changes nothing), media_settings mirrors
+// the active revision for older releases, the admin config routes list, show history and roll back, and
+// a policy change never reads the database per setting.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-tiercfg-'));
+const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+Object.assign(process.env, {
+ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
+ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'),
+});
+
+(async () => {
+ const db = require('../server/db/database');
+ db.upsertApp({ app_id: 'live', api_key: 'live-key-tiercfg' });
+ // An operator had overridden one threshold before this release.
+ db.run("INSERT INTO media_settings (key, value) VALUES ('storage_tier.minAgeDays', '10')");
+ const storage = require('../server/vod/vod-storage');
+ const row = (k) => { const r = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${k}`]); return r ? JSON.parse(r.value) : undefined; };
+
+ // ── Revision 1: exactly what media_settings held ──
+ assert.strictEqual(storage.getSettings().minAgeDays, 10, 'the override carries over');
+ assert.strictEqual(storage.getSettings().maxViewsForCold, storage.DEFAULTS.maxViewsForCold, 'everything else is its default');
+ const cfg = storage.tierConfig.get(storage.DEFAULTS);
+ assert.strictEqual(cfg.revision(), 1);
+ assert.deepStrictEqual(cfg.snapshot(1).values, { minAgeDays: 10 });
+
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
+ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const call = async (method, p, body) => {
+ const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { authorization: 'Bearer live-key-tiercfg', 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
+ return { status: res.status, body: await res.json().catch(() => null) };
+ };
+
+ // ── PUT /tiers/settings: one validated revision, merged, recorded with the app and the reason ──
+ let r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { r2MinViews: '30', r2Enabled: 'false', reason: 'fewer promotions' });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.body.revision, 2);
+ assert.deepStrictEqual([r.body.settings.r2MinViews, r.body.settings.r2Enabled, r.body.settings.minAgeDays], [30, false, 10], 'coerced, and merged over the active revision');
+ assert.deepStrictEqual([row('r2MinViews'), row('r2Enabled'), row('minAgeDays'), row('maxViewsForCold')], [30, false, 10, undefined], 'media_settings mirrors the revision (defaults stay rowless)');
+
+ // A change that breaks a rule across thresholds is refused whole, and nothing changes.
+ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { localLowWaterPct: 80, r2MinViews: 40 });
+ assert.strictEqual(r.status, 422, JSON.stringify(r.body));
+ assert.strictEqual(r.body.code, 'config.invalid');
+ assert.ok(JSON.stringify(r.body.errors).includes('localLowWaterPct must be below hotDiskPressurePct'), JSON.stringify(r.body.errors));
+ assert.deepStrictEqual([cfg.revision(), storage.getSettings().r2MinViews, row('r2MinViews')], [2, 30, 30]);
+ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { hotDiskPressurePct: 150 });
+ assert.strictEqual(r.status, 422, 'a percentage above 100');
+ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', {});
+ assert.strictEqual(r.status, 400, 'nothing to change');
+
+ // The policy endpoint says where each threshold comes from.
+ r = await call('GET', '/api/v1/live/admin/storage/tiers/policy');
+ assert.deepStrictEqual(r.body.r2.thresholds.r2MinViews, { value: 30, default: storage.DEFAULTS.r2MinViews, source: 'setting' });
+ assert.strictEqual(r.body.r2.thresholds.r2MaxIdleDays.source, 'default');
+
+ // ── The configuration model's routes: list, history, rollback ──
+ r = await call('GET', '/api/v1/live/admin/storage/config');
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ const ns = r.body.namespaces.find((n) => n.namespace === 'media.storage_tier');
+ assert.deepStrictEqual([ns.revision, ns.last_known_good && ns.last_known_good.revision], [2, 2]);
+ r = await call('GET', '/api/v1/live/admin/storage/config/media.storage_tier/history');
+ assert.deepStrictEqual(r.body.snapshots.map((x) => [x.revision, x.state]), [[2, 'active'], [1, 'superseded']]);
+ assert.deepStrictEqual(r.body.snapshots[0].created_by, { type: 'service', id: 'live' });
+ assert.strictEqual(r.body.snapshots[0].reason, 'fewer promotions');
+ r = await call('POST', '/api/v1/live/admin/storage/config/media.storage_tier/rollback', { reason: 'back to what it was' });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.body.revision, 3);
+ assert.deepStrictEqual([storage.getSettings().r2MinViews, storage.getSettings().r2Enabled, storage.getSettings().minAgeDays], [storage.DEFAULTS.r2MinViews, true, 10], 'revision 1 again, as a new revision');
+ assert.deepStrictEqual([row('r2MinViews'), row('r2Enabled'), row('minAgeDays')], [undefined, undefined, 10], 'and media_settings with it');
+ assert.strictEqual((await call('GET', '/api/v1/live/admin/storage/config/other.namespace')).status, 404);
+
+ // ── A restart reads the same active revision ──
+ storage.tierConfig._reset();
+ assert.deepStrictEqual([storage.tierConfig.get(storage.DEFAULTS).revision(), storage.getSettings().minAgeDays], [3, 10]);
+
+ server.close();
+ console.log('tier config: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -78,9 +78,8 @@ const s3 = http.createServer((req, res) => {
@@ -78,9 +78,8 @@ const s3 = http.createServer((req, res) => {
assert.deepStrictEqual([storage.DEFAULTS.r2Enabled, storage.DEFAULTS.r2MinViews, storage.DEFAULTS.r2RecentAccessDays, storage.DEFAULTS.r2MaxIdleDays, storage.DEFAULTS.r2MaxPerSweep],
assert.deepStrictEqual([storage.DEFAULTS.r2Enabled, storage.DEFAULTS.r2MinViews, storage.DEFAULTS.r2RecentAccessDays, storage.DEFAULTS.r2MaxIdleDays, storage.DEFAULTS.r2MaxPerSweep],
[true, 20, 3, 14, 5]);
[true, 20, 3, 14, 5]);
// Keep the sweep's disk-pressure drain out of this test (whatever this machine's disk looks like).
// Keep the sweep's disk-pressure drain out of this test (whatever this machine's disk looks like).
- storage.setSetting('hotDiskPressurePct', 101);
- storage.setSetting('criticalDiskPct', 101);
- storage.setSetting('minFreeGb', 0);
+ // One validated revision of media.storage_tier (percentages are 1–100: at 100 the drain never starts).
+ await storage.setSettings({ hotDiskPressurePct: 100, criticalDiskPct: 100, minFreeGb: 0 }, { reason: 'test: no pressure drain' });
const bytes = crypto.randomBytes(200000);
const bytes = crypto.randomBytes(200000);
const addVod = (id, { app = 'live', provider = 'local', views = 0, accessed = null } = {}) => {
const addVod = (id, { app = 'live', provider = 'local', views = 0, accessed = null } = {}) => {