189 lines
7.3 KiB
JavaScript
189 lines
7.3 KiB
JavaScript
|
|
/**
|
||
|
|
* rigor/tx-rollback-parity.test.js — transactional batch atomicity and
|
||
|
|
* batch+PLTC reachability.
|
||
|
|
*
|
||
|
|
* Contracts pinned:
|
||
|
|
* - updateRelationsBatchTransactional is ATOMIC: a poison op anywhere in
|
||
|
|
* the batch (invalid possibility) fails the whole batch and rolls back
|
||
|
|
* every earlier op; on success every op applies in the given order.
|
||
|
|
* - batch ADD validates possibility exactly like single adds (an invalid
|
||
|
|
* possibility must never be stored, even via the batch path).
|
||
|
|
* - with enableReachabilityCheck, batch adds/removes update the PLTC
|
||
|
|
* indices: chain checks reflect batch-written edges immediately.
|
||
|
|
*/
|
||
|
|
import { describe, it } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { rigor } from '@rigor/core';
|
||
|
|
import { Arbiter } from '../../src/index.js';
|
||
|
|
|
||
|
|
const DOCS = 3;
|
||
|
|
const docKey = (i) => `doc:${i}`;
|
||
|
|
const tupleKey = (d) => `u:0|owner|doc:${d}`;
|
||
|
|
|
||
|
|
function applyMirror(tuples, ops) {
|
||
|
|
for (const op of ops) {
|
||
|
|
if (op.operation === 'remove') {
|
||
|
|
tuples.delete(tupleKey(op.doc));
|
||
|
|
} else if (op.operation === 'modify' && !tuples.has(tupleKey(op.doc))) {
|
||
|
|
continue; // engine no-op on missing tuple
|
||
|
|
} else {
|
||
|
|
tuples.set(tupleKey(op.doc), op.value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeWrapper() {
|
||
|
|
const arbiter = new Arbiter();
|
||
|
|
arbiter.addNode('u:0', 'user');
|
||
|
|
for (let i = 0; i < DOCS; i++) arbiter.addNode(docKey(i), 'doc');
|
||
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
||
|
|
const tuples = new Map();
|
||
|
|
const ops = [];
|
||
|
|
|
||
|
|
const wrapper = {
|
||
|
|
engine: arbiter,
|
||
|
|
tuples,
|
||
|
|
add(doc, value) {
|
||
|
|
arbiter.addRelation('u:0', 'owner', docKey(doc), { possibility: value });
|
||
|
|
tuples.set(tupleKey(doc), value);
|
||
|
|
return { ok: true };
|
||
|
|
},
|
||
|
|
txBatch(batchOps, poisonAt) {
|
||
|
|
const batch = batchOps.map((op, i) => ({
|
||
|
|
operation: op.operation,
|
||
|
|
srcKey: 'u:0',
|
||
|
|
relation: 'owner',
|
||
|
|
dstKey: docKey(op.doc),
|
||
|
|
options: op.operation === 'remove'
|
||
|
|
? {}
|
||
|
|
: { possibility: i === poisonAt ? 1.5 : op.value }
|
||
|
|
}));
|
||
|
|
const result = arbiter.relationManager.updateRelationsBatchTransactional(batch);
|
||
|
|
if (result.success) {
|
||
|
|
applyMirror(tuples, batchOps);
|
||
|
|
}
|
||
|
|
return { success: result.success, error: result.error };
|
||
|
|
},
|
||
|
|
check(doc) {
|
||
|
|
const result = arbiter.check('u:0', 'can_read', docKey(doc));
|
||
|
|
const expected = tuples.get(tupleKey(doc)) ?? 0;
|
||
|
|
return { engine: result.possibility, expected, reason: result.reason };
|
||
|
|
},
|
||
|
|
clone() {
|
||
|
|
const fresh = makeWrapper();
|
||
|
|
for (const op of ops) {
|
||
|
|
const [name, ...args] = op;
|
||
|
|
fresh[name](...args);
|
||
|
|
}
|
||
|
|
return fresh;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const record = (name, fn) => (...args) => {
|
||
|
|
const res = fn(...args);
|
||
|
|
ops.push([name, ...args]);
|
||
|
|
return res;
|
||
|
|
};
|
||
|
|
wrapper.add = record('add', wrapper.add);
|
||
|
|
wrapper.txBatch = record('txBatch', wrapper.txBatch);
|
||
|
|
wrapper.check = record('check', wrapper.check);
|
||
|
|
return wrapper;
|
||
|
|
}
|
||
|
|
|
||
|
|
const opGen = rigor.gen.record({
|
||
|
|
operation: rigor.gen.enum(['add', 'modify', 'remove']),
|
||
|
|
doc: rigor.gen.int(0, DOCS - 1),
|
||
|
|
value: rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9])
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Transactional batch atomicity and batch+PLTC (rigor)', () => {
|
||
|
|
it('FIXED MATRIX: poison at every position rolls back everything; batch add validates possibility', () => {
|
||
|
|
const w = makeWrapper();
|
||
|
|
w.add(0, 0.9);
|
||
|
|
w.add(1, 0.7);
|
||
|
|
|
||
|
|
for (const poisonAt of [0, 1, 2]) {
|
||
|
|
const tx = w.txBatch([
|
||
|
|
{ operation: 'add', doc: 2, value: 0.4 },
|
||
|
|
{ operation: 'modify', doc: 1, value: 0.6 },
|
||
|
|
{ operation: 'modify', doc: 0, value: 0.6 }
|
||
|
|
], poisonAt);
|
||
|
|
assert.equal(tx.success, false, `poison at ${poisonAt} must fail`);
|
||
|
|
assert.equal(w.check(0).engine, 0.9, `poison at ${poisonAt}: doc0 rolled back`);
|
||
|
|
assert.equal(w.check(1).engine, 0.7, `poison at ${poisonAt}: doc1 rolled back`);
|
||
|
|
assert.equal(w.check(2).engine, 0, `poison at ${poisonAt}: doc2 rolled back`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const clean = w.txBatch([
|
||
|
|
{ operation: 'add', doc: 2, value: 0.4 },
|
||
|
|
{ operation: 'modify', doc: 1, value: 0.6 },
|
||
|
|
{ operation: 'modify', doc: 0, value: 0.6 }
|
||
|
|
], -1);
|
||
|
|
assert.equal(clean.success, true, 'clean tx succeeds');
|
||
|
|
assert.equal(w.check(0).engine, 0.6, 'clean tx: doc0 last-write-wins');
|
||
|
|
assert.equal(w.check(1).engine, 0.6, 'clean tx: doc1 modified');
|
||
|
|
assert.equal(w.check(2).engine, 0.4, 'clean tx: doc2 added');
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => w.engine.relationManager.updateRelationsBatch([
|
||
|
|
{ operation: 'add', srcKey: 'u:0', relation: 'owner', dstKey: 'doc:0', options: { possibility: 7 } }
|
||
|
|
]),
|
||
|
|
/Invalid possibility/,
|
||
|
|
'batch add must reject invalid possibility'
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('BATCH+PLTC: chain reachability reflects batch writes immediately', () => {
|
||
|
|
const arb = new Arbiter({ enableReachabilityCheck: true });
|
||
|
|
arb.addNode('u:0', 'user');
|
||
|
|
arb.addNode('g:0', 'group');
|
||
|
|
arb.addNode('doc:0', 'doc');
|
||
|
|
arb.setRelationConfig('can_access', {
|
||
|
|
type: 'chain',
|
||
|
|
steps: [
|
||
|
|
{ relation: 'member_of', direction: 'out' },
|
||
|
|
{ relation: 'reads', direction: 'out' }
|
||
|
|
]
|
||
|
|
});
|
||
|
|
arb.relationManager.updateRelationsBatch([
|
||
|
|
{ operation: 'add', srcKey: 'u:0', relation: 'member_of', dstKey: 'g:0', options: { possibility: 0.8 } },
|
||
|
|
{ operation: 'add', srcKey: 'g:0', relation: 'reads', dstKey: 'doc:0', options: { possibility: 0.7 } }
|
||
|
|
]);
|
||
|
|
assert.equal(arb.check('u:0', 'can_access', 'doc:0').possibility, 0.7, 'chain allow after batch add');
|
||
|
|
arb.relationManager.updateRelationsBatch([
|
||
|
|
{ operation: 'remove', srcKey: 'g:0', relation: 'reads', dstKey: 'doc:0', options: {} }
|
||
|
|
]);
|
||
|
|
assert.equal(arb.check('u:0', 'can_access', 'doc:0').possibility, 0, 'chain deny after batch remove');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('PROPERTY CAMPAIGN: tx atomicity and in-order application hold under fuzzed batches', async () => {
|
||
|
|
const result = await rigor.campaign(
|
||
|
|
[rigor.object('graph', makeWrapper, [
|
||
|
|
rigor.method('add', function (d, v) { return this.add(d, v); }, rigor.args(rigor.gen.int(0, DOCS - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]))),
|
||
|
|
rigor.method('txBatch', function (batchOps, poisonAt) { return this.txBatch(batchOps, poisonAt); },
|
||
|
|
rigor.args(rigor.gen.array(opGen, 1, 4), rigor.gen.oneOf([-1, 0, 1, 2, 3]))),
|
||
|
|
rigor.method('check', function (d) { return this.check(d); }, rigor.args(rigor.gen.int(0, DOCS - 1)))
|
||
|
|
])],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('decision parity after every step', (ctx) => {
|
||
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||
|
|
return ctx.actual.engine === ctx.actual.expected;
|
||
|
|
}),
|
||
|
|
rigor.invariant('tx returns a boolean success flag', (ctx) => {
|
||
|
|
if (ctx.action !== 'graph.txBatch') return true;
|
||
|
|
return typeof ctx.actual.success === 'boolean';
|
||
|
|
}),
|
||
|
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
||
|
|
])
|
||
|
|
).run({ effort: 400, seed: 'tx-rollback-parity', maxTraceLength: 25 });
|
||
|
|
|
||
|
|
const inv = result.crucibleVerdict;
|
||
|
|
assert.equal(inv.passed, true, [
|
||
|
|
`tx atomicity violated in ${inv.failureCount} cases:`,
|
||
|
|
...result.failures.slice(0, 3).map((f) =>
|
||
|
|
` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
|
||
|
|
)
|
||
|
|
].join('\n'));
|
||
|
|
});
|
||
|
|
});
|