Skip to content

Commit 037022a

Browse files
committed
basic etag behavior
1 parent 72ab12e commit 037022a

File tree

3 files changed

+109
-3
lines changed

3 files changed

+109
-3
lines changed

cloudformation/iam.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ Resources:
8888
Resource:
8989
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
9090
Condition:
91-
ForAllValues:StringEquals:
91+
ForAllValues:StringLike:
9292
dynamodb:LeadingKeys:
93-
- testing # add any keys that must be accessible
93+
- etag-events-* # add any keys that must be accessible
9494
ForAllValues:StringLike:
9595
dynamodb:Attributes:
9696
- primaryKey

src/api/functions/cache.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import {
2+
DeleteItemCommand,
23
DynamoDBClient,
4+
GetItemCommand,
35
PutItemCommand,
46
QueryCommand,
7+
UpdateItemCommand,
58
} from "@aws-sdk/client-dynamodb";
69
import { genericConfig } from "../../common/config.js";
710
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
@@ -52,3 +55,79 @@ export async function insertItemIntoCache(
5255
}),
5356
);
5457
}
58+
59+
export async function atomicIncrementCacheCounter(
60+
dynamoClient: DynamoDBClient,
61+
key: string,
62+
amount: number,
63+
returnOld: boolean = false,
64+
): Promise<number> {
65+
const response = await dynamoClient.send(
66+
new UpdateItemCommand({
67+
TableName: genericConfig.CacheDynamoTableName,
68+
Key: marshall({
69+
primaryKey: key,
70+
}),
71+
UpdateExpression: "ADD #counterValue :increment",
72+
ExpressionAttributeNames: {
73+
"#counterValue": "counterValue",
74+
},
75+
ExpressionAttributeValues: marshall({
76+
":increment": amount,
77+
}),
78+
ReturnValues: returnOld ? "UPDATED_OLD" : "UPDATED_NEW",
79+
}),
80+
);
81+
82+
if (!response.Attributes) {
83+
return returnOld ? 0 : amount;
84+
}
85+
86+
const value = unmarshall(response.Attributes).counter;
87+
return typeof value === "number" ? value : 0;
88+
}
89+
90+
export async function getCacheCounter(
91+
dynamoClient: DynamoDBClient,
92+
key: string,
93+
defaultValue: number = 0,
94+
): Promise<number> {
95+
const response = await dynamoClient.send(
96+
new GetItemCommand({
97+
TableName: genericConfig.CacheDynamoTableName,
98+
Key: marshall({
99+
primaryKey: key,
100+
}),
101+
ProjectionExpression: "counterValue", // Only retrieve the value attribute
102+
}),
103+
);
104+
105+
if (!response.Item) {
106+
return defaultValue;
107+
}
108+
109+
const value = unmarshall(response.Item).counterValue;
110+
return typeof value === "number" ? value : defaultValue;
111+
}
112+
113+
export async function deleteCacheCounter(
114+
dynamoClient: DynamoDBClient,
115+
key: string,
116+
): Promise<number | null> {
117+
const params = {
118+
TableName: genericConfig.CacheDynamoTableName,
119+
Key: marshall({
120+
primaryKey: key,
121+
}),
122+
ReturnValue: "ALL_OLD",
123+
};
124+
125+
const response = await dynamoClient.send(new DeleteItemCommand(params));
126+
127+
if (response.Attributes) {
128+
const item = unmarshall(response.Attributes);
129+
const value = item.counterValue;
130+
return typeof value === "number" ? value : 0;
131+
}
132+
return null;
133+
}

src/api/routes/events.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import { randomUUID } from "crypto";
2424
import moment from "moment-timezone";
2525
import { IUpdateDiscord, updateDiscord } from "../functions/discord.js";
2626
import rateLimiter from "api/plugins/rateLimiter.js";
27+
import {
28+
atomicIncrementCacheCounter,
29+
deleteCacheCounter,
30+
getCacheCounter,
31+
} from "api/functions/cache.js";
2732

2833
const repeatOptions = ["weekly", "biweekly"] as const;
2934
const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`;
@@ -137,6 +142,11 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
137142
TableName: genericConfig.EventsDynamoTableName,
138143
});
139144
}
145+
const etag = await getCacheCounter(
146+
fastify.dynamoClient,
147+
"events-etag-all",
148+
);
149+
reply.header("etag", etag);
140150
const response = await fastify.dynamoClient.send(command);
141151
const items = response.Items?.map((item) => unmarshall(item));
142152
const currentTimeChicago = moment().tz("America/Chicago");
@@ -277,6 +287,18 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
277287
}
278288
throw new DiscordEventError({});
279289
}
290+
await atomicIncrementCacheCounter(
291+
fastify.dynamoClient,
292+
`events-etag-${entryUUID}`,
293+
1,
294+
false,
295+
);
296+
await atomicIncrementCacheCounter(
297+
fastify.dynamoClient,
298+
"events-etag-all",
299+
1,
300+
false,
301+
);
280302
reply.status(201).send({
281303
id: entryUUID,
282304
resource: `/api/v1/events/${entryUUID}`,
@@ -335,6 +357,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
335357
message: "Failed to delete event from Dynamo table.",
336358
});
337359
}
360+
await deleteCacheCounter(fastify.dynamoClient, `events-etag-${id}`);
338361
request.log.info(
339362
{ type: "audit", actor: request.username, target: id },
340363
`deleted event "${id}"`,
@@ -372,7 +395,11 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
372395
if (!ts) {
373396
reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY);
374397
}
375-
return reply.send(item);
398+
const etag = await getCacheCounter(
399+
fastify.dynamoClient,
400+
`events-etag-${id}`,
401+
);
402+
return reply.header("etag", etag).send(item);
376403
} catch (e) {
377404
if (e instanceof BaseError) {
378405
throw e;

0 commit comments

Comments
 (0)