1import * as deadline from "@distilled.cloud/aws/deadline";
2import * as Effect from "effect/Effect";
3import * as EffectStream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags, type Tags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 asPlain,
13 deadlineArnOf,
14 fetchDeadlineTags,
15 retryWhileFarmSettling,
16 syncDeadlineTags,
17} from "./internal.ts";
18
19export type BudgetStatus = deadline.BudgetStatus;
20export type BudgetActionType = deadline.BudgetActionType;
21
22export interface BudgetActionProps {
23 /**
24 * Action taken when the threshold is crossed
25 * (`STOP_SCHEDULING_AND_COMPLETE_TASKS` or
26 * `STOP_SCHEDULING_AND_CANCEL_TASKS`).
27 */
28 type: BudgetActionType;
29 /**
30 * Percentage of the dollar limit at which the action fires (e.g. `100`).
31 */
32 thresholdPercentage: number;
33 /**
34 * A description of the action.
35 */
36 description?: string;
37}
38
39export interface BudgetScheduleProps {
40 /**
41 * Fixed budget window.
42 */
43 fixed: {
44 /**
45 * ISO-8601 start of the budget window (e.g. `2026-01-01T00:00:00Z`).
46 */
47 startTime: string;
48 /**
49 * ISO-8601 end of the budget window.
50 */
51 endTime: string;
52 };
53}
54
55export interface BudgetProps {
56 /**
57 * The identifier of the farm the budget belongs to. Changing it replaces
58 * the budget.
59 */
60 farmId: string;
61 /**
62 * The identifier of the queue whose usage the budget tracks. Changing it
63 * replaces the budget.
64 */
65 queueId: string;
66 /**
67 * Display name of the budget.
68 * @default ${app}-${stage}-${id}
69 */
70 displayName?: string;
71 /**
72 * A description of the budget.
73 */
74 description?: string;
75 /**
76 * Approximate dollar limit of the budget.
77 */
78 approximateDollarLimit: number;
79 /**
80 * Threshold actions taken as usage approaches the limit.
81 */
82 actions: BudgetActionProps[];
83 /**
84 * The period the budget is measured over.
85 */
86 schedule: BudgetScheduleProps;
87 /**
88 * Whether the budget is evaluated (`ACTIVE`) or ignored (`INACTIVE`).
89 * @default "ACTIVE"
90 */
91 status?: BudgetStatus;
92 /**
93 * Tags to associate with the budget.
94 */
95 tags?: Record<string, string>;
96}
97
98export interface Budget extends Resource<
99 "AWS.Deadline.Budget",
100 BudgetProps,
101 {
102 /**
103 * The identifier of the farm the budget belongs to.
104 */
105 farmId: string;
106 /**
107 * Service-assigned unique identifier of the budget (`budget-...`).
108 */
109 budgetId: string;
110 /**
111 * ARN of the budget.
112 */
113 budgetArn: string;
114 /**
115 * The identifier of the queue whose usage the budget tracks.
116 */
117 queueId: string;
118 /**
119 * The budget's display name.
120 */
121 displayName: string;
122 /**
123 * Whether the budget is currently evaluated.
124 */
125 status: BudgetStatus;
126 /**
127 * The configured approximate dollar limit.
128 */
129 approximateDollarLimit: number;
130 /**
131 * Current tags reported for the budget.
132 */
133 tags: Record<string, string>;
134 },
135 never,
136 Providers
137> {}
138
139/**
140 * An AWS Deadline Cloud budget — tracks a queue's approximate render spend
141 * over a fixed window and stops scheduling when thresholds are crossed.
142 *
143 * ### Creating Budgets
144 * **Example:** Queue Budget with Hard Stop
145 * ```typescript
146 * import * as AWS from "alchemy/AWS";
147 *
148 * const budget = yield* AWS.Deadline.Budget("MonthlyBudget", {
149 * farmId: farm.farmId,
150 * queueId: queue.queueId,
151 * approximateDollarLimit: 100,
152 * actions: [
153 * { type: "STOP_SCHEDULING_AND_COMPLETE_TASKS", thresholdPercentage: 100 },
154 * ],
155 * schedule: {
156 * fixed: {
157 * startTime: "2026-01-01T00:00:00Z",
158 * endTime: "2027-01-01T00:00:00Z",
159 * },
160 * },
161 * });
162 * ```
163 *
164 * **Example:** Graduated Thresholds
165 * ```typescript
166 * // Let in-flight tasks finish at 90%, cancel everything at 100%.
167 * const budget = yield* AWS.Deadline.Budget("QueueBudget", {
168 * farmId: farm.farmId,
169 * queueId: queue.queueId,
170 * approximateDollarLimit: 500,
171 * actions: [
172 * { type: "STOP_SCHEDULING_AND_COMPLETE_TASKS", thresholdPercentage: 90 },
173 * { type: "STOP_SCHEDULING_AND_CANCEL_TASKS", thresholdPercentage: 100 },
174 * ],
175 * schedule: {
176 * fixed: {
177 * startTime: "2026-01-01T00:00:00Z",
178 * endTime: "2026-02-01T00:00:00Z",
179 * },
180 * },
181 * });
182 * ```
183 *
184 * @resource
185 */
186export const Budget = Resource<Budget>("AWS.Deadline.Budget");
187
188const createBudgetName = (
189 id: string,
190 props: { displayName?: string | undefined },
191) =>
192 props.displayName
193 ? Effect.succeed(props.displayName)
194 : createPhysicalName({ id, maxLength: 100 });
195
196interface BudgetState {
197 attrs: Budget["Attributes"];
198 described: deadline.GetBudgetResponse;
199}
200
201const readBudgetById = Effect.fn(function* (
202 farmId: string,
203 budgetId: string,
204 arnOf: (path: string) => string,
205) {
206 const described = yield* deadline
207 .getBudget({ farmId, budgetId })
208 .pipe(
209 Effect.catchTag("ResourceNotFoundException", () =>
210 Effect.succeed(undefined),
211 ),
212 );
213 if (!described) return undefined;
214 const budgetArn = arnOf(`farm/${farmId}/budget/${described.budgetId}`);
215 const state: BudgetState = {
216 described,
217 attrs: {
218 farmId,
219 budgetId: described.budgetId,
220 budgetArn,
221 queueId: described.usageTrackingResource.queueId,
222 displayName: described.displayName,
223 status: described.status,
224 approximateDollarLimit: described.approximateDollarLimit,
225 tags: yield* fetchDeadlineTags(budgetArn),
226 },
227 };
228 return state;
229});
230
231const findBudgetByDisplayName = Effect.fn(function* (
232 farmId: string,
233 displayName: string,
234 arnOf: (path: string) => string,
235) {
236 const summaries = yield* deadline.listBudgets.items({ farmId }).pipe(
237 EffectStream.runCollect,
238 Effect.map((chunk) => Array.from(chunk)),
239 // The parent farm may itself be gone.
240 Effect.catchTag("ResourceNotFoundException", () =>
241 Effect.succeed([] as deadline.BudgetSummary[]),
242 ),
243 );
244 const match = summaries.find(
245 (summary) => summary.displayName === displayName,
246 );
247 if (!match) return undefined;
248 return yield* readBudgetById(farmId, match.budgetId, arnOf);
249});
250
251const actionKey = (action: {
252 type: BudgetActionType;
253 thresholdPercentage: number;
254}) => `${action.type}@${action.thresholdPercentage}`;
255
256const toWireSchedule = (
257 schedule: BudgetScheduleProps,
258): deadline.BudgetSchedule => ({
259 fixed: {
260 startTime: new Date(schedule.fixed.startTime),
261 endTime: new Date(schedule.fixed.endTime),
262 },
263});
264
265export const BudgetProvider = () =>
266 Provider.effect(
267 Budget,
268 Effect.gen(function* () {
269 return {
270 stables: ["farmId", "budgetId", "budgetArn", "queueId"],
271 // Keyed by a parent farm — sub-resource list() convention.
272 list: () => Effect.succeed([]),
273 read: Effect.fn(function* ({ id, olds, output }) {
274 const arnOf = yield* deadlineArnOf;
275 const farmId = output?.farmId ?? olds?.farmId;
276 if (farmId === undefined) return undefined;
277 const state = output?.budgetId
278 ? yield* readBudgetById(farmId, output.budgetId, arnOf)
279 : yield* findBudgetByDisplayName(
280 farmId,
281 yield* createBudgetName(id, olds ?? {}),
282 arnOf,
283 );
284 if (!state) return undefined;
285 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
286 ? state.attrs
287 : Unowned(state.attrs);
288 }),
289 diff: Effect.fn(function* ({ news, olds }) {
290 if (!isResolved(news)) return;
291 if (olds === undefined) return;
292 // The parent farm and tracked queue are fixed at creation.
293 if (olds.farmId !== news.farmId || olds.queueId !== news.queueId) {
294 return { action: "replace" } as const;
295 }
296 }),
297 reconcile: Effect.fn(function* ({ id, news, output, session }) {
298 if (news === undefined) {
299 return yield* Effect.fail(
300 new Error("AWS.Deadline.Budget requires props"),
301 );
302 }
303 const arnOf = yield* deadlineArnOf;
304 const farmId = news.farmId;
305 const displayName = yield* createBudgetName(id, news);
306 const internalTags = yield* createInternalTags(id);
307 const desiredTags = { ...internalTags, ...news.tags };
308 const desiredSchedule = toWireSchedule(news.schedule);
309
310 // Observe.
311 let state = output?.budgetId
312 ? yield* readBudgetById(farmId, output.budgetId, arnOf)
313 : yield* findBudgetByDisplayName(farmId, displayName, arnOf);
314
315 // Ensure.
316 if (state === undefined) {
317 const created = yield* retryWhileFarmSettling(
318 deadline.createBudget({
319 farmId,
320 displayName,
321 description: news.description,
322 usageTrackingResource: { queueId: news.queueId },
323 approximateDollarLimit: news.approximateDollarLimit,
324 actions: news.actions,
325 schedule: desiredSchedule,
326 tags: desiredTags,
327 }),
328 );
329 yield* session.note(
330 `Created budget ${displayName} (${created.budgetId})`,
331 );
332 state = yield* readBudgetById(farmId, created.budgetId, arnOf);
333 if (state === undefined) {
334 return yield* Effect.fail(
335 new Error(`failed to read created budget ${displayName}`),
336 );
337 }
338 }
339
340 // Sync mutable settings — compute action/schedule deltas from
341 // OBSERVED state.
342 const described = state.described;
343 const observedActions = described.actions.map((action) => ({
344 type: action.type,
345 thresholdPercentage: action.thresholdPercentage,
346 }));
347 const observedKeys = new Set(observedActions.map(actionKey));
348 const desiredKeys = new Set(news.actions.map(actionKey));
349 const actionsToAdd = news.actions.filter(
350 (action) => !observedKeys.has(actionKey(action)),
351 );
352 const actionsToRemove = observedActions.filter(
353 (action) => !desiredKeys.has(actionKey(action)),
354 );
355 const observedSchedule = described.schedule.fixed;
356 // Deadline clamps a startTime in the past to the creation instant,
357 // so a past desired startTime can never converge — only compare it
358 // while it is still in the future.
359 const now = yield* Effect.sync(() => Date.now());
360 const desiredStart = desiredSchedule.fixed.startTime.getTime();
361 const scheduleDrifted =
362 (desiredStart > now &&
363 observedSchedule.startTime.getTime() !== desiredStart) ||
364 observedSchedule.endTime.getTime() !==
365 desiredSchedule.fixed.endTime.getTime();
366 const desiredStatus = news.status ?? described.status;
367 const needsUpdate =
368 displayName !== described.displayName ||
369 (news.description !== undefined &&
370 news.description !== (asPlain(described.description) ?? "")) ||
371 desiredStatus !== described.status ||
372 news.approximateDollarLimit !== described.approximateDollarLimit ||
373 actionsToAdd.length > 0 ||
374 actionsToRemove.length > 0 ||
375 scheduleDrifted;
376 if (needsUpdate) {
377 yield* retryWhileFarmSettling(
378 deadline.updateBudget({
379 farmId,
380 budgetId: state.attrs.budgetId,
381 displayName,
382 description: news.description,
383 status: news.status,
384 approximateDollarLimit: news.approximateDollarLimit,
385 actionsToAdd:
386 actionsToAdd.length > 0 ? actionsToAdd : undefined,
387 actionsToRemove:
388 actionsToRemove.length > 0 ? actionsToRemove : undefined,
389 schedule: scheduleDrifted ? desiredSchedule : undefined,
390 }),
391 );
392 yield* session.note(`Updated budget ${displayName}`);
393 }
394
395 // Sync tags — diff against observed cloud tags.
396 yield* syncDeadlineTags(state.attrs.budgetArn, desiredTags);
397
398 yield* session.note(state.attrs.budgetArn);
399 const final = yield* readBudgetById(
400 farmId,
401 state.attrs.budgetId,
402 arnOf,
403 );
404 if (!final) {
405 return yield* Effect.fail(
406 new Error(`failed to read reconciled budget ${displayName}`),
407 );
408 }
409 return final.attrs;
410 }),
411 delete: Effect.fn(function* ({ output }) {
412 yield* deadline
413 .deleteBudget({
414 farmId: output.farmId,
415 budgetId: output.budgetId,
416 })
417 .pipe(
418 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
419 );
420 }),
421 };
422 }),
423 );