Skip to content

Commit 9e4689c

Browse files
committed
feat(api): dual-write to unified events collection for visit/mark/assignee
- Add src/events/unified/dualWrite.js (visitEventUnified, toggleEventMarkUnified, updateAssigneeUnified) - Add hawk_dual_write_failures_total metric - eventsFactory: call dual-write after visitEvent, toggleEventMark, updateAssignee - Update .env.sample with USE_UNIFIED_EVENTS_COLLECTIONS - Update docs/METRICS.md Made-with: Cursor
1 parent c4d0111 commit 9e4689c

File tree

7 files changed

+167
-2
lines changed

7 files changed

+167
-2
lines changed

.env.sample

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ INVITE_LINK_HASH_SALT=secret_hash_salt
3232
# Option to enable playground (if true playground will be available at /graphql route)
3333
PLAYGROUND_ENABLE=false
3434

35+
# If true, dual-write to unified events/repetitions collections (events, repetitions with projectId)
36+
USE_UNIFIED_EVENTS_COLLECTIONS=false
37+
3538
# AMQP URL
3639
AMQP_URL=amqp://guest:guest@rabbitmq
3740

docs/METRICS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ Labels:
135135

136136
**Purpose**: Track transient or persistent database errors.
137137

138+
#### hawk_dual_write_failures_total (Counter)
139+
140+
Counter of dual-write failures to unified events/repetitions collections.
141+
142+
Labels:
143+
- `type` - `events` | `repetitions`
144+
145+
**Purpose**: Track failures when writing to unified collections (when `USE_UNIFIED_EVENTS_COLLECTIONS=true`). Used for monitoring dual-write reliability during migration.
146+
138147
## Testing
139148

140149
### Manual Testing

src/events/unified/dualWrite.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Dual-write to unified events collection (API layer)
3+
*
4+
* When USE_UNIFIED_EVENTS_COLLECTIONS=true, duplicates updates to the unified
5+
* events collection. Errors are logged, not thrown.
6+
*
7+
* @see docs/mongodb-unified-collections/
8+
*/
9+
10+
const { ObjectId } = require('mongodb');
11+
const { incrementDualWriteFailure } = require('../../metrics/dualWrite');
12+
13+
const EVENTS_COLLECTION = 'events';
14+
15+
function isUnifiedEnabled() {
16+
return process.env.USE_UNIFIED_EVENTS_COLLECTIONS === 'true';
17+
}
18+
19+
function getEventsDb() {
20+
const mongo = require('../../mongo');
21+
return mongo.databases?.events;
22+
}
23+
24+
/**
25+
* Add user to visitedBy in unified events collection
26+
*
27+
* @param {string} projectId - project ObjectId
28+
* @param {string} eventId - event ObjectId
29+
* @param {string} userId - user ObjectId
30+
*/
31+
async function visitEventUnified(projectId, eventId, userId) {
32+
if (!isUnifiedEnabled()) return;
33+
34+
try {
35+
const db = getEventsDb();
36+
if (!db) return;
37+
38+
await db.collection(EVENTS_COLLECTION).updateOne(
39+
{ projectId: new ObjectId(projectId), _id: new ObjectId(eventId) },
40+
{ $addToSet: { visitedBy: new ObjectId(userId) } }
41+
);
42+
} catch (err) {
43+
incrementDualWriteFailure('events');
44+
console.error('[dualWrite] visitEventUnified failed', { projectId, eventId, err });
45+
}
46+
}
47+
48+
/**
49+
* Toggle event mark (resolved, ignored, starred) in unified events collection
50+
*
51+
* @param {string} projectId - project ObjectId
52+
* @param {string} eventId - event ObjectId
53+
* @param {string} mark - mark name (e.g. 'resolved', 'ignored', 'starred')
54+
* @param {boolean} isUnset - if true, remove mark; if false, set mark with timestamp
55+
*/
56+
async function toggleEventMarkUnified(projectId, eventId, mark, isUnset) {
57+
if (!isUnifiedEnabled()) return;
58+
59+
try {
60+
const db = getEventsDb();
61+
if (!db) return;
62+
63+
const markKey = `marks.${mark}`;
64+
const update = isUnset
65+
? { $unset: { [markKey]: '' } }
66+
: { $set: { [markKey]: Math.floor(Date.now() / 1000) } };
67+
68+
await db.collection(EVENTS_COLLECTION).updateOne(
69+
{ projectId: new ObjectId(projectId), _id: new ObjectId(eventId) },
70+
update
71+
);
72+
} catch (err) {
73+
incrementDualWriteFailure('events');
74+
console.error('[dualWrite] toggleEventMarkUnified failed', { projectId, eventId, mark, err });
75+
}
76+
}
77+
78+
/**
79+
* Update assignee in unified events collection
80+
*
81+
* @param {string} projectId - project ObjectId
82+
* @param {string} eventId - event ObjectId
83+
* @param {string} assignee - assignee user id or '' to unassign
84+
*/
85+
async function updateAssigneeUnified(projectId, eventId, assignee) {
86+
if (!isUnifiedEnabled()) return;
87+
88+
try {
89+
const db = getEventsDb();
90+
if (!db) return;
91+
92+
await db.collection(EVENTS_COLLECTION).updateOne(
93+
{ projectId: new ObjectId(projectId), _id: new ObjectId(eventId) },
94+
{ $set: { assignee } }
95+
);
96+
} catch (err) {
97+
incrementDualWriteFailure('events');
98+
console.error('[dualWrite] updateAssigneeUnified failed', { projectId, eventId, err });
99+
}
100+
}
101+
102+
module.exports = {
103+
visitEventUnified,
104+
toggleEventMarkUnified,
105+
updateAssigneeUnified,
106+
};

