Skip to content

Commit c8615f8

Browse files
committed
Make updating orgs more reliable
1 parent e0ba586 commit c8615f8

File tree

4 files changed

+143
-96
lines changed

4 files changed

+143
-96
lines changed

src/api/functions/organizations.ts

Lines changed: 74 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ import {
66
} from "@aws-sdk/client-dynamodb";
77
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
88
import { genericConfig } from "common/config.js";
9-
import {
10-
BaseError,
11-
DatabaseFetchError,
12-
ValidationError,
13-
} from "common/errors/index.js";
9+
import { BaseError, DatabaseFetchError } from "common/errors/index.js";
1410
import { OrgRole, orgRoles } from "common/roles.js";
1511
import {
1612
enforcedOrgLeadEntry,
@@ -25,6 +21,7 @@ import { modifyGroup } from "./entraId.js";
2521
import { EntraGroupActions } from "common/types/iam.js";
2622
import { buildAuditLogTransactPut } from "./auditLog.js";
2723
import { Modules } from "common/modules.js";
24+
import { retryDynamoTransactionWithBackoff } from "api/utils.js";
2825

2926
export interface GetOrgInfoInputs {
3027
id: string;
@@ -51,6 +48,7 @@ export async function getOrgInfo({
5148
ExpressionAttributeValues: {
5249
":definitionId": { S: `DEFINE#${id}` },
5350
},
51+
ConsistentRead: true,
5452
});
5553
let response = { leads: [] } as {
5654
leads: { name: string; username: string; title: string | undefined }[];
@@ -80,13 +78,14 @@ export async function getOrgInfo({
8078
message: "Failed to get org metadata.",
8179
});
8280
}
83-
// Get leads
81+
8482
const leadsQuery = new QueryCommand({
8583
TableName: genericConfig.SigInfoTableName,
8684
KeyConditionExpression: "primaryKey = :leadName",
8785
ExpressionAttributeValues: {
8886
":leadName": { S: `LEAD#${id}` },
8987
},
88+
ConsistentRead: true,
9089
});
9190
try {
9291
const responseMarshall = await dynamoClient.send(leadsQuery);
@@ -173,11 +172,6 @@ export async function getUserOrgRoles({
173172
}
174173
}
175174

176-
/**
177-
* Adds a user as a lead, handling DB, Entra sync, and returning an email payload.
178-
* It will only succeed if the user is not already a lead, preventing race conditions.
179-
* @returns SQSMessage payload for email notification, or null if the user is already a lead.
180-
*/
181175
export const addLead = async ({
182176
user,
183177
orgId,
@@ -203,46 +197,53 @@ export const addLead = async ({
203197
}): Promise<SQSMessage | null> => {
204198
const { username } = user;
205199

206-
const addTransaction = new TransactWriteItemsCommand({
207-
TransactItems: [
208-
buildAuditLogTransactPut({
209-
entry: {
210-
module: Modules.ORG_INFO,
211-
actor: actorUsername,
212-
target: username,
213-
message: `Added target as a lead of ${orgId}.`,
214-
},
215-
})!,
216-
{
217-
Put: {
218-
TableName: genericConfig.SigInfoTableName,
219-
Item: marshall({
220-
...user,
221-
primaryKey: `LEAD#${orgId}`,
222-
entryId: username,
223-
updatedAt: new Date().toISOString(),
224-
}),
225-
// This condition ensures the Put operation fails if an item with this primary key already exists.
226-
ConditionExpression: "attribute_not_exists(primaryKey)",
200+
const addOperation = async () => {
201+
const addTransaction = new TransactWriteItemsCommand({
202+
TransactItems: [
203+
buildAuditLogTransactPut({
204+
entry: {
205+
module: Modules.ORG_INFO,
206+
actor: actorUsername,
207+
target: username,
208+
message: `Added target as a lead of ${orgId}.`,
209+
},
210+
})!,
211+
{
212+
Put: {
213+
TableName: genericConfig.SigInfoTableName,
214+
Item: marshall({
215+
...user,
216+
primaryKey: `LEAD#${orgId}`,
217+
entryId: username,
218+
updatedAt: new Date().toISOString(),
219+
}),
220+
ConditionExpression:
221+
"attribute_not_exists(primaryKey) AND attribute_not_exists(entryId)",
222+
},
227223
},
228-
},
229-
],
230-
});
224+
],
225+
});
226+
227+
return await dynamoClient.send(addTransaction);
228+
};
231229

232230
try {
233-
await dynamoClient.send(addTransaction);
231+
await retryDynamoTransactionWithBackoff(
232+
addOperation,
233+
logger,
234+
`Add lead ${username} to ${orgId}`,
235+
);
234236
} catch (e: any) {
235-
// This specific error is thrown when a ConditionExpression fails.
236237
if (
237238
e.name === "TransactionCanceledException" &&
238239
e.message.includes("ConditionalCheckFailed")
239240
) {
240241
logger.info(
241242
`User ${username} is already a lead for ${orgId}. Skipping add operation.`,
242243
);
243-
return null; // Gracefully exit without erroring.
244+
return null;
244245
}
245-
throw e; // Re-throw any other type of error.
246+
throw e;
246247
}
247248

248249
logger.info(
@@ -290,11 +291,6 @@ export const addLead = async ({
290291
};
291292
};
292293

293-
/**
294-
* Removes a user as a lead, handling DB, Entra sync, and returning an email payload.
295-
* It will only succeed if the user is currently a lead, and attempts to avoid race conditions in Exec group management.
296-
* @returns SQSMessage payload for email notification, or null if the user was not a lead.
297-
*/
298294
export const removeLead = async ({
299295
username,
300296
orgId,
@@ -318,44 +314,41 @@ export const removeLead = async ({
318314
execGroupId: string;
319315
officersEmail: string;
320316
}): Promise<SQSMessage | null> => {
321-
const getDelayed = async () => {
322-
// HACK: wait up to 30ms in an attempt to de-sync the threads on checking leads.
323-
// Yes, I know this is bad. But because of a lack of consistent reads on Dynamo GSIs,
324-
// we're going to have to run with it for now.
325-
const sleepMs = Math.random() * 30;
326-
logger.info(`Sleeping for ${sleepMs}ms before checking.`);
327-
await new Promise((resolve) => setTimeout(resolve, sleepMs));
328-
return getUserOrgRoles({ username, dynamoClient, logger });
329-
};
330-
const userRolesPromise = getDelayed();
331-
const removeTransaction = new TransactWriteItemsCommand({
332-
TransactItems: [
333-
buildAuditLogTransactPut({
334-
entry: {
335-
module: Modules.ORG_INFO,
336-
actor: actorUsername,
337-
target: username,
338-
message: `Removed target from lead of ${orgId}.`,
339-
},
340-
})!,
341-
{
342-
Delete: {
343-
TableName: genericConfig.SigInfoTableName,
344-
Key: marshall({
345-
primaryKey: `LEAD#${orgId}`,
346-
entryId: username,
347-
}),
348-
// Idempotent
349-
ConditionExpression: "attribute_exists(primaryKey)",
317+
const removeOperation = async () => {
318+
const removeTransaction = new TransactWriteItemsCommand({
319+
TransactItems: [
320+
buildAuditLogTransactPut({
321+
entry: {
322+
module: Modules.ORG_INFO,
323+
actor: actorUsername,
324+
target: username,
325+
message: `Removed target from lead of ${orgId}.`,
326+
},
327+
})!,
328+
{
329+
Delete: {
330+
TableName: genericConfig.SigInfoTableName,
331+
Key: marshall({
332+
primaryKey: `LEAD#${orgId}`,
333+
entryId: username,
334+
}),
335+
ConditionExpression:
336+
"attribute_exists(primaryKey) AND attribute_exists(entryId)",
337+
},
350338
},
351-
},
352-
],
353-
});
339+
],
340+
});
341+
342+
return await dynamoClient.send(removeTransaction);
343+
};
354344

355345
try {
356-
await dynamoClient.send(removeTransaction);
346+
await retryDynamoTransactionWithBackoff(
347+
removeOperation,
348+
logger,
349+
`Remove lead ${username} from ${orgId}`,
350+
);
357351
} catch (e: any) {
358-
// This specific error is thrown when a ConditionExpression fails, meaning we do nothing.
359352
if (
360353
e.name === "TransactionCanceledException" &&
361354
e.message.includes("ConditionalCheckFailed")
@@ -385,11 +378,12 @@ export const removeLead = async ({
385378
);
386379
}
387380

388-
const userRoles = await userRolesPromise;
389-
// Since the read is eventually consistent, don't count the role we just removed if it still gets returned.
381+
// Use consistent read to check if user has other lead roles
382+
const userRoles = await getUserOrgRoles({ username, dynamoClient, logger });
390383
const otherLeadRoles = userRoles
391384
.filter((x) => x.role === "LEAD")
392385
.filter((x) => x.org !== orgId);
386+
393387
if (otherLeadRoles.length === 0) {
394388
await modifyGroup(
395389
entraIdToken,

src/api/routes/organizations.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
4646
import { getRoleCredentials } from "api/functions/sts.js";
4747
import { SQSClient } from "@aws-sdk/client-sqs";
4848
import { sendSqsMessagesInBatches } from "api/functions/sqs.js";
49+
import { retryDynamoTransactionWithBackoff } from "api/utils.js";
4950

5051
export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${ORG_DATA_CACHED_DURATION}, stale-while-revalidate=${Math.floor(ORG_DATA_CACHED_DURATION * 1.1)}, stale-if-error=3600`;
5152

@@ -95,7 +96,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
9596
};
9697
};
9798

98-
// Omitting GET and POST routes for brevity as they are unchanged...
9999
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
100100
"",
101101
{
@@ -140,7 +140,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
140140
let successOnly = data
141141
.filter((x) => x.status === "fulfilled")
142142
.map((x) => x.value);
143-
// return just the ID for anything not in the DB.
144143
const successIds = successOnly.map((x) => x.id);
145144
if (!isAuthenticated) {
146145
successOnly = successOnly.map((x) => ({
@@ -163,6 +162,7 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
163162
}
164163
},
165164
);
165+
166166
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
167167
"/:orgId",
168168
{
@@ -212,6 +212,7 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
212212
return reply.send(response);
213213
},
214214
);
215+
215216
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
216217
"/:orgId/meta",
217218
{
@@ -249,15 +250,17 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
249250
},
250251
},
251252
async (request, reply) => {
252-
try {
253-
const logStatement = buildAuditLogTransactPut({
254-
entry: {
255-
module: Modules.ORG_INFO,
256-
message: "Updated organization metadata.",
257-
actor: request.username!,
258-
target: request.params.orgId,
259-
},
260-
});
253+
const timestamp = new Date().toISOString();
254+
const logStatement = buildAuditLogTransactPut({
255+
entry: {
256+
module: Modules.ORG_INFO,
257+
message: "Updated organization metadata.",
258+
actor: request.username!,
259+
target: request.params.orgId,
260+
},
261+
});
262+
263+
const metadataOperation = async () => {
261264
const commandTransaction = new TransactWriteItemsCommand({
262265
TransactItems: [
263266
...(logStatement ? [logStatement] : []),
@@ -269,15 +272,23 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
269272
...request.body,
270273
primaryKey: `DEFINE#${request.params.orgId}`,
271274
entryId: "0",
272-
updatedAt: new Date().toISOString(),
275+
updatedAt: timestamp,
273276
},
274277
{ removeUndefinedValues: true },
275278
),
276279
},
277280
},
278281
],
279282
});
280-
await fastify.dynamoClient.send(commandTransaction);
283+
return await fastify.dynamoClient.send(commandTransaction);
284+
};
285+
286+
try {
287+
await retryDynamoTransactionWithBackoff(
288+
metadataOperation,
289+
request.log,
290+
`Update metadata for ${request.params.orgId}`,
291+
);
281292
} catch (e) {
282293
if (e instanceof BaseError) {
283294
throw e;
@@ -339,7 +350,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
339350
});
340351
}
341352

342-
// Check paid membership for all potential new members before proceeding.
343353
if (add.length > 0) {
344354
try {
345355
const paidMemberships = await Promise.all(
@@ -376,6 +386,8 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
376386
entryId: "0",
377387
}),
378388
AttributesToGet: ["leadsEntraGroupId"],
389+
// Use consistent read to ensure we get the latest metadata
390+
ConsistentRead: true,
379391
});
380392

381393
const [metadataResponse, clients] = await Promise.all([
@@ -405,7 +417,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
405417
officersEmail,
406418
};
407419

408-
// Attempt to add and remove all users specified in the request body.
409420
const addPromises = add.map((user) => addLead({ ...commonArgs, user }));
410421
const removePromises = remove.map((username) =>
411422
removeLead({ ...commonArgs, username }),

0 commit comments

Comments
 (0)