Skip to content

Commit 88ce363

Browse files
committed
fix: preserve transfer-staging rows across periodic truncate
The periodic truncateStagingV2 task in src/tasks/sync-registries-v2.js destroys any row with committed: true, which races with project transfers (ProjectV2.transfer stages committed: true + is_transfer: true). The is_transfer row must survive until OfferV2.generateOfferFile reads it, or until the offer flow explicitly clears it. The race caused the project-v2 integration test "should transfer a project successfully" to flake on CI. Exclude is_transfer rows from the periodic truncate in both v2 and the v1 simulator branch. To avoid a permanent-row-leak regression (once the truncate no longer cleans up), make OfferV2.cancelActiveOffer destroy the is_transfer staging row in parallel with the activeOfferTradeId meta row, mirroring the existing v1 offer.controller.cancelActiveOffer contract. Adds a regression test that exercises the new cleanup contract end-to-end on DELETE /v2/offer.
1 parent 269ba5f commit 88ce363

4 files changed

Lines changed: 70 additions & 9 deletions

File tree

src/models/v2/offer-v2.model.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -431,12 +431,16 @@ class OfferV2 {
431431
const tradeId = tradeIdRecord.meta_value;
432432
await datalayerPersistance.cancelOffer(tradeId);
433433

434-
// Remove trade ID from MetaV2
435-
await MetaV2.destroy({
436-
where: {
437-
meta_key: 'activeOfferTradeId',
438-
},
439-
});
434+
// Clear the active offer trade ID and the transfer-staging marker in
435+
// parallel, mirroring the v1 offer.controller.cancelActiveOffer flow.
436+
// The transfer-staging row (committed: true, is_transfer: true) is
437+
// preserved by the periodic sync-registries-v2 truncate on purpose so
438+
// that GET /v2/offer can read it between transfer-stage and offer
439+
// consumption; it must be cleaned up here when the offer is cancelled.
440+
await Promise.all([
441+
MetaV2.destroy({ where: { meta_key: 'activeOfferTradeId' } }),
442+
StagingV2.destroy({ where: { is_transfer: true } }),
443+
]);
440444
} catch (error) {
441445
loggerV2.error('[v2]: Error canceling active offer:', error);
442446
throw new Error(error.message);

src/tasks/sync-registries-v2.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,17 @@ const truncateStagingV2 = async () => {
9797

9898
while (!success && attempts < maxAttempts) {
9999
try {
100-
// Only delete records that have been marked as committed
101-
// This prevents deleting newly staged records that haven't been committed yet
100+
// Only delete records that have been marked as committed.
101+
// This prevents deleting newly staged records that haven't been committed yet.
102+
// Exclude is_transfer records: project transfers are staged with
103+
// committed: true + is_transfer: true and must persist until the offer
104+
// flow consumes them (see staging-v2.model generateOfferFile and the
105+
// offer controller). They are cleaned up explicitly by the offer flow,
106+
// not by this periodic task.
102107
await StagingV2.destroy({
103108
where: {
104109
committed: true,
110+
is_transfer: false,
105111
},
106112
});
107113
success = true; // If destroy succeeds, set success to true to exit the loop

src/tasks/sync-registries.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,20 @@ const truncateStaging = async () => {
215215
while (!success && attempts < maxAttempts) {
216216
try {
217217
if (CONFIG.USE_SIMULATOR) {
218-
await Staging.destroy({ where: { commited: true } });
218+
// Simulator-mode cleanup: delete only committed non-transfer
219+
// records. Project transfers are staged with commited: true +
220+
// isTransfer: true and must persist until the offer flow consumes
221+
// them (see Staging.generateOfferFile and
222+
// offer.controller.cancelActiveOffer, which destroys the
223+
// isTransfer row on cancel). Excluding them here matches the
224+
// existing v1 cancelActiveOffer contract.
225+
//
226+
// The non-simulator branch below still calls truncate()
227+
// unconditionally; that pre-existing race on the v1 transfer
228+
// flow is out of scope for this change.
229+
await Staging.destroy({
230+
where: { commited: true, isTransfer: false },
231+
});
219232
} else {
220233
await Staging.truncate();
221234
}

tests/v2/integration/offer-v2.spec.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,44 @@ describe('Phase 18.4: OfferV2 Integration Tests', function () {
380380
}
381381
});
382382
});
383+
384+
it('should remove activeOfferTradeId meta and is_transfer staging row on successful cancel', async function () {
385+
// Regression guard: cancelActiveOffer must clear both MetaV2.activeOfferTradeId
386+
// and the is_transfer staging marker, since the periodic truncateStagingV2
387+
// task deliberately preserves is_transfer rows (see src/tasks/sync-registries-v2.js).
388+
await MetaV2.destroy({ where: {} });
389+
await resetV2StagingTable();
390+
391+
await MetaV2.create({
392+
meta_key: 'activeOfferTradeId',
393+
meta_value: 'test-trade-id-cleanup',
394+
});
395+
396+
await StagingV2.create({
397+
uuid: testProject.cadTrustProjectId,
398+
table: 'project',
399+
action: 'UPDATE',
400+
data: JSON.stringify([
401+
{ cad_trust_project_id: testProject.cadTrustProjectId },
402+
]),
403+
committed: true,
404+
failed_commit: false,
405+
is_transfer: true,
406+
});
407+
408+
const response = await supertest(app).delete('/v2/offer').expect(200);
409+
expect(response.body).to.have.property('success', true);
410+
411+
const remainingTradeIdMeta = await MetaV2.findOne({
412+
where: { meta_key: 'activeOfferTradeId' },
413+
});
414+
expect(remainingTradeIdMeta, 'activeOfferTradeId meta should be cleared').to.be.null;
415+
416+
const remainingTransferRows = await StagingV2.count({
417+
where: { is_transfer: true },
418+
});
419+
expect(remainingTransferRows, 'is_transfer staging row should be cleared').to.equal(0);
420+
});
383421
});
384422

385423
describe('POST /v2/offer/accept/commit - Commit imported offer', function () {

0 commit comments

Comments
 (0)