openvibers/openvibe.media Commits
Add revisioned per-class storage policy budgets and residency checks (#11)
Changes
nnew file mode 100644@@@ -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; } };nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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); });