js-rigor: fix batch operation ordering (last-write-wins), batch-order campaign

updateRelationsBatch previously pre-sorted ops remove->modify->add, which
changed the final state whenever one tuple was touched by mixed kinds:
[add, remove] left the tuple present, [modify, add, modify] ended with
the middle value. Now ops apply strictly in the given order via the
dedup-aware _addRelationInternal (in-place last-write-wins) with upfront
validation, post-batch PLTC edge updates, and per-relation arbiter-level
cache invalidation.

batch-order-parity.test.js pins the contract with an in-order mirror
(modify-of-missing is a silent no-op).
This commit is contained in:
John Dvorak
2026-07-31 14:25:07 -07:00
parent 7ffa5045e6
commit f5690d4777
2 changed files with 210 additions and 25 deletions
+55 -25
View File
@@ -382,33 +382,63 @@ export class RelationUpdates {
if (this.manager.arbiter._snapshotReadOnly) {
throw new Error('Cannot update relations in batch while in snapshot read-only mode. Disable snapshot before writing.');
}
const addOps = [];
const removeOps = [];
const modifyOps = [];
// Pre-sort operations for better cache locality
var len = updates.length;
for (var i = 0; i < len; i++) {
const update = updates[i];
switch (update.operation) {
case 'add':
addOps.push(update);
break;
case 'remove':
removeOps.push(update);
break;
case 'modify':
modifyOps.push(update);
break;
default:
throw new Error(`Unknown operation: ${update.operation}`);
// Validate every operation up front so a bad op cannot partially apply.
for (const op of updates) {
if (!op || (op.operation !== 'add' && op.operation !== 'remove' && op.operation !== 'modify')) {
throw new Error(`Unknown operation: ${op.operation}`);
}
}
// Process in optimal order: removes first, then modifications, then adds
this._processBatchRemovals(removeOps, chunkSize);
this._processBatchModifications(modifyOps, chunkSize);
this._processBatchAdditions(addOps, chunkSize);
// Apply operations strictly in the given order. The previous
// remove->modify->add pre-sort changed the final state whenever one
// tuple was touched by mixed kinds: [add, remove] left the tuple
// present, and [modify, add, modify] ended with the middle value
// instead of the last one (last-write-wins).
const arbiter = this.manager.arbiter;
arbiter.batchUpdateInProgress = true;
try {
for (let i = 0; i < updates.length; i += chunkSize) {
const chunk = updates.slice(i, i + chunkSize);
for (const op of chunk) {
switch (op.operation) {
case 'add':
this._addRelationInternal(op.srcKey, op.relation, op.dstKey, op.options);
break;
case 'remove':
this.manager.removeRelation(op.srcKey, op.relation, op.dstKey);
break;
case 'modify':
this._modifyRelation(op.srcKey, op.relation, op.dstKey, op.options);
break;
}
}
}
} finally {
arbiter.batchUpdateInProgress = false;
}
// After the batch completes, update PLTC indices for all touched edges
// (adds and removes both affect reachability).
const reachabilityChecker = arbiter.reachabilityChecker;
if (reachabilityChecker &&
(reachabilityChecker.pltcIndex?.initialized ||
reachabilityChecker.pltcBackwardIndex?.initialized)) {
const edgesToAdd = new Set();
for (const op of updates) {
const srcId = arbiter.nodeIdByKey.get(op.srcKey);
const dstId = arbiter.nodeIdByKey.get(op.dstKey);
if (srcId !== undefined && dstId !== undefined) {
edgesToAdd.add(`${srcId}:${dstId}`);
}
}
reachabilityChecker.beginUpdateBatch();
for (const edgeKey of edgesToAdd) {
const [srcId, dstId] = edgeKey.split(':').map(Number);
this.manager._updatePLTCIndicesOnAdd(srcId, dstId, null);
}
reachabilityChecker.commitUpdateBatch();
}
// The batch path bypasses Arbiter.addRelation/removeRelation, so the
// arbiter-level rule caches (rule result cache, ChainRule caches,