openvibers/openvibe.vip Commits
A published plan and a membership change commit only with their events: outbox.emit() in plans.publish and in the entitlement projection was not awaited (VIP's emit wraps the async PostgreSQL enqueue, so the codemod could not see it), so a failed event write was swallowed and the change committed without its event. Both are awaited; tests make the event write fail and assert the change rolls back (they reject emit rather than add a constraint, so they also run as the DML-only role under test:pg). Found by the estate-wide floating-promise sweep, fixed and reviewed independently (VERDICT: SHIP). npm test and test:pg 10/10. npm test 10/10 test files passed.
Changes
@@@ -35,7 +35,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plaasync function emitChanged(row, prev, { reason, membership }, traceparent) {const v = membership && membership.plan_version_id ? await plans.version(membership.plan_version_id) : null;-- outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {++ await outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,previous: prev ? { active: !!prev.active, expires_at: prev.expires_at, cancel_at_period_end: !!prev.cancel_at_period_end } : null,@@@ -53,7 +53,7 @@ function createPlans({ db, now, outbox, creators, perks }) {async function emitPublished(plan, v, traceparent) {const creator = await creators.byId(plan.creator_id);-- outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {++ await outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,creator: creators.present(creator), billing_kind: plan.billing_kind,terms: json(v.terms, {}), published_at: v.published_at,@@@ -260,5 +260,25 @@ const MIN = 60_000;assert.strictEqual(byName.status, 200, byName.text);});++ test('a failed outbox write fails the delivery: the projection commits only with its event', async () => {++ const m = t.network.newUser('m19');++ const ent = t.billing.pay(m.subject, creator.subject).events[1];++ // Neither is a change if the event is not written with it: the outbox INSERT must fail the transaction.++ // The event write fails (a rejecting emit, as a failed INSERT would): no DDL, so this also runs as the DML-only role under test:pg.++ const ob = t.app.locals.outbox, realEmit = ob.emit;++ ob.emit = async () => { throw new Error('outbox write failed (test)'); };++ try {++ const res = await t.deliver(ent);++ assert.strictEqual(res.status, 500, `the delivery must not succeed without its event: ${JSON.stringify(res.json)}`);++ assert.strictEqual(res.json.code, 'vip.event_failed');++ assert.strictEqual(await t.domain.entitlements.getRow(m.subject, creator.subject), null, 'the projection rolled back with the event');++ } finally {++ ob.emit = realEmit;++ }++ const ok = await t.deliver(ent); // the inbox claim rolled back too, so Events' retry applies it++ assert.strictEqual(ok.json.outcome, 'changed');++ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject).length, 1);++ });++await run().finally(async () => await t.close());})();@@@ -163,5 +163,27 @@ const { test, run } = harness('plans');assert.strictEqual(own.status, 200);});++ test('a failed outbox write fails the publish: the plan commits only with its event', async () => {++ const fresh = t.network.newUser('erin', { role: 'streamer' });++ // Neither is a change if the event is not written with it: the outbox INSERT must fail the transaction.++ // The event write fails (a rejecting emit, as a failed INSERT would): no DDL, so this also runs as the DML-only role under test:pg.++ const ob = t.app.locals.outbox, realEmit = ob.emit;++ ob.emit = async () => { throw new Error('outbox write failed (test)'); };++ let cid;++ try {++ const res = await t.call('POST', '/api/v1/plans', { user: fresh, body: { name: 'Doomed', publish: true } });++ assert.strictEqual(res.status, 500, `the publish must not succeed without its event: ${res.text}`);++ cid = (await t.domain.creators.bySubject(fresh.subject)).id;++ assert.deepStrictEqual(await t.domain.plans.list({ creatorId: cid, includeDrafts: true, includeArchived: true }), [], 'the plan rolled back with the event');++ } finally {++ ob.emit = realEmit;++ }++ const ok = await t.call('POST', '/api/v1/plans', { user: fresh, body: { name: 'Doomed', publish: true } });++ assert.strictEqual(ok.status, 201, ok.text);++ const ev = (await t.outboxEvents('vip.plan.published')).filter((e) => e.subject.id === ok.json.plan.id);++ assert.strictEqual(ev.length, 1);++ assert.strictEqual(ev[0].payload.version, 1);++ });++await run().finally(async () => await t.close());})();
@@ -35,7 +35,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -35,7 +35,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
async function emitChanged(row, prev, { reason, membership }, traceparent) {
async function emitChanged(row, prev, { reason, membership }, traceparent) {
const v = membership && membership.plan_version_id ? await plans.version(membership.plan_version_id) : null;
const v = membership && membership.plan_version_id ? await plans.version(membership.plan_version_id) : null;
- outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {
+ await outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
previous: prev ? { active: !!prev.active, expires_at: prev.expires_at, cancel_at_period_end: !!prev.cancel_at_period_end } : null,
previous: prev ? { active: !!prev.active, expires_at: prev.expires_at, cancel_at_period_end: !!prev.cancel_at_period_end } : null,
@@ -53,7 +53,7 @@ function createPlans({ db, now, outbox, creators, perks }) {
@@ -53,7 +53,7 @@ function createPlans({ db, now, outbox, creators, perks }) {
async function emitPublished(plan, v, traceparent) {
async function emitPublished(plan, v, traceparent) {
const creator = await creators.byId(plan.creator_id);
const creator = await creators.byId(plan.creator_id);
- outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {
+ await outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {
plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,
plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,
creator: creators.present(creator), billing_kind: plan.billing_kind,
creator: creators.present(creator), billing_kind: plan.billing_kind,
terms: json(v.terms, {}), published_at: v.published_at,
terms: json(v.terms, {}), published_at: v.published_at,
@@ -260,5 +260,25 @@ const MIN = 60_000;
@@ -260,5 +260,25 @@ const MIN = 60_000;
assert.strictEqual(byName.status, 200, byName.text);
assert.strictEqual(byName.status, 200, byName.text);
});
});
+ test('a failed outbox write fails the delivery: the projection commits only with its event', async () => {
+ const m = t.network.newUser('m19');
+ const ent = t.billing.pay(m.subject, creator.subject).events[1];
+ // Neither is a change if the event is not written with it: the outbox INSERT must fail the transaction.
+ // The event write fails (a rejecting emit, as a failed INSERT would): no DDL, so this also runs as the DML-only role under test:pg.
+ const ob = t.app.locals.outbox, realEmit = ob.emit;
+ ob.emit = async () => { throw new Error('outbox write failed (test)'); };
+ try {
+ const res = await t.deliver(ent);
+ assert.strictEqual(res.status, 500, `the delivery must not succeed without its event: ${JSON.stringify(res.json)}`);
+ assert.strictEqual(res.json.code, 'vip.event_failed');
+ assert.strictEqual(await t.domain.entitlements.getRow(m.subject, creator.subject), null, 'the projection rolled back with the event');
+ } finally {
+ ob.emit = realEmit;
+ }
+ const ok = await t.deliver(ent); // the inbox claim rolled back too, so Events' retry applies it
+ assert.strictEqual(ok.json.outcome, 'changed');
+ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject).length, 1);
+ });
+
await run().finally(async () => await t.close());
await run().finally(async () => await t.close());
})();
})();
@@ -163,5 +163,27 @@ const { test, run } = harness('plans');
@@ -163,5 +163,27 @@ const { test, run } = harness('plans');
assert.strictEqual(own.status, 200);
assert.strictEqual(own.status, 200);
});
});
+ test('a failed outbox write fails the publish: the plan commits only with its event', async () => {
+ const fresh = t.network.newUser('erin', { role: 'streamer' });
+ // Neither is a change if the event is not written with it: the outbox INSERT must fail the transaction.
+ // The event write fails (a rejecting emit, as a failed INSERT would): no DDL, so this also runs as the DML-only role under test:pg.
+ const ob = t.app.locals.outbox, realEmit = ob.emit;
+ ob.emit = async () => { throw new Error('outbox write failed (test)'); };
+ let cid;
+ try {
+ const res = await t.call('POST', '/api/v1/plans', { user: fresh, body: { name: 'Doomed', publish: true } });
+ assert.strictEqual(res.status, 500, `the publish must not succeed without its event: ${res.text}`);
+ cid = (await t.domain.creators.bySubject(fresh.subject)).id;
+ assert.deepStrictEqual(await t.domain.plans.list({ creatorId: cid, includeDrafts: true, includeArchived: true }), [], 'the plan rolled back with the event');
+ } finally {
+ ob.emit = realEmit;
+ }
+ const ok = await t.call('POST', '/api/v1/plans', { user: fresh, body: { name: 'Doomed', publish: true } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ const ev = (await t.outboxEvents('vip.plan.published')).filter((e) => e.subject.id === ok.json.plan.id);
+ assert.strictEqual(ev.length, 1);
+ assert.strictEqual(ev[0].payload.version, 1);
+ });
+
await run().finally(async () => await t.close());
await run().finally(async () => await t.close());
})();
})();