Bounded reconciliation increment
This commit is contained in:
+97
-4
@@ -133,20 +133,84 @@ test('indexed market uses bounded durable event catch-up and never requests full
|
||||
await f.restart(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
|
||||
f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
|
||||
const anchor = f.events[129].eventHash;
|
||||
f.events[129].eventHash = 'f'.repeat(64); f.advance(1001);
|
||||
f.events[129].eventHash = 'f'.repeat(64); f.advance(16_000);
|
||||
assert.throws(() => f.market.browse(), /history changed/);
|
||||
f.events[129].eventHash = anchor;
|
||||
f.events.length = 0;
|
||||
assert.throws(() => f.market.browse(), /cursor ahead/);
|
||||
});
|
||||
|
||||
test('disconnect cursor and payout hold commit together and survive a crash/restart', async t => {
|
||||
test('market reconciliation keyset progress is bounded, durable and resumes after restart', async t => {
|
||||
const f = await fixture(t, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
const ids = Array.from({ length: 260 }, (_, i) => `00000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`);
|
||||
try {
|
||||
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
|
||||
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const listingId = `10000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
|
||||
const cardHash = (i + 1).toString(16).padStart(64, '0');
|
||||
const target = i === ids.length - 1;
|
||||
insertListing.run(listingId, cardHash, target ? 'sold' : 'cancelled', JSON.stringify({ id: listingId, seller: alice.actor,
|
||||
cardHash, sequence: 0, status: target ? 'sold' : 'cancelled', kind: 'fixed' }));
|
||||
const order = target ? { id, termsVersion: 3, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0,
|
||||
priceAtomicXmr: '100000000000', feeBps: 250, createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000,
|
||||
status: 'complete', payout: 'mock-paid', deliveryConfirmations: 2, requiredConfirmations: 2, transferId: '0'.repeat(64) }
|
||||
: { id, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0, status: 'refunded', createdAt: f.now };
|
||||
insertOrder.run(id, JSON.stringify(order));
|
||||
}
|
||||
} finally { db.close(); }
|
||||
const disconnectedTx = transferId({ network: NETWORK, chainId: CHAIN_ID,
|
||||
cardHash: (ids.length).toString(16).padStart(64, '0'), from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' });
|
||||
f.events.push({ sequence: 1, type: 'disconnect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
|
||||
transactionIds: [disconnectedTx], eventHash: 'e'.repeat(64) });
|
||||
|
||||
// First browse starts reconciliation and visits only the first fixed-size keyset page.
|
||||
f.market.browse();
|
||||
const progress = () => {
|
||||
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try { return JSON.parse((conn.prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') as { value: string }).value); }
|
||||
finally { conn.close(); }
|
||||
};
|
||||
assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[127]);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), true);
|
||||
await assert.rejects(f.list(), /catching up/); // second 128-order batch; no new listing is admitted.
|
||||
assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[255]);
|
||||
// The ledger advances while this durable multi-batch job is in flight.
|
||||
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'f'.repeat(64), previousHash: 'd'.repeat(64),
|
||||
transactionIds: [], eventHash: '2'.repeat(64) });
|
||||
f.market.browse(); // Tip/revision changed: rewind and bind the updated event page.
|
||||
assert.equal(progress().eventPage.cursor, 2);
|
||||
assert.equal(progress().afterId, ids[127]);
|
||||
f.market.browse(); // Second order batch of the refreshed scan.
|
||||
f.market.browse(); // Remaining four orders, then at most one listing page.
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 0);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), true, 'new events appended during job must stay behind the work fence');
|
||||
const reviewDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
const row = reviewDb.prepare('SELECT data FROM orders WHERE id=?').get(ids[259]) as { data: string };
|
||||
assert.equal(JSON.parse(row.data).status, 'review'); // Disconnect reached the tail batch before the cursor advances.
|
||||
} finally { reviewDb.close(); }
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 0);
|
||||
|
||||
// Simulate process death after committed batch progress, then resume using the SQLite job cursor.
|
||||
await f.restart(); assert.equal(f.market.diagnostics().eventBacklog, true);
|
||||
for (let i = 0; i < 12 && f.market.hasReconciliationBacklog(); i++) f.market.browse();
|
||||
assert.equal(f.market.hasReconciliationBacklog(), false);
|
||||
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try { assert.equal(conn.prepare('SELECT 1 FROM market_meta WHERE key=?').get('canonical_reconciliation_job'), undefined); }
|
||||
finally { conn.close(); }
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
});
|
||||
|
||||
test('bounded disconnect review batch commits before checkpoint advance and resumes after restart', async t => {
|
||||
const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing);
|
||||
await f.run(bob, { type: 'mock-pay', orderId: order.id });
|
||||
const delivery = f.delivery(order); f.confirm(delivery);
|
||||
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: f.chain.state.blocks.length + 1 });
|
||||
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
|
||||
assert.equal((await f.orders())[0].status, 'complete');
|
||||
const initiallyCompleted = (await f.orders())[0];
|
||||
assert.equal(initiallyCompleted.status, 'complete', JSON.stringify({ order: initiallyCompleted,
|
||||
job: new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')).prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') }));
|
||||
// The same delivery may already be canonical again; the intermediate disconnect must not be lost.
|
||||
f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) });
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
@@ -154,7 +218,9 @@ test('disconnect cursor and payout hold commit together and survive a crash/rest
|
||||
db.exec("CREATE TRIGGER fail_cursor BEFORE UPDATE ON market_meta WHEN NEW.key='canonical_event_cursor' BEGIN SELECT RAISE(ABORT,'injected cursor failure'); END");
|
||||
assert.throws(() => f.market.browse(), /injected cursor failure/);
|
||||
const persisted = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string };
|
||||
assert.equal(JSON.parse(persisted.data).status, 'complete');
|
||||
assert.equal(JSON.parse(persisted.data).status, 'review');
|
||||
const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value);
|
||||
assert.equal(job.stage, 'listings'); // Order effects and work cursor committed atomically; event anchor still has not advanced.
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
|
||||
db.exec('DROP TRIGGER fail_cursor');
|
||||
} finally { db.close(); }
|
||||
@@ -165,6 +231,33 @@ test('disconnect cursor and payout hold commit together and survive a crash/rest
|
||||
await f.restart(); assert.equal((await f.orders())[0].status, 'review');
|
||||
});
|
||||
|
||||
test('market reconciliation batch failure rolls back order effects and resumes from durable keyset', async t => {
|
||||
const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing);
|
||||
await f.run(bob, { type: 'mock-pay', orderId: order.id });
|
||||
const delivery = f.delivery(order); f.confirm(delivery);
|
||||
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
|
||||
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64),
|
||||
transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
|
||||
assert.equal((await f.orders())[0].status, 'complete');
|
||||
f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64),
|
||||
transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) });
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
db.exec("CREATE TRIGGER fail_review_batch BEFORE UPDATE ON orders WHEN json_extract(NEW.data,'$.status')='review' BEGIN SELECT RAISE(ABORT,'injected reconciliation batch failure'); END");
|
||||
assert.throws(() => f.market.browse(), /injected reconciliation batch failure/);
|
||||
const row = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string };
|
||||
assert.equal(JSON.parse(row.data).status, 'complete');
|
||||
const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value);
|
||||
assert.equal(job.stage, 'orders'); assert.equal(job.afterId, null);
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
|
||||
db.exec('DROP TRIGGER fail_review_batch');
|
||||
} finally { db.close(); }
|
||||
await f.restart();
|
||||
assert.equal((await f.orders())[0].status, 'review');
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), false);
|
||||
});
|
||||
|
||||
test('USD and mock XMR accounting use bounded integer amounts and exact fee splitting', () => {
|
||||
assert.equal(parseUsd('15.01'), 1501); assert.equal(parseUsd('0.01'), 1);
|
||||
for (const invalid of ['0', '-1', '1e3', '1.001', ' 1', 'NaN', '1000001', '01.00']) assert.throws(() => parseUsd(invalid));
|
||||
|
||||
Reference in New Issue
Block a user