src/metrics/dualWrite.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Prometheus metrics for dual-write to unified collections
3+
*
4+
* @see docs/mongodb-unified-collections/
5+
*/
6+
7+
import promClient from 'prom-client';
8+
9+
export const dualWriteFailuresTotal = new promClient.Counter({
10+
name: 'hawk_dual_write_failures_total',
11+
help: 'Counter of dual-write failures to unified collections by type',
12+
labelNames: ['type'],
13+
});
14+
15+
/**
16+
* Increment dual-write failure counter
17+
* @param type - 'events' | 'repetitions'
18+
*/
19+
export function incrementDualWriteFailure(type: 'events' | 'repetitions'): void {
20+
dualWriteFailuresTotal.labels(type).inc();
21+
}

src/metrics/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import client from 'prom-client';
22
import express from 'express';
33
import { gqlOperationDuration, gqlOperationErrors, gqlResolverDuration } from './graphql';
44
import { mongoCommandDuration, mongoCommandErrors } from './mongodb';
5+
import { dualWriteFailuresTotal } from './dualWrite';
56

67
/**
78
* Create a Registry to register the metrics
@@ -49,6 +50,11 @@ register.registerMetric(gqlResolverDuration);
4950
register.registerMetric(mongoCommandDuration);
5051
register.registerMetric(mongoCommandErrors);
5152

53+
/**
54+
* Register dual-write metrics
55+
*/
56+
register.registerMetric(dualWriteFailuresTotal);
57+
5258
/**
5359
* Express middleware to track HTTP metrics
5460
* @param req - Express request object

src/models/eventsFactory.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import ChartDataService from '../services/chartDataService';
66

77
const Factory = require('./modelFactory');
88
const mongo = require('../mongo');
9+
const {
10+
visitEventUnified,
11+
toggleEventMarkUnified,
12+
updateAssigneeUnified,
13+
} = require('../events/unified/dualWrite');
914
const Event = require('../models/event');
1015
const { ObjectId } = require('mongodb');
1116
const { composeEventPayloadByRepetition } = require('../utils/merge');
@@ -836,6 +841,8 @@ class EventsFactory extends Factory {
836841
throw new Error(`Event not found for eventId: ${eventId}`);
837842
}
838843

844+
visitEventUnified(this.projectId.toString(), eventId, userId).catch(() => {});
845+
839846
return result;
840847
}
841848

@@ -861,8 +868,9 @@ class EventsFactory extends Factory {
861868
const markKey = `marks.${mark}`;
862869

863870
let update;
871+
const isUnset = !!(event.marks && event.marks[mark]);
864872

865-
if (event.marks && event.marks[mark]) {
873+
if (isUnset) {
866874
update = {
867875
$unset: { [markKey]: '' },
868876
};
@@ -872,7 +880,11 @@ class EventsFactory extends Factory {
872880
};
873881
}
874882

875-
return collection.updateOne(query, update);
883+
const result = await collection.updateOne(query, update);
884+
885+
toggleEventMarkUnified(this.projectId.toString(), eventId, mark, isUnset).catch(() => {});
886+
887+
return result;
876888
}
877889

878890
/**
@@ -920,6 +932,8 @@ class EventsFactory extends Factory {
920932
throw new Error(`Event not found for eventId: ${eventId}`);
921933
}
922934

935+
updateAssigneeUnified(this.projectId.toString(), eventId, assignee).catch(() => {});
936+
923937
return result;
924938
}
925939

src/types/env.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,11 @@ declare namespace NodeJS {
101101
* @example "my-github-app"
102102
*/
103103
GITHUB_APP_SLUG?: string;
104+
105+
/**
106+
* If true, dual-write to unified events/repetitions collections (events, repetitions with projectId)
107+
* @default 'false'
108+
*/
109+
USE_UNIFIED_EVENTS_COLLECTIONS?: string;
104110
}
105111
}

0 commit comments

Comments
 (0)