Skip to content

Commit 5f8fe7d

Browse files
fix: monitor deactivation workflow bugs (#2059)
* fix: monitor deactivation workflow bugs - Fix StepPaused sending "monitors paused" email to users who logged back in - Fix Step14Days skipping next step scheduling when user has no email - Fix StepPaused workspace query failing for users without sessions (innerJoin on session table) - Fix getUser throwing on missing email, breaking Redis cleanup in StepPaused - Add Cloud Tasks dedup via deterministic task names (handles ALREADY_EXISTS) - Add email dedup via Redis keys to prevent duplicate sends on retries - Migrate Redis from shared set to individual keys with 30-day TTL - Add hasUserLoggedIn check to Step14Days (was missing, marked :scary:) - Add active/deletedAt filters to monitor pause query - Deduplicate users in LaunchMonitorWorkflow * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 67ac7a4 commit 5f8fe7d

2 files changed

Lines changed: 146 additions & 96 deletions

File tree

apps/workflows/fly.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ primary_region = 'ams'
3131
strategy = "rolling"
3232

3333
[[http_service.checks]]
34-
grace_period = "10s"
34+
grace_period = "30s"
3535
interval = "1m"
3636
method = "GET"
37-
timeout = "5s"
37+
timeout = "10s"
3838
path = "/ping"
3939

4040
[env]

apps/workflows/src/cron/monitor.ts

Lines changed: 144 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import {
1111
or,
1212
schema,
1313
} from "@openstatus/db";
14-
import { session, user } from "@openstatus/db/src/schema";
14+
import { user } from "@openstatus/db/src/schema";
1515
import {
1616
monitorDeactivationEmail,
1717
monitorPausedEmail,
1818
} from "@openstatus/emails";
19-
import { sendBatchEmailHtml } from "@openstatus/emails/src/send";
19+
import {
20+
type EmailHtml,
21+
sendBatchEmailHtml,
22+
} from "@openstatus/emails/src/send";
2023
import { Redis } from "@openstatus/upstash";
2124
import { RateLimiter } from "limiter";
2225
import { z } from "zod";
@@ -122,9 +125,15 @@ export async function LaunchMonitorWorkflow() {
122125
or(isNull(schema.workspace.plan), eq(schema.workspace.plan, "free")),
123126
),
124127
);
125-
// Let's merge both results
126-
const users = [...u, ...u1];
127-
// iterate over users
128+
const usersMap = new Map<number, (typeof u)[number]>();
129+
for (const entry of [...u, ...u1]) {
130+
usersMap.set(entry.userId, entry);
131+
}
132+
const users = Array.from(usersMap.values());
133+
const duplicatesRemoved = u.length + u1.length - users.length;
134+
if (duplicatesRemoved > 0) {
135+
console.log(`Removed ${duplicatesRemoved} duplicate users`);
136+
}
128137

129138
const allResult = [];
130139

@@ -154,8 +163,7 @@ async function workflowInit({
154163
};
155164
}) {
156165
console.log(`Starting workflow for ${user.userId}`);
157-
// Let's check if the user is in the workflow
158-
const isMember = await redis.sismember("workflow:users", user.userId);
166+
const isMember = await redis.exists(`workflow:user:${user.userId}`);
159167
if (isMember) {
160168
console.log(`user workflow already started for ${user.userId}`);
161169
return;
@@ -173,30 +181,39 @@ async function workflowInit({
173181
console.log(`user has no running monitors for ${user.userId}`);
174182
return;
175183
}
184+
const initialRun = new Date().getTime();
176185
await CreateTask({
177186
parent,
178187
client: client,
179188
step: "14days",
180189
userId: user.userId,
181-
initialRun: new Date().getTime(),
190+
initialRun,
191+
});
192+
await redis.set(`workflow:user:${user.userId}`, initialRun, {
193+
ex: 30 * 86400,
182194
});
183-
// // Add our user to the list of users that have started the workflow
184-
185-
await redis.sadd("workflow:users", user.userId);
186195
console.log(`user workflow started for ${user.userId}`);
187196
}
188197

189198
export async function Step14Days(userId: number, workFlowRunTimestamp: number) {
190-
const user = await getUser(userId);
199+
const hasConnected = await hasUserLoggedIn({
200+
userId,
201+
date: new Date(workFlowRunTimestamp),
202+
});
191203

192-
// Send email saying we are going to pause the monitors
193-
// The task has just been created we don't double check if the user has logged in :scary:
194-
// send First email
195-
// TODO: Send email
204+
if (hasConnected) {
205+
await redis.del(`workflow:user:${userId}`);
206+
return;
207+
}
208+
209+
const user = await getUser(userId);
196210

197211
if (user.email) {
198-
await sendBatchEmailHtml([
199-
{
212+
await sendWorkflowEmail({
213+
userId,
214+
step: "14days",
215+
initialRun: workFlowRunTimestamp,
216+
email: {
200217
to: user.email,
201218
subject: "Your OpenStatus monitors will be paused in 14 days",
202219
from: "Thibault From OpenStatus <thibault@notifications.openstatus.dev>",
@@ -207,36 +224,37 @@ export async function Step14Days(userId: number, workFlowRunTimestamp: number) {
207224
).toDateString(),
208225
}),
209226
},
210-
]);
211-
212-
await CreateTask({
213-
parent,
214-
client: client,
215-
step: "3days",
216-
userId: user.id,
217-
initialRun: workFlowRunTimestamp,
218227
});
219228
}
229+
230+
await CreateTask({
231+
parent,
232+
client: client,
233+
step: "3days",
234+
userId: user.id,
235+
initialRun: workFlowRunTimestamp,
236+
});
220237
}
221238

222239
export async function Step3Days(userId: number, workFlowRunTimestamp: number) {
223-
// check if user has connected
224240
const hasConnected = await hasUserLoggedIn({
225241
userId,
226242
date: new Date(workFlowRunTimestamp),
227243
});
228244

229245
if (hasConnected) {
230-
//
231-
await redis.srem("workflow:users", userId);
246+
await redis.del(`workflow:user:${userId}`);
232247
return;
233248
}
234249

235250
const user = await getUser(userId);
236251

237252
if (user.email) {
238-
await sendBatchEmailHtml([
239-
{
253+
await sendWorkflowEmail({
254+
userId,
255+
step: "3days",
256+
initialRun: workFlowRunTimestamp,
257+
email: {
240258
to: user.email,
241259
subject: "Your OpenStatus monitors will be paused in 3 days",
242260
from: "Thibault From OpenStatus <thibault@notifications.openstatus.dev>",
@@ -247,12 +265,9 @@ export async function Step3Days(userId: number, workFlowRunTimestamp: number) {
247265
).toDateString(),
248266
}),
249267
},
250-
]);
268+
});
251269
}
252270

253-
// Send second email
254-
//TODO: Send email
255-
// Let's schedule the next task
256271
await CreateTask({
257272
client,
258273
parent,
@@ -263,64 +278,48 @@ export async function Step3Days(userId: number, workFlowRunTimestamp: number) {
263278
}
264279

265280
export async function StepPaused(userId: number, workFlowRunTimestamp: number) {
266-
const hasConnected = await hasUserLoggedIn({
267-
userId,
268-
date: new Date(workFlowRunTimestamp),
269-
});
270-
if (!hasConnected) {
271-
// sendSecond pause email
272-
const users = await db
273-
.select({
274-
userId: schema.user.id,
275-
email: schema.user.email,
276-
workspaceId: schema.workspace.id,
277-
})
278-
.from(user)
279-
.innerJoin(session, eq(schema.user.id, schema.session.userId))
280-
.innerJoin(
281-
schema.usersToWorkspaces,
282-
eq(schema.user.id, schema.usersToWorkspaces.userId),
283-
)
284-
.innerJoin(
285-
schema.workspace,
286-
eq(schema.usersToWorkspaces.workspaceId, schema.workspace.id),
287-
)
288-
.where(
289-
and(
290-
or(isNull(schema.workspace.plan), eq(schema.workspace.plan, "free")),
291-
eq(schema.user.id, userId),
292-
),
293-
)
294-
.get();
295-
// We should only have one user :)
296-
if (!users) {
297-
console.error(`No user found for ${userId}`);
281+
try {
282+
const hasConnected = await hasUserLoggedIn({
283+
userId,
284+
date: new Date(workFlowRunTimestamp),
285+
});
286+
287+
if (hasConnected) {
298288
return;
299289
}
300290

301-
await db
302-
.update(schema.monitor)
303-
.set({ active: false })
304-
.where(eq(schema.monitor.workspaceId, users.workspaceId));
305-
// Send last email with pause monitor
306-
}
307-
308-
const currentUser = await getUser(userId);
309-
// TODO: Send email
310-
// Remove user for workflow
291+
const userWorkspace = await getUserWorkspace(userId);
292+
if (userWorkspace) {
293+
await db
294+
.update(schema.monitor)
295+
.set({ active: false })
296+
.where(
297+
and(
298+
eq(schema.monitor.workspaceId, userWorkspace.workspaceId),
299+
eq(schema.monitor.active, true),
300+
isNull(schema.monitor.deletedAt),
301+
),
302+
);
303+
}
311304

312-
if (currentUser.email) {
313-
await sendBatchEmailHtml([
314-
{
315-
to: currentUser.email,
316-
subject: "Your monitors have been paused",
317-
from: "Thibault From OpenStatus <thibault@notifications.openstatus.dev>",
318-
reply_to: "thibault@openstatus.dev",
319-
html: monitorPausedEmail(),
320-
},
321-
]);
305+
const currentUser = await getUser(userId);
306+
if (currentUser.email) {
307+
await sendWorkflowEmail({
308+
userId,
309+
step: "paused",
310+
initialRun: workFlowRunTimestamp,
311+
email: {
312+
to: currentUser.email,
313+
subject: "Your monitors have been paused",
314+
from: "Thibault From OpenStatus <thibault@notifications.openstatus.dev>",
315+
reply_to: "thibault@openstatus.dev",
316+
html: monitorPausedEmail(),
317+
},
318+
});
319+
}
320+
} finally {
321+
await redis.del(`workflow:user:${userId}`);
322322
}
323-
await redis.srem("workflow:users", userId);
324323
}
325324

326325
async function hasUserLoggedIn({
@@ -346,7 +345,7 @@ async function hasUserLoggedIn({
346345
return user.lastSession > date;
347346
}
348347

349-
function CreateTask({
348+
async function CreateTask({
350349
parent,
351350
client,
352351
step,
@@ -361,10 +360,12 @@ function CreateTask({
361360
}) {
362361
const url = `https://openstatus-workflows.fly.dev/cron/monitors/${step}?userId=${userId}&initialRun=${initialRun}`;
363362
const timestamp = getScheduledTime(step);
363+
const taskName = `${parent}/tasks/workflow-${userId}-${step}-${initialRun}`;
364364
const newTask: google.cloud.tasks.v2beta3.ITask = {
365+
name: taskName,
365366
httpRequest: {
366367
headers: {
367-
"Content-Type": "application/json", // Set content type to ensure compatibility your application's request parsing
368+
"Content-Type": "application/json",
368369
Authorization: `${env().CRON_SECRET}`,
369370
},
370371
httpMethod: "GET",
@@ -376,7 +377,17 @@ function CreateTask({
376377
};
377378

378379
const request = { parent: parent, task: newTask };
379-
return client.createTask(request);
380+
try {
381+
return await client.createTask(request);
382+
} catch (e) {
383+
if (e instanceof Error && "code" in e && e.code === 6) {
384+
console.log(
385+
`Task already exists for user ${userId} step ${step}, skipping`,
386+
);
387+
return;
388+
}
389+
throw e;
390+
}
380391
}
381392

382393
function getScheduledTime(step: z.infer<typeof workflowStepSchema>) {
@@ -408,8 +419,47 @@ async function getUser(userId: number) {
408419
if (!currentUser) {
409420
throw new Error("User not found");
410421
}
411-
if (!currentUser.email) {
412-
throw new Error("User email not found");
413-
}
414422
return currentUser;
415423
}
424+
425+
async function sendWorkflowEmail({
426+
userId,
427+
step,
428+
initialRun,
429+
email,
430+
}: {
431+
userId: number;
432+
step: string;
433+
initialRun: number;
434+
email: EmailHtml;
435+
}) {
436+
const key = `workflow:email:${userId}:${step}:${initialRun}`;
437+
const alreadySent = await redis.exists(key);
438+
if (alreadySent) {
439+
console.log(`Email already sent for user ${userId} step ${step}, skipping`);
440+
return;
441+
}
442+
await sendBatchEmailHtml([email]);
443+
await redis.set(key, 1, { ex: 30 * 86400 });
444+
}
445+
446+
async function getUserWorkspace(userId: number) {
447+
return db
448+
.select({ workspaceId: schema.workspace.id })
449+
.from(schema.user)
450+
.innerJoin(
451+
schema.usersToWorkspaces,
452+
eq(schema.user.id, schema.usersToWorkspaces.userId),
453+
)
454+
.innerJoin(
455+
schema.workspace,
456+
eq(schema.usersToWorkspaces.workspaceId, schema.workspace.id),
457+
)
458+
.where(
459+
and(
460+
eq(schema.user.id, userId),
461+
or(isNull(schema.workspace.plan), eq(schema.workspace.plan, "free")),
462+
),
463+
)
464+
.get();
465+
}

0 commit comments

Comments
 (0)