Moved reconciliation row selection and order/listing status evaluation into the worker thread
This commit is contained in:
@@ -18,22 +18,102 @@ function assertCurrentJob(expected) {
|
||||
throw new Error('Reconciliation job changed before worker commit');
|
||||
}
|
||||
}
|
||||
function rows(stage, afterId, limit) {
|
||||
const table = stage === 'orders' ? 'orders' : 'listings';
|
||||
return db.prepare(`SELECT id,data FROM ${table} WHERE (? IS NULL OR id>?) ORDER BY id LIMIT ?`)
|
||||
.all(afterId, afterId, limit);
|
||||
}
|
||||
function validCard(facts, cardHash, owner, sequence, maturity) {
|
||||
const nft = facts.nfts[cardHash];
|
||||
return !!nft && nft.owner === owner && nft.sequence === sequence && nft.confirmations >= maturity;
|
||||
}
|
||||
function deliveryId(request, order) { return request.deliveryIds[order.id]; }
|
||||
function processOrders(request, selected) {
|
||||
const disconnected = new Set(request.disconnectedIds);
|
||||
const pendingIds = new Set(request.pendingIds);
|
||||
const orders = [], listings = [];
|
||||
for (const row of selected) {
|
||||
const order = JSON.parse(row.data);
|
||||
const listingRow = db.prepare('SELECT data FROM listings WHERE id=?').get(order.listingId);
|
||||
if (!listingRow) throw new Error('Order listing missing during reconciliation');
|
||||
const listing = JSON.parse(listingRow.data);
|
||||
const id = deliveryId(request, order), delivery = request.deliveryTransactions[id];
|
||||
if (order.status === 'complete' && disconnected.has(id)) {
|
||||
order.status = 'review'; order.payout = 'review';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'review';
|
||||
}
|
||||
if (order.status === 'refunded' || order.status === 'expired') continue;
|
||||
const confirmations = delivery ? request.height - delivery.height + 1 : 0;
|
||||
order.deliveryConfirmations = confirmations;
|
||||
if (order.status === 'complete' || order.status === 'review') {
|
||||
if (!delivery || confirmations < order.requiredConfirmations) {
|
||||
order.status = 'review'; order.payout = 'review';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'review';
|
||||
}
|
||||
orders.push(order);
|
||||
continue;
|
||||
}
|
||||
if (delivery && order.status === 'awaiting-payment') {
|
||||
order.status = 'review'; order.payout = 'review';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'review';
|
||||
} else if (delivery) {
|
||||
order.transferId = id; order.status = 'delivery-pending';
|
||||
if (confirmations >= order.requiredConfirmations) {
|
||||
order.status = 'complete'; order.payout = 'mock-paid'; listing.status = 'sold';
|
||||
if (order.additionalFee) order.additionalFee.payout = 'mock-paid';
|
||||
}
|
||||
} else if (order.status === 'awaiting-payment' && request.expectedJob.observedAt >= order.payBy) {
|
||||
order.status = 'expired'; listing.status = listing.kind === 'fixed' ? 'open' : 'expired';
|
||||
} else if (order.status !== 'awaiting-payment') {
|
||||
const pending = pendingIds.has(id);
|
||||
const conflict = request.pendingTransactions.some(tx => tx.cardIds.includes(order.cardHash) && tx.id !== id);
|
||||
if (!validCard(request, order.cardHash, order.seller, order.sequence, request.maturity) ||
|
||||
conflict || (request.expectedJob.observedAt >= order.deliverBy && !pending)) order.status = 'refund-required';
|
||||
else if (order.status === 'delivery-pending' && !pending) order.status = 'paid';
|
||||
}
|
||||
orders.push(order);
|
||||
listings.push(listing);
|
||||
}
|
||||
return { orders, listings };
|
||||
}
|
||||
function processListings(request, selected) {
|
||||
const pendingCards = new Set(request.pendingCardIds), listings = [];
|
||||
for (const row of selected) {
|
||||
const listing = JSON.parse(row.data);
|
||||
if (listing.status !== 'open') continue;
|
||||
if (listing.additionalFee?.bps !== request.additionalFee.bps || listing.additionalFee?.address !== request.additionalFee.address ||
|
||||
!validCard(request, listing.cardHash, listing.seller, listing.sequence, request.maturity) || pendingCards.has(listing.cardHash)) {
|
||||
listing.status = 'invalid';
|
||||
} else if (listing.kind === 'auction' && request.expectedJob.observedAt >= listing.endsAt + request.paymentWindow) {
|
||||
listing.status = 'expired';
|
||||
} else if (listing.kind === 'auction' && request.expectedJob.observedAt >= listing.endsAt && !listing.highestBid) {
|
||||
listing.status = 'expired';
|
||||
}
|
||||
listings.push(listing);
|
||||
}
|
||||
return { orders: [], listings };
|
||||
}
|
||||
function saveResults(result) {
|
||||
const order = db.prepare(`INSERT INTO orders(id,data) VALUES(?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data`);
|
||||
for (const item of result.orders) order.run(item.id, JSON.stringify(item));
|
||||
const listing = db.prepare(`INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data`);
|
||||
for (const item of result.listings) listing.run(item.id, item.cardHash, item.status, JSON.stringify(item));
|
||||
}
|
||||
|
||||
port.on('message', request => {
|
||||
try {
|
||||
if (request.operation === 'persist-batch') {
|
||||
if (request.operation === 'reconcile-batch') {
|
||||
atomic(() => {
|
||||
assertCurrentJob(request.expectedJob);
|
||||
const order = db.prepare(`INSERT INTO orders(id,data) VALUES(?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data`);
|
||||
for (const row of request.orders) order.run(row.id, row.data);
|
||||
const listing = db.prepare(`INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data`);
|
||||
for (const row of request.listings) listing.run(row.id, row.card, row.status, row.data);
|
||||
const selected = rows(request.stage, request.afterId, request.batchSize);
|
||||
const result = request.stage === 'orders' ? processOrders(request, selected) : processListings(request, selected);
|
||||
saveResults(result);
|
||||
db.prepare("UPDATE market_meta SET value=? WHERE key='canonical_reconciliation_job'")
|
||||
.run(JSON.stringify(request.nextJob));
|
||||
});
|
||||
} else {
|
||||
} else if (request.operation === 'finalize') {
|
||||
atomic(() => {
|
||||
assertCurrentJob(request.expectedJob);
|
||||
db.prepare('DELETE FROM receipts WHERE expires < ?').run(request.observedAt - 86400_000);
|
||||
@@ -43,7 +123,7 @@ port.on('message', request => {
|
||||
}
|
||||
db.prepare("DELETE FROM market_meta WHERE key='canonical_reconciliation_job'").run();
|
||||
});
|
||||
}
|
||||
} else throw new Error('Unknown reconciliation worker operation');
|
||||
port.postMessage({ id: request.id, ok: true });
|
||||
} catch (error) {
|
||||
port.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : 'Worker batch failed' });
|
||||
|
||||
+65
-50
@@ -71,8 +71,6 @@ export class Market {
|
||||
private readonly workerRequests = new Map<number, { resolve(): void; reject(error: Error): void }>();
|
||||
private reconciliationWorkerBusy = false;
|
||||
private reconciliationWorkerError: string | undefined;
|
||||
private stagedReconciliationWrites: { orders: Map<string, MarketOrderRecord>;
|
||||
listings: Map<string, MarketListingRecord> } | undefined;
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly chain: Chain;
|
||||
readonly config: Config;
|
||||
@@ -151,6 +149,7 @@ export class Market {
|
||||
this.db.close();
|
||||
}
|
||||
isReconciliationWorkerBusy(): boolean { return this.reconciliationWorkerBusy; }
|
||||
isReconciliationWorkerFailed(): boolean { return !!this.reconciliationWorkerError; }
|
||||
diagnostics() {
|
||||
const states=this.db.prepare("SELECT json_extract(data,'$.status') AS status,COUNT(*) AS count FROM orders GROUP BY json_extract(data,'$.status')").all();
|
||||
return { queued:this.queued,lastReconciledAt:this.reconciled?.at??null,orderStates:states,
|
||||
@@ -179,18 +178,10 @@ export class Market {
|
||||
return JSON.parse(row.data) as MarketOrderRecord;
|
||||
}
|
||||
private saveListing(l: MarketListingRecord) {
|
||||
if (this.stagedReconciliationWrites) {
|
||||
this.stagedReconciliationWrites.listings.set(l.id, l);
|
||||
return;
|
||||
}
|
||||
this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data')
|
||||
.run(l.id, l.cardHash, l.status, JSON.stringify(l));
|
||||
}
|
||||
private saveOrder(o: MarketOrderRecord) {
|
||||
if (this.stagedReconciliationWrites) {
|
||||
this.stagedReconciliationWrites.orders.set(o.id, o);
|
||||
return;
|
||||
}
|
||||
this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data').run(o.id, JSON.stringify(o));
|
||||
}
|
||||
private atomic<T>(fn: () => T): T {
|
||||
@@ -237,21 +228,6 @@ export class Market {
|
||||
private scheduleWorkerContinuation(): void {
|
||||
setImmediate(() => { if (!this.reconciliationWorkerBusy && this.hasReconciliationBacklog()) this.reconcile(); }).unref();
|
||||
}
|
||||
private persistWorkerBatch(job: z.infer<typeof reconciliationJobSchema>, nextJob: z.infer<typeof reconciliationJobSchema>,
|
||||
writes: { orders: Map<string, MarketOrderRecord>; listings: Map<string, MarketListingRecord> }): void {
|
||||
if (this.reconciliationWorkerBusy) return;
|
||||
this.reconciliationWorkerBusy = true;
|
||||
void this.workerRequest({ operation: 'persist-batch', expectedJob: job, nextJob,
|
||||
orders: [...writes.orders.values()].map(order => ({ id: order.id, data: JSON.stringify(order) })),
|
||||
listings: [...writes.listings.values()].map(listing => ({ id: listing.id, card: listing.cardHash,
|
||||
status: listing.status, data: JSON.stringify(listing) })) })
|
||||
.then(() => { this.reconciliationJob = nextJob; this.reconciliationWorkerError = undefined; this.eventBacklog = true; })
|
||||
.catch(error => { this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker commit failed'; this.eventBacklog = true; })
|
||||
.finally(() => {
|
||||
this.reconciliationWorkerBusy = false;
|
||||
if (!this.reconciliationWorkerError) this.scheduleWorkerContinuation();
|
||||
});
|
||||
}
|
||||
private finalizeWorkerJob(job: z.infer<typeof reconciliationJobSchema>): void {
|
||||
const summary = this.chain.summary?.(), eventPage = job.eventPage as CanonicalEvents | null;
|
||||
const currentHead = this.chain.canonicalHead?.() ?? eventPage?.head ?? 0;
|
||||
@@ -292,9 +268,61 @@ export class Market {
|
||||
if (!this.reconciliationWorkerError && this.hasReconciliationBacklog()) this.scheduleWorkerContinuation();
|
||||
});
|
||||
}
|
||||
private dispatchWorkerBatch(job: z.infer<typeof reconciliationJobSchema>): void {
|
||||
if (!this.reconciliationWorker || this.reconciliationWorkerBusy || job.stage === 'finalize') return;
|
||||
this.reconciliationWorkerBusy = true;
|
||||
try {
|
||||
const rows = this.nextReconciliationRows(job.stage, job.afterId);
|
||||
const s = this.view();
|
||||
const pendingTransactions = s.pendingTransfers.map(tx => ({ id: transactionId(tx), cardIds: transactionCardIds(tx) }));
|
||||
const pendingIds = pendingTransactions.map(tx => tx.id);
|
||||
const pendingCardIds = [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))];
|
||||
const deliveryIds: Record<string, string> = Object.create(null);
|
||||
const targetCards = new Set<string>();
|
||||
if (job.stage === 'orders') {
|
||||
for (const row of rows) {
|
||||
const order = JSON.parse(row.data) as MarketOrderRecord;
|
||||
deliveryIds[order.id] = this.deliveryId(order);
|
||||
targetCards.add(order.cardHash);
|
||||
}
|
||||
} else {
|
||||
for (const row of rows) targetCards.add((JSON.parse(row.data) as MarketListingRecord).cardHash);
|
||||
}
|
||||
const deliveryTransactions: Record<string, { height: number; blockHash: string } | null> = Object.create(null);
|
||||
const nfts: Record<string, { owner: string; sequence: number; confirmations: number } | null> = Object.create(null);
|
||||
if (job.stage === 'orders') for (const id of Object.values(deliveryIds)) deliveryTransactions[id] = s.transaction(id) ?? null;
|
||||
for (const card of targetCards) {
|
||||
const nft = s.nft(card);
|
||||
nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: nft.confirmations } : null;
|
||||
}
|
||||
const disconnectedIds = job.eventPage?.events.filter(event => event.type === 'disconnect')
|
||||
.flatMap(event => event.transactionIds) ?? [];
|
||||
const nextJob = job.stage === 'orders'
|
||||
? rows.length < MARKET_RECONCILIATION_BATCH_SIZE ? { ...job, stage: 'listings' as const, afterId: null }
|
||||
: { ...job, afterId: rows.at(-1)!.id }
|
||||
: rows.length < MARKET_RECONCILIATION_BATCH_SIZE ? { ...job, stage: 'finalize' as const, afterId: null }
|
||||
: { ...job, afterId: rows.at(-1)!.id };
|
||||
void this.workerRequest({ operation: 'reconcile-batch', expectedJob: job, nextJob, stage: job.stage,
|
||||
afterId: job.afterId, batchSize: MARKET_RECONCILIATION_BATCH_SIZE, deliveryIds,
|
||||
deliveryTransactions, nfts, pendingIds, pendingCardIds, disconnectedIds, height: s.height,
|
||||
pendingTransactions,
|
||||
maturity: CONSENSUS.coinbaseMaturity, additionalFee: ADDITIONAL_MARKET_FEE,
|
||||
paymentWindow: PAYMENT_WINDOW })
|
||||
.then(() => { this.reconciliationJob = nextJob; this.reconciliationWorkerError = undefined; this.eventBacklog = true; })
|
||||
.catch(error => { this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker reconciliation failed'; this.eventBacklog = true; })
|
||||
.finally(() => {
|
||||
this.reconciliationWorkerBusy = false;
|
||||
if (!this.reconciliationWorkerError) this.scheduleWorkerContinuation();
|
||||
});
|
||||
} catch (error) {
|
||||
this.reconciliationWorkerBusy = false;
|
||||
this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker reconciliation preparation failed';
|
||||
this.eventBacklog = true;
|
||||
}
|
||||
}
|
||||
/** Reconcile one durable keyset batch from canonical transaction identity, never mempool acknowledgement. */
|
||||
private reconcile() {
|
||||
if (this.reconciliationWorkerBusy) return;
|
||||
if (this.reconciliationWorkerBusy || this.reconciliationWorkerError) return;
|
||||
const now = this.now();
|
||||
const summary = this.chain.summary?.();
|
||||
if (!this.reconciliationJob) {
|
||||
@@ -344,17 +372,14 @@ export class Market {
|
||||
if (current && !this.eventBacklog) this.reconciled = { ...current, at: now };
|
||||
return;
|
||||
}
|
||||
if (this.reconciliationWorker) { this.dispatchWorkerBatch(job); return; }
|
||||
const s = this.view(), pendingIds = new Set(s.pendingTransfers.map(transactionId));
|
||||
const eventPage = job.eventPage as CanonicalEvents | null;
|
||||
const disconnected = new Set(eventPage?.events.filter(e => e.type === 'disconnect').flatMap(e => e.transactionIds) ?? []);
|
||||
const rows = this.nextReconciliationRows(job.stage, job.afterId);
|
||||
let nextJob: z.infer<typeof reconciliationJobSchema> | undefined;
|
||||
let completed = false;
|
||||
const workerWrites = this.reconciliationWorker ? { orders: new Map<string, MarketOrderRecord>(),
|
||||
listings: new Map<string, MarketListingRecord>() } : undefined;
|
||||
this.stagedReconciliationWrites = workerWrites;
|
||||
|
||||
try { this.atomic(() => {
|
||||
this.atomic(() => {
|
||||
if (job.stage === 'orders' && rows.length) {
|
||||
for (const row of rows) {
|
||||
const o = JSON.parse(row.data) as MarketOrderRecord;
|
||||
@@ -413,28 +438,18 @@ export class Market {
|
||||
else nextJob = { ...job, afterId: rows.at(-1)!.id };
|
||||
} else {
|
||||
if (rows.length < MARKET_RECONCILIATION_BATCH_SIZE) {
|
||||
if (this.reconciliationWorker) nextJob = { ...job, stage: 'finalize', afterId: null };
|
||||
else {
|
||||
this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(job.observedAt - 86400_000);
|
||||
if (eventPage) {
|
||||
this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
||||
.run('canonical_event_cursor', JSON.stringify({ version: 1, streamId: eventPage.streamId,
|
||||
cursor: eventPage.cursor, anchor: eventPage.anchor }));
|
||||
}
|
||||
this.db.prepare('DELETE FROM market_meta WHERE key=?').run(RECONCILIATION_JOB_KEY);
|
||||
completed = true;
|
||||
this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(job.observedAt - 86400_000);
|
||||
if (eventPage) {
|
||||
this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
||||
.run('canonical_event_cursor', JSON.stringify({ version: 1, streamId: eventPage.streamId,
|
||||
cursor: eventPage.cursor, anchor: eventPage.anchor }));
|
||||
}
|
||||
this.db.prepare('DELETE FROM market_meta WHERE key=?').run(RECONCILIATION_JOB_KEY);
|
||||
completed = true;
|
||||
} else nextJob = { ...job, afterId: rows.at(-1)!.id };
|
||||
}
|
||||
if (nextJob && !this.reconciliationWorker) this.db.prepare('UPDATE market_meta SET value=? WHERE key=?').run(JSON.stringify(nextJob), RECONCILIATION_JOB_KEY);
|
||||
}); } finally { this.stagedReconciliationWrites = undefined; }
|
||||
|
||||
if (this.reconciliationWorker) {
|
||||
if (!nextJob) throw new Error('Worker reconciliation batch did not produce a continuation');
|
||||
if (!workerWrites) throw new Error('Worker reconciliation writes were not staged');
|
||||
this.persistWorkerBatch(job, nextJob, workerWrites);
|
||||
return;
|
||||
}
|
||||
if (nextJob) this.db.prepare('UPDATE market_meta SET value=? WHERE key=?').run(JSON.stringify(nextJob), RECONCILIATION_JOB_KEY);
|
||||
});
|
||||
|
||||
if (completed) {
|
||||
this.reconciliationJob = undefined;
|
||||
|
||||
Reference in New Issue
Block a user