Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1/**
2 * @fileoverview This defines a few separate "services" that can be injected
3 * into the RuleEngine and other services in the POST /content hot path (like
4 * the signal execution service) to enable those services to make the queries
5 * they need to run a rule. Having these queries defined separately + injected
6 * into the consumers gives us a cleaner place to add optimizations to the query
7 * logic (i.e., run the queries against replicas, add caching, etc) and makes
8 * the consumers much more unit testable.
9 */
10import { Op } from 'sequelize';
11
12import { inject } from '../iocContainer/index.js';
13import { type LocationArea } from '../models/types/locationArea.js';
14import { type Action } from '../services/moderationConfigService/index.js';
15import { cached } from '../utils/caching.js';
16import { jsonParse, jsonStringify } from '../utils/encoding.js';
17import { getUtcDateOnlyString } from '../utils/time.js';
18
19export const makeGetEnabledRulesForItemTypeEventuallyConsistent = inject(
20 ['getSequelizeItemTypeEventuallyConsistent'],
21 function (getSequelizeItemTypeEventuallyConsistent) {
22 return cached({
23 async producer(itemTypeId: string) {
24 // Getting the enabledRules is currently coupled to sequelize, so,
25 // annoyingly, we first have to convert the contentTypeId into a full
26 // contentType model object. However, we don't want to incur too much
27 // overhead for that, so we use a cached lookup. (Note: we can't just
28 // take a model object as the argument because our caching library
29 // requires the cache key to be a string; further, even if we could just
30 // accept the model object, we wouldn't want to, because we want to move
31 // away from this coupling to sequelize.)
32 const itemType = await getSequelizeItemTypeEventuallyConsistent({
33 id: itemTypeId,
34 });
35
36 return itemType ? itemType.getEnabledRules() : null;
37 },
38 directives: { freshUntilAge: 20 },
39 });
40 },
41);
42
43export type GetEnabledRulesForItemTypeEventuallyConsistent = ReturnType<
44 typeof makeGetEnabledRulesForItemTypeEventuallyConsistent
45>;
46
47export const makeGetSequelizeItemTypeEventuallyConsistent = inject(
48 ['ItemTypeModel'],
49 (ItemType) => {
50 return cached({
51 async producer(key: { id: string } | { name: string; orgId: string }) {
52 return 'id' in key
53 ? ItemType.findByPk(key.id)
54 : ItemType.findOne({ where: { name: key.name, orgId: key.orgId } });
55 },
56 directives: { freshUntilAge: 10, maxStale: [0, 2, 2] },
57 });
58 },
59);
60
61export type GetSequelizeItemTypeEventuallyConsistent = ReturnType<
62 typeof makeGetSequelizeItemTypeEventuallyConsistent
63>;
64
65export const makeGetItemTypesForOrgEventuallyConsistent = inject(
66 ['ModerationConfigService'],
67 (moderationConfigService) => async (orgId: string) =>
68 moderationConfigService.getItemTypes({
69 orgId,
70 }),
71);
72
73export type GetItemTypesForOrgEventuallyConsistent = ReturnType<
74 typeof makeGetItemTypesForOrgEventuallyConsistent
75>;
76
77// TODO: this could probably be improved to increase cache hit rates, since
78// rn the cache will only be used if all the ids have previously been fetched.
79export const makeGetPoliciesForRulesEventuallyConsistent = inject(
80 ['PolicyModel'],
81 function (Policy) {
82 return cached({
83 keyGeneration: {
84 toString: (ids: readonly string[]) => jsonStringify([...ids].sort()),
85 fromString: (it) => jsonParse(it),
86 },
87 async producer(key) {
88 return Policy.getPoliciesForRuleIds(key);
89 },
90 directives: { freshUntilAge: 120 },
91 });
92 },
93);
94
95export type GetPoliciesForRulesEventuallyConsistent = ReturnType<
96 typeof makeGetPoliciesForRulesEventuallyConsistent
97>;
98
99export const makeGetActionsForRuleEventuallyConsistent = inject(
100 ['ActionModel'],
101 (Action) => {
102 return cached({
103 async producer(ruleId: string) {
104 // This generates a pretty slow/overly-complex query, but I think it's
105 // the best we can do with Sequelize. Eventually, we want to move off of
106 // Sequelize, so we don't fetch full model instances + we cast the
107 // result to be a plain data object, so that the rest of the code can't
108 // depend on getting an Action model instance back, as that won't always
109 // be the case.
110 return Action.findAll({
111 where: { '$rules.id$': ruleId },
112 include: [{ association: 'rules', attributes: ['id'] }],
113 raw: true,
114 }) as Promise<Action[]>;
115 },
116 directives: { freshUntilAge: 30 },
117 });
118 },
119);
120
121export type GetActionsForRuleEventuallyConsistent = ReturnType<
122 typeof makeGetActionsForRuleEventuallyConsistent
123>;
124
125export const makeGetLocationBankLocationsEventuallyConsistent = inject(
126 ['LocationBankLocationModel'],
127 (LocationBankLocation) => {
128 return cached({
129 async producer(bankId: string) {
130 // NB: we use `raw: true` to get back plain JS objects, rather than
131 // sequelize model instances. We do that because, with model instances,
132 // every proprety access runs some extra getter code; see
133 // https://github.com/sequelize/sequelize/blob/e77dcf78b341b62c97dbb29f16ce7a23f46ddc53/src/model.js#L42
134 // This ends up killing the performance of our hot-path code that checks
135 // whether a location is in each of these location areas.
136 //
137 // Meanwhile, we have to cast to LocationArea[] because findAll is still
138 // typed (incorrectly) to return the model instance, even when `raw:
139 // true` is provided.
140 return LocationBankLocation.findAll({
141 where: { bankId },
142 raw: true,
143 }) as Promise<LocationArea[]>;
144 },
145 directives(locations) {
146 const numLocations = locations.length;
147 const cacheTime = 15 + numLocations ** (1 / 3);
148 const swrTime = numLocations ** (2 / 3);
149 return { freshUntilAge: cacheTime, maxStale: [0, swrTime, swrTime] };
150 },
151 collapseOverlappingRequestsTime: 60,
152 });
153 },
154);
155
156export type GetLocationBankLocationsBankEventuallyConsistent = ReturnType<
157 typeof makeGetLocationBankLocationsEventuallyConsistent
158>;
159
160export const makeGetTextBankStringsEventuallyConsistent = inject(
161 ['ModerationConfigService'],
162 (moderationConfigService) => {
163 return cached({
164 async producer(input: { orgId: string; bankId: string }) {
165 const { orgId, bankId } = input;
166 const bank = await moderationConfigService.getTextBank({
167 id: bankId,
168 orgId,
169 });
170
171 return bank.strings;
172 },
173 directives: { freshUntilAge: 60, maxStale: [0, 5, 5] },
174 });
175 },
176);
177
178export type GetTextBankStringsEventuallyConsistent = ReturnType<
179 typeof makeGetTextBankStringsEventuallyConsistent
180>;
181
182export const makeGetImageBankEventuallyConsistent = inject(
183 ['HMAHashBankService'],
184 (hmaService) => {
185 return cached({
186 async producer(input: { orgId: string; bankId: string }) {
187 const { orgId, bankId } = input;
188 return hmaService.getBankById(orgId, parseInt(bankId, 10));
189 },
190 directives: { freshUntilAge: 60, maxStale: [0, 5, 5] },
191 });
192 },
193);
194
195export type GetImageBankEventuallyConsistent = ReturnType<
196 typeof makeGetImageBankEventuallyConsistent
197>;
198
199export const makeRecordRuleActionLimitUsage = inject(
200 ['Sequelize', 'Tracer'],
201 (db, tracer) => {
202 /**
203 * Record that each of the rules given by ruleIds has used up one of its
204 * daily action runs, against its maxDailyActions.
205 */
206 async function recordRuleActionLimitUsage(ruleIds: readonly string[]) {
207 if (ruleIds.length === 0) {
208 return;
209 }
210
211 const today = getUtcDateOnlyString();
212 await db.transactionWithRetry(async () => {
213 // Using two queries like this isn't as efficient as, e.g.,
214 // UPDATE `rules`
215 // SET `daily_actions_run` =
216 // IF(last_action_date != $1, 1, daily_actions_run + 1)
217 // SET `last_action_date` = $1
218 // WHERE `id` IN (...);
219 // But it lets us keep the code in Sequelize, which is probably worth it.
220 await db.Rule.increment(
221 { dailyActionsRun: 1 },
222 { where: { id: { [Op.in]: ruleIds }, lastActionDate: today } },
223 );
224
225 await db.Rule.update(
226 { dailyActionsRun: 1, lastActionDate: today },
227 {
228 where: {
229 id: { [Op.in]: ruleIds },
230 lastActionDate: { [Op.ne]: today },
231 },
232 },
233 );
234 });
235 }
236
237 return tracer.traced(
238 {
239 resource: 'ruleEngine',
240 operation: 'recordActionLimitUsage',
241 attributesFromArgs: ([ruleIds]) => ({ ruleIds }),
242 },
243 recordRuleActionLimitUsage,
244 );
245 },
246);
247
248export type RecordRuleActionLimitUsage = (
249 ruleIds: readonly string[],
250) => Promise<void>;