openvibers/openvibe.media Commits

Add revisioned per-class storage policy budgets and residency checks (#11)

OP OpenVibers committed 2 Oct 2026 · 44fc3cef8c · 2 files, +153 −0

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Media/pull/11

Changes

ddiff --git a/server/placement/storage-policy.js b/server/placement/storage-policy.js
nnew file mode 100644
iindex 0000000..35e990f
---- /dev/null
++++ b/server/placement/storage-policy.js
@@@ -0,0 +1,105 @@
++/**
++ * Revisioned placement budgets and residency policy (docs/media-fabric.md §6, §10).
++ * This namespace is additive: existing tiering sweeps do not read it yet.
++ */
++'use strict';
++
++const config = require('openvibe-shared/config');
++const db = require('../db/database');
++
++// Match media.object_tier (server/objects/tier-policy.js) until placement uses this policy.
++const BASE = { maxPromotionsPerSweep: 3, maxDemotionsPerSweep: 10, minResidencyMs: 0 };
++const CLASSES = ['video', 'image', 'download', 'game-asset', 'attachment', 'backup'];
++const DEFAULTS = {
++ classes: Object.fromEntries(CLASSES.map((name) => [name, { ...BASE }])),
++};
++
++const CLASS_SCHEMA = {
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ maxPromotionsPerSweep: { type: 'integer', minimum: 0, maximum: 100 },
++ maxDemotionsPerSweep: { type: 'integer', minimum: 0, maximum: 500 },
++ minResidencyMs: { type: 'integer', minimum: 0 },
++ },
++};
++const SCHEMA = {
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ classes: {
++ type: 'object',
++ additionalProperties: false,
++ properties: Object.fromEntries(CLASSES.map((name) => [name, CLASS_SCHEMA])),
++ },
++ },
++};
++
++let store = null;
++
++async function init() {
++ if (store) return store;
++ store = await config.createConfigStore({
++ db: db.getDb(), service: 'media', namespace: 'media.storage_policy',
++ defaults: DEFAULTS, schema: SCHEMA,
++ legacy: () => ({}),
++ onActivate: async () => {},
++ log: { info: (m) => console.log(`[StoragePolicy] ${m}`), warn: (m) => console.warn(`[StoragePolicy] ${m}`), error: (m) => console.error(`[StoragePolicy] ${m}`) },
++ });
++ return store;
++}
++
++function get() {
++ if (!store) throw new Error('media.storage_policy is not loaded yet (storage-policy init() at boot)');
++ return store;
++}
++
++async function set(updates, { actor = null, reason = null } = {}) {
++ // Build a complete value: the shared store's merge does not promise to merge nested class fields.
++ return get().apply(merge(settings(), updates), { actor, reason });
++}
++
++function merge(base, updates) {
++ const out = { ...base };
++ for (const [key, value] of Object.entries(updates || {})) {
++ out[key] = value && typeof value === 'object' && !Array.isArray(value)
++ && base[key] && typeof base[key] === 'object' ? merge(base[key], value) : value;
++ }
++ return out;
++}
++
++function settings() {
++ let configured;
++ try { configured = get().get(); } catch { configured = {}; }
++ const classes = {};
++ for (const name of CLASSES) classes[name] = { ...BASE, ...configured.classes?.[name] };
++ return { classes };
++}
++
++function classPolicy(name) {
++ if (!CLASSES.includes(name)) throw new RangeError(`Unknown media class: ${name}`);
++ return settings().classes[name];
++}
++
++function budgetFor(name) {
++ const { maxPromotionsPerSweep, maxDemotionsPerSweep } = classPolicy(name);
++ return { maxPromotionsPerSweep, maxDemotionsPerSweep };
++}
++
++function timestamp(value) {
++ if (value instanceof Date) return value.getTime();
++ if (typeof value === 'number') return value;
++ if (typeof value !== 'string') return NaN;
++ const trimmed = value.trim();
++ return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(trimmed) ? trimmed : `${trimmed.replace(' ', 'T')}Z`);
++}
++
++function mayMove({ class: name, lastMovedAt, now = Date.now() }) {
++ const { minResidencyMs } = classPolicy(name);
++ if (minResidencyMs === 0 || lastMovedAt == null) return true;
++ const last = timestamp(lastMovedAt);
++ const current = timestamp(now);
++ return Number.isFinite(last) && Number.isFinite(current) && current - last >= minResidencyMs;
++}
++
++module.exports = { DEFAULTS, SCHEMA, CLASSES, init, get, set, settings, budgetFor, mayMove, _reset: () => { store = null; } };
ddiff --git a/test/placement-storage-policy.test.js b/test/placement-storage-policy.test.js
nnew file mode 100644
iindex 0000000..b462969
---- /dev/null
++++ b/test/placement-storage-policy.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++(async () => {
++ const dir = (name) => { const d = path.join(os.tmpdir(), `ov-media-policy-${name}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`); fs.mkdirSync(d, { recursive: true }); return d; };
++ for (const key of ['VOD_PATH', 'CLIPS_PATH', 'PASTES_PATH', 'THUMBNAILS_PATH', 'FILES_PATH', 'OBJECTS_PATH', 'ASSETS_PATH']) process.env[key] = dir(key);
++ process.env.MEDIA_PGLITE_DIR = dir('pglite');
++ process.env.DATABASE_URL = '';
++
++ const db = require('../server/db/database');
++ await db.initDb();
++ const policy = require('../server/placement/storage-policy');
++ await policy.init();
++
++ assert.strictEqual(policy.get().revision(), 1);
++ for (const name of policy.CLASSES) {
++ assert.deepStrictEqual(policy.DEFAULTS.classes[name], {
++ maxPromotionsPerSweep: 3, maxDemotionsPerSweep: 10, minResidencyMs: 0,
++ });
++ assert.deepStrictEqual(policy.budgetFor(name), { maxPromotionsPerSweep: 3, maxDemotionsPerSweep: 10 });
++ }
++ assert.strictEqual(policy.mayMove({ class: 'video', lastMovedAt: 1000, now: 1000 }), true, 'zero residency is off');
++ assert.throws(() => policy.budgetFor('unknown'), RangeError);
++
++ await policy.set({ classes: { image: { maxPromotionsPerSweep: 2, maxDemotionsPerSweep: 4, minResidencyMs: 60_000 } } },
++ { actor: { type: 'service', id: 'media-test' }, reason: 'test image placement policy' });
++ assert.strictEqual(policy.get().revision(), 2, 'set creates a new revision');
++ assert.deepStrictEqual(policy.budgetFor('image'), { maxPromotionsPerSweep: 2, maxDemotionsPerSweep: 4 });
++ assert.deepStrictEqual(policy.budgetFor('video'), { maxPromotionsPerSweep: 3, maxDemotionsPerSweep: 10 }, 'other classes keep defaults');
++ assert.strictEqual(policy.mayMove({ class: 'image', lastMovedAt: 1000, now: 60_999 }), false);
++ assert.strictEqual(policy.mayMove({ class: 'image', lastMovedAt: 1000, now: 61_000 }), true, 'boundary is inclusive');
++ assert.strictEqual(policy.mayMove({ class: 'image', lastMovedAt: null, now: 1000 }), true, 'first move has no residency');
++ assert.strictEqual(policy.mayMove({ class: 'image', lastMovedAt: 'invalid', now: 61_000 }), false);
++ assert.strictEqual(policy.mayMove({ class: 'image', lastMovedAt: '2026-10-02 00:00:00', now: '2026-10-02T00:01:00Z' }), true);
++
++ await policy.set({ classes: { video: { maxPromotionsPerSweep: 1 } } },
++ { actor: { type: 'service', id: 'media-test' }, reason: 'test a second partial update' });
++ assert.strictEqual(policy.get().revision(), 3);
++ assert.deepStrictEqual(policy.budgetFor('image'), { maxPromotionsPerSweep: 2, maxDemotionsPerSweep: 4 }, 'second revision preserves image settings');
++ assert.deepStrictEqual(policy.budgetFor('video'), { maxPromotionsPerSweep: 1, maxDemotionsPerSweep: 10 });
++
++ policy._reset();
++ console.log('placement-storage-policy: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });