This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
import {
|
||||
ALLOCATION_STATUSES,
|
||||
CONSUMING_ALLOCATION_STATUSES,
|
||||
RESERVING_ALLOCATION_STATUSES,
|
||||
} from '@pig/core';
|
||||
import type {
|
||||
AllocationStatus,
|
||||
GuaranteeType,
|
||||
GpuSocket,
|
||||
InterconnectType,
|
||||
SecurityTier,
|
||||
} from '@pig/core';
|
||||
import type {
|
||||
Allocation,
|
||||
CapacityCommitment,
|
||||
Database,
|
||||
DemandDeal,
|
||||
NewCapacityCommitment,
|
||||
} from '@pig/db';
|
||||
import { allocations, capacityCommitments, demandDeals } from '@pig/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Principal } from '../lib/auth';
|
||||
import { MutationError } from '../lib/mutation';
|
||||
import { quantityAt, type CommitmentShape } from './capacity';
|
||||
|
||||
export type CapacityWriteTransaction = Parameters<Parameters<Database['transaction']>[0]>[0];
|
||||
|
||||
export interface CommitmentCapacity {
|
||||
id: string;
|
||||
gpuCount: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
totalGpuHours: number;
|
||||
shape: CommitmentShape | null;
|
||||
oversubscriptionPct: number;
|
||||
terminatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ReservationCapacity {
|
||||
id?: string;
|
||||
gpuHours: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
status: AllocationStatus;
|
||||
holdExpiresAt: Date | null;
|
||||
}
|
||||
|
||||
export interface CapacityViolation {
|
||||
code: 'outside_commitment_window' | 'total_capacity_exceeded' | 'shape_capacity_exceeded';
|
||||
message: string;
|
||||
at?: Date;
|
||||
reserved?: number;
|
||||
available?: number;
|
||||
}
|
||||
|
||||
const EPSILON = 1e-7;
|
||||
|
||||
function isLiveReservation(reservation: ReservationCapacity, now: Date): boolean {
|
||||
if (!(RESERVING_ALLOCATION_STATUSES as readonly string[]).includes(reservation.status)) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
reservation.status === 'planned' &&
|
||||
reservation.holdExpiresAt !== null &&
|
||||
reservation.holdExpiresAt <= now
|
||||
);
|
||||
}
|
||||
|
||||
function reservationRate(reservation: ReservationCapacity): number {
|
||||
const durationHours =
|
||||
(reservation.endsAt.getTime() - reservation.startsAt.getTime()) / 3_600_000;
|
||||
return durationHours > 0 ? reservation.gpuHours / durationHours : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks both the contracted hour budget and every instantaneous shape interval.
|
||||
* The row lock used by the caller turns this pure check into a concurrency-safe
|
||||
* invariant rather than an optimistic preflight that two requests can both pass.
|
||||
*/
|
||||
export function findCapacityViolation(
|
||||
commitment: CommitmentCapacity,
|
||||
existing: readonly ReservationCapacity[],
|
||||
candidate: ReservationCapacity | null,
|
||||
now: Date,
|
||||
): CapacityViolation | null {
|
||||
const reservations = [...existing, ...(candidate ? [candidate] : [])].filter((reservation) =>
|
||||
isLiveReservation(reservation, now),
|
||||
);
|
||||
|
||||
for (const reservation of reservations) {
|
||||
if (
|
||||
reservation.startsAt < commitment.startsAt ||
|
||||
reservation.endsAt > commitment.endsAt ||
|
||||
reservation.endsAt <= reservation.startsAt
|
||||
) {
|
||||
return {
|
||||
code: 'outside_commitment_window',
|
||||
message: 'The allocation window must sit inside the commitment window.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const multiplier = 1 + commitment.oversubscriptionPct / 100;
|
||||
const allowedGpuHours = commitment.totalGpuHours * multiplier;
|
||||
const reservedGpuHours = reservations.reduce((sum, reservation) => sum + reservation.gpuHours, 0);
|
||||
if (reservedGpuHours - allowedGpuHours > EPSILON) {
|
||||
return {
|
||||
code: 'total_capacity_exceeded',
|
||||
message: 'The allocation would exceed the commitment GPU-hour budget.',
|
||||
reserved: reservedGpuHours,
|
||||
available: allowedGpuHours,
|
||||
};
|
||||
}
|
||||
|
||||
const boundaries = new Set<number>([
|
||||
commitment.startsAt.getTime(),
|
||||
commitment.endsAt.getTime(),
|
||||
...(commitment.shape?.intervals.map((boundary) => Date.parse(boundary)) ?? []),
|
||||
]);
|
||||
for (const reservation of reservations) {
|
||||
boundaries.add(reservation.startsAt.getTime());
|
||||
boundaries.add(reservation.endsAt.getTime());
|
||||
}
|
||||
|
||||
const ordered = [...boundaries]
|
||||
.filter(
|
||||
(boundary) =>
|
||||
Number.isFinite(boundary) &&
|
||||
boundary >= commitment.startsAt.getTime() &&
|
||||
boundary <= commitment.endsAt.getTime(),
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (let index = 0; index < ordered.length - 1; index++) {
|
||||
const intervalStart = ordered[index]!;
|
||||
const intervalEnd = ordered[index + 1]!;
|
||||
if (intervalEnd <= intervalStart) continue;
|
||||
|
||||
const overlapping = reservations.filter(
|
||||
(reservation) =>
|
||||
reservation.startsAt.getTime() < intervalEnd &&
|
||||
reservation.endsAt.getTime() > intervalStart,
|
||||
);
|
||||
if (overlapping.length === 0) continue;
|
||||
|
||||
const at = new Date(intervalStart + (intervalEnd - intervalStart) / 2);
|
||||
const reserved = overlapping.reduce(
|
||||
(sum, reservation) => sum + reservationRate(reservation),
|
||||
0,
|
||||
);
|
||||
const available = quantityAt(commitment.shape, commitment.gpuCount, at) * multiplier;
|
||||
if (reserved - available > EPSILON) {
|
||||
return {
|
||||
code: 'shape_capacity_exceeded',
|
||||
message: 'The allocation would exceed available GPUs during part of its window.',
|
||||
at,
|
||||
reserved,
|
||||
available,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function commitmentCapacityError(commitment: CommitmentCapacity): string | null {
|
||||
const durationHours =
|
||||
(commitment.endsAt.getTime() - commitment.startsAt.getTime()) / 3_600_000;
|
||||
if (durationHours <= 0) return 'Commitment end must be after its start.';
|
||||
if (commitment.oversubscriptionPct < 0) return 'Oversubscription cannot be negative.';
|
||||
|
||||
const shape = commitment.shape;
|
||||
if (!shape) {
|
||||
if (commitment.totalGpuHours - durationHours * commitment.gpuCount > EPSILON) {
|
||||
return 'Total GPU-hours cannot exceed the flat commitment envelope.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shape.intervals.length < 2 || shape.quantities.length !== shape.intervals.length - 1) {
|
||||
return 'Shape quantities must have exactly one entry per interval.';
|
||||
}
|
||||
|
||||
const boundaries = shape.intervals.map((boundary) => Date.parse(boundary));
|
||||
if (boundaries.some((boundary) => !Number.isFinite(boundary))) {
|
||||
return 'Shape intervals must be valid ISO-8601 timestamps.';
|
||||
}
|
||||
if (
|
||||
boundaries[0] !== commitment.startsAt.getTime() ||
|
||||
boundaries[boundaries.length - 1] !== commitment.endsAt.getTime()
|
||||
) {
|
||||
return 'Shape intervals must cover the full commitment window.';
|
||||
}
|
||||
for (let index = 0; index < boundaries.length - 1; index++) {
|
||||
if (boundaries[index + 1]! <= boundaries[index]!) {
|
||||
return 'Shape intervals must be strictly ascending.';
|
||||
}
|
||||
}
|
||||
if (
|
||||
shape.quantities.some(
|
||||
(quantity) => !Number.isInteger(quantity) || quantity < 0 || quantity > commitment.gpuCount,
|
||||
)
|
||||
) {
|
||||
return 'Shape quantities must be whole GPUs within the commitment envelope.';
|
||||
}
|
||||
|
||||
const shapedGpuHours = shape.quantities.reduce(
|
||||
(sum, quantity, index) =>
|
||||
sum + ((boundaries[index + 1]! - boundaries[index]!) / 3_600_000) * quantity,
|
||||
0,
|
||||
);
|
||||
if (commitment.totalGpuHours - shapedGpuHours > EPSILON) {
|
||||
return 'Total GPU-hours cannot exceed the capacity described by the shape.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface CommitmentWriteInput {
|
||||
accountId: string;
|
||||
siteId?: string | null;
|
||||
supplyDealId?: string | null;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
socket?: GpuSocket | null;
|
||||
gpuCount: number;
|
||||
interconnectType?: InterconnectType;
|
||||
securityTier?: SecurityTier;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
totalGpuHours: number;
|
||||
costPerGpuHourCents: number;
|
||||
currency?: string;
|
||||
shape?: CommitmentShape | null;
|
||||
colocateWith?: string[];
|
||||
isContiguous?: boolean;
|
||||
minimumSpendCents?: number | null;
|
||||
isAutoRenew?: boolean;
|
||||
noticeDays?: number | null;
|
||||
takeOrPayFloorPct?: number | null;
|
||||
prepaidPct?: number | null;
|
||||
prepaidAmountCents?: number | null;
|
||||
usefulLifeYears?: number | null;
|
||||
salvageValuePct?: number | null;
|
||||
depreciationStartAt?: string | null;
|
||||
costOfCapitalBps?: number | null;
|
||||
financingInstrument?: string | null;
|
||||
oversubscriptionPct?: number;
|
||||
notes?: string | null;
|
||||
terminatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AllocationWriteInput {
|
||||
capacityCommitmentId: string;
|
||||
demandDealId: string;
|
||||
gpuHours: number;
|
||||
pricePerGpuHourCents: number;
|
||||
currency?: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
status: (typeof CONSUMING_ALLOCATION_STATUSES)[number];
|
||||
guaranteeType?: GuaranteeType;
|
||||
priority?: number;
|
||||
complianceDecisionId?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface HoldWriteInput
|
||||
extends Omit<AllocationWriteInput, 'status' | 'pricePerGpuHourCents'> {
|
||||
pricePerGpuHourCents?: number;
|
||||
holdExpiresAt: string;
|
||||
holdOpportunityCostCents?: number | null;
|
||||
}
|
||||
|
||||
export interface ReleaseWriteInput {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CommitmentWriteResult {
|
||||
commitment: CapacityCommitment;
|
||||
}
|
||||
|
||||
export interface AllocationWriteResult {
|
||||
allocation: Allocation;
|
||||
commitment: Pick<CapacityCommitment, 'id' | 'name'>;
|
||||
deal: Pick<DemandDeal, 'id' | 'accountId'>;
|
||||
}
|
||||
|
||||
export interface ReleaseWriteResult {
|
||||
allocation: Allocation;
|
||||
accountId?: string;
|
||||
previousStatus: AllocationStatus;
|
||||
}
|
||||
|
||||
function toCapacity(commitment: CapacityCommitment): CommitmentCapacity {
|
||||
return {
|
||||
id: commitment.id,
|
||||
gpuCount: commitment.gpuCount,
|
||||
startsAt: commitment.startsAt,
|
||||
endsAt: commitment.endsAt,
|
||||
totalGpuHours: Number(commitment.totalGpuHours),
|
||||
shape: commitment.shape,
|
||||
oversubscriptionPct: Number(commitment.oversubscriptionPct),
|
||||
terminatedAt: commitment.terminatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toReservation(allocation: Allocation): ReservationCapacity {
|
||||
return {
|
||||
id: allocation.id,
|
||||
gpuHours: Number(allocation.gpuHours),
|
||||
startsAt: allocation.startsAt,
|
||||
endsAt: allocation.endsAt,
|
||||
status: allocation.status,
|
||||
holdExpiresAt: allocation.holdExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function assertCommitmentValid(commitment: CommitmentCapacity): void {
|
||||
const error = commitmentCapacityError(commitment);
|
||||
if (error) throw new MutationError('invalid_commitment', error, 400);
|
||||
}
|
||||
|
||||
function assertCapacityAvailable(
|
||||
commitment: CommitmentCapacity,
|
||||
existing: readonly Allocation[],
|
||||
candidate: ReservationCapacity | null,
|
||||
now: Date,
|
||||
): void {
|
||||
const violation = findCapacityViolation(
|
||||
commitment,
|
||||
existing.map(toReservation),
|
||||
candidate,
|
||||
now,
|
||||
);
|
||||
if (!violation) return;
|
||||
throw new MutationError(violation.code, violation.message, 409);
|
||||
}
|
||||
|
||||
export class CapacityWriteService {
|
||||
constructor(private readonly tx: CapacityWriteTransaction) {}
|
||||
|
||||
private async lockCommitment(id: string): Promise<CapacityCommitment> {
|
||||
const [commitment] = await this.tx
|
||||
.select()
|
||||
.from(capacityCommitments)
|
||||
.where(eq(capacityCommitments.id, id))
|
||||
.limit(1)
|
||||
.for('update');
|
||||
if (!commitment) throw MutationError.notFound('Capacity commitment');
|
||||
return commitment;
|
||||
}
|
||||
|
||||
private async reservations(commitmentId: string): Promise<Allocation[]> {
|
||||
return this.tx
|
||||
.select()
|
||||
.from(allocations)
|
||||
.where(eq(allocations.capacityCommitmentId, commitmentId));
|
||||
}
|
||||
|
||||
private async demandDeal(id: string): Promise<Pick<DemandDeal, 'id' | 'accountId'>> {
|
||||
const [deal] = await this.tx
|
||||
.select({ id: demandDeals.id, accountId: demandDeals.accountId })
|
||||
.from(demandDeals)
|
||||
.where(eq(demandDeals.id, id))
|
||||
.limit(1);
|
||||
if (!deal) throw MutationError.notFound('Demand deal');
|
||||
return deal;
|
||||
}
|
||||
|
||||
async createCommitment(input: CommitmentWriteInput, now: Date): Promise<CommitmentWriteResult> {
|
||||
const capacity: CommitmentCapacity = {
|
||||
id: 'new',
|
||||
gpuCount: input.gpuCount,
|
||||
startsAt: new Date(input.startsAt),
|
||||
endsAt: new Date(input.endsAt),
|
||||
totalGpuHours: input.totalGpuHours,
|
||||
shape: input.shape ?? null,
|
||||
oversubscriptionPct: input.oversubscriptionPct ?? 0,
|
||||
terminatedAt: null,
|
||||
};
|
||||
assertCommitmentValid(capacity);
|
||||
|
||||
const [commitment] = await this.tx
|
||||
.insert(capacityCommitments)
|
||||
.values({
|
||||
accountId: input.accountId,
|
||||
siteId: input.siteId,
|
||||
supplyDealId: input.supplyDealId,
|
||||
name: input.name,
|
||||
gpuType: input.gpuType,
|
||||
socket: input.socket,
|
||||
gpuCount: input.gpuCount,
|
||||
interconnectType: input.interconnectType,
|
||||
securityTier: input.securityTier,
|
||||
startsAt: capacity.startsAt,
|
||||
endsAt: capacity.endsAt,
|
||||
totalGpuHours: String(input.totalGpuHours),
|
||||
costPerGpuHourCents: input.costPerGpuHourCents,
|
||||
currency: input.currency,
|
||||
shape: input.shape,
|
||||
colocateWith: input.colocateWith,
|
||||
isContiguous: input.isContiguous,
|
||||
minimumSpendCents: input.minimumSpendCents,
|
||||
isAutoRenew: input.isAutoRenew,
|
||||
noticeDays: input.noticeDays,
|
||||
takeOrPayFloorPct:
|
||||
input.takeOrPayFloorPct === undefined || input.takeOrPayFloorPct === null
|
||||
? input.takeOrPayFloorPct
|
||||
: String(input.takeOrPayFloorPct),
|
||||
prepaidPct:
|
||||
input.prepaidPct === undefined || input.prepaidPct === null
|
||||
? input.prepaidPct
|
||||
: String(input.prepaidPct),
|
||||
prepaidAmountCents: input.prepaidAmountCents,
|
||||
usefulLifeYears:
|
||||
input.usefulLifeYears === undefined || input.usefulLifeYears === null
|
||||
? input.usefulLifeYears
|
||||
: String(input.usefulLifeYears),
|
||||
salvageValuePct:
|
||||
input.salvageValuePct === undefined || input.salvageValuePct === null
|
||||
? input.salvageValuePct
|
||||
: String(input.salvageValuePct),
|
||||
depreciationStartAt:
|
||||
input.depreciationStartAt === undefined
|
||||
? undefined
|
||||
: input.depreciationStartAt === null
|
||||
? null
|
||||
: new Date(input.depreciationStartAt),
|
||||
costOfCapitalBps: input.costOfCapitalBps,
|
||||
financingInstrument: input.financingInstrument,
|
||||
oversubscriptionPct: String(input.oversubscriptionPct ?? 0),
|
||||
notes: input.notes,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (!commitment) throw new MutationError('write_failed', 'Commitment was not created.', 409);
|
||||
return { commitment };
|
||||
}
|
||||
|
||||
async updateCommitment(
|
||||
id: string,
|
||||
input: Partial<CommitmentWriteInput>,
|
||||
now: Date,
|
||||
): Promise<CommitmentWriteResult> {
|
||||
const current = await this.lockCommitment(id);
|
||||
const existing = await this.reservations(id);
|
||||
const prospective: CommitmentCapacity = {
|
||||
id,
|
||||
gpuCount: input.gpuCount ?? current.gpuCount,
|
||||
startsAt: input.startsAt ? new Date(input.startsAt) : current.startsAt,
|
||||
endsAt: input.endsAt ? new Date(input.endsAt) : current.endsAt,
|
||||
totalGpuHours: input.totalGpuHours ?? Number(current.totalGpuHours),
|
||||
shape: input.shape === undefined ? current.shape : input.shape,
|
||||
oversubscriptionPct:
|
||||
input.oversubscriptionPct ?? Number(current.oversubscriptionPct),
|
||||
terminatedAt:
|
||||
input.terminatedAt === undefined
|
||||
? current.terminatedAt
|
||||
: input.terminatedAt
|
||||
? new Date(input.terminatedAt)
|
||||
: null,
|
||||
};
|
||||
assertCommitmentValid(prospective);
|
||||
assertCapacityAvailable(prospective, existing, null, now);
|
||||
if (
|
||||
prospective.terminatedAt &&
|
||||
existing.some((allocation) => isLiveReservation(toReservation(allocation), now))
|
||||
) {
|
||||
throw new MutationError(
|
||||
'commitment_in_use',
|
||||
'Release live allocations before terminating the commitment.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const changes: Partial<NewCapacityCommitment> = { updatedAt: now };
|
||||
const assign = <Key extends keyof CommitmentWriteInput>(
|
||||
key: Key,
|
||||
value: NewCapacityCommitment[keyof NewCapacityCommitment],
|
||||
) => {
|
||||
if (input[key] !== undefined) {
|
||||
(changes as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
};
|
||||
assign('accountId', input.accountId);
|
||||
assign('siteId', input.siteId);
|
||||
assign('supplyDealId', input.supplyDealId);
|
||||
assign('name', input.name);
|
||||
assign('gpuType', input.gpuType);
|
||||
assign('socket', input.socket);
|
||||
assign('gpuCount', input.gpuCount);
|
||||
assign('interconnectType', input.interconnectType);
|
||||
assign('securityTier', input.securityTier);
|
||||
assign('startsAt', prospective.startsAt);
|
||||
assign('endsAt', prospective.endsAt);
|
||||
assign('totalGpuHours', String(prospective.totalGpuHours));
|
||||
assign('costPerGpuHourCents', input.costPerGpuHourCents);
|
||||
assign('currency', input.currency);
|
||||
assign('shape', input.shape);
|
||||
assign('colocateWith', input.colocateWith);
|
||||
assign('isContiguous', input.isContiguous);
|
||||
assign('minimumSpendCents', input.minimumSpendCents);
|
||||
assign('isAutoRenew', input.isAutoRenew);
|
||||
assign('noticeDays', input.noticeDays);
|
||||
assign(
|
||||
'takeOrPayFloorPct',
|
||||
input.takeOrPayFloorPct === null ? null : String(input.takeOrPayFloorPct),
|
||||
);
|
||||
assign('prepaidPct', input.prepaidPct === null ? null : String(input.prepaidPct));
|
||||
assign('prepaidAmountCents', input.prepaidAmountCents);
|
||||
assign('usefulLifeYears', input.usefulLifeYears === null ? null : String(input.usefulLifeYears));
|
||||
assign('salvageValuePct', input.salvageValuePct === null ? null : String(input.salvageValuePct));
|
||||
assign(
|
||||
'depreciationStartAt',
|
||||
input.depreciationStartAt ? new Date(input.depreciationStartAt) : null,
|
||||
);
|
||||
assign('costOfCapitalBps', input.costOfCapitalBps);
|
||||
assign('financingInstrument', input.financingInstrument);
|
||||
assign('oversubscriptionPct', String(prospective.oversubscriptionPct));
|
||||
assign('notes', input.notes);
|
||||
assign('terminatedAt', prospective.terminatedAt);
|
||||
|
||||
const [commitment] = await this.tx
|
||||
.update(capacityCommitments)
|
||||
.set(changes)
|
||||
.where(eq(capacityCommitments.id, id))
|
||||
.returning();
|
||||
if (!commitment) throw MutationError.notFound('Capacity commitment');
|
||||
return { commitment };
|
||||
}
|
||||
|
||||
async createAllocation(
|
||||
input: AllocationWriteInput,
|
||||
principal: Principal,
|
||||
now: Date,
|
||||
): Promise<AllocationWriteResult> {
|
||||
const commitment = await this.lockCommitment(input.capacityCommitmentId);
|
||||
if (commitment.terminatedAt) {
|
||||
throw new MutationError('commitment_terminated', 'The commitment is terminated.', 409);
|
||||
}
|
||||
const deal = await this.demandDeal(input.demandDealId);
|
||||
const existing = await this.reservations(commitment.id);
|
||||
const candidate: ReservationCapacity = {
|
||||
gpuHours: input.gpuHours,
|
||||
startsAt: new Date(input.startsAt),
|
||||
endsAt: new Date(input.endsAt),
|
||||
status: input.status,
|
||||
holdExpiresAt: null,
|
||||
};
|
||||
assertCapacityAvailable(toCapacity(commitment), existing, candidate, now);
|
||||
|
||||
const [allocation] = await this.tx
|
||||
.insert(allocations)
|
||||
.values({
|
||||
capacityCommitmentId: commitment.id,
|
||||
demandDealId: deal.id,
|
||||
gpuHours: String(input.gpuHours),
|
||||
pricePerGpuHourCents: input.pricePerGpuHourCents,
|
||||
currency: input.currency,
|
||||
startsAt: candidate.startsAt,
|
||||
endsAt: candidate.endsAt,
|
||||
status: input.status,
|
||||
guaranteeType: input.guaranteeType,
|
||||
priority: input.priority,
|
||||
complianceDecisionId: input.complianceDecisionId,
|
||||
createdByUserId: principal.userId,
|
||||
notes: input.notes,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (!allocation) throw new MutationError('write_failed', 'Allocation was not created.', 409);
|
||||
return { allocation, commitment, deal };
|
||||
}
|
||||
|
||||
async createHold(
|
||||
input: HoldWriteInput,
|
||||
principal: Principal,
|
||||
now: Date,
|
||||
): Promise<AllocationWriteResult> {
|
||||
const holdExpiresAt = new Date(input.holdExpiresAt);
|
||||
if (holdExpiresAt <= now) {
|
||||
throw new MutationError('hold_already_expired', 'A new hold must expire in the future.', 400);
|
||||
}
|
||||
const commitment = await this.lockCommitment(input.capacityCommitmentId);
|
||||
if (commitment.terminatedAt) {
|
||||
throw new MutationError('commitment_terminated', 'The commitment is terminated.', 409);
|
||||
}
|
||||
const deal = await this.demandDeal(input.demandDealId);
|
||||
const existing = await this.reservations(commitment.id);
|
||||
const candidate: ReservationCapacity = {
|
||||
gpuHours: input.gpuHours,
|
||||
startsAt: new Date(input.startsAt),
|
||||
endsAt: new Date(input.endsAt),
|
||||
status: 'planned',
|
||||
holdExpiresAt,
|
||||
};
|
||||
assertCapacityAvailable(toCapacity(commitment), existing, candidate, now);
|
||||
|
||||
const [allocation] = await this.tx
|
||||
.insert(allocations)
|
||||
.values({
|
||||
capacityCommitmentId: commitment.id,
|
||||
demandDealId: deal.id,
|
||||
gpuHours: String(input.gpuHours),
|
||||
pricePerGpuHourCents: input.pricePerGpuHourCents ?? 0,
|
||||
currency: input.currency,
|
||||
startsAt: candidate.startsAt,
|
||||
endsAt: candidate.endsAt,
|
||||
status: 'planned',
|
||||
holdExpiresAt,
|
||||
holdOpportunityCostCents: input.holdOpportunityCostCents,
|
||||
guaranteeType: input.guaranteeType,
|
||||
priority: input.priority,
|
||||
complianceDecisionId: input.complianceDecisionId,
|
||||
createdByUserId: principal.userId,
|
||||
notes: input.notes,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (!allocation) throw new MutationError('write_failed', 'Hold was not created.', 409);
|
||||
return { allocation, commitment, deal };
|
||||
}
|
||||
|
||||
async releaseAllocation(
|
||||
id: string,
|
||||
_input: ReleaseWriteInput,
|
||||
now: Date,
|
||||
): Promise<ReleaseWriteResult> {
|
||||
const [reference] = await this.tx
|
||||
.select({ capacityCommitmentId: allocations.capacityCommitmentId })
|
||||
.from(allocations)
|
||||
.where(eq(allocations.id, id))
|
||||
.limit(1);
|
||||
if (!reference) throw MutationError.notFound('Allocation');
|
||||
await this.lockCommitment(reference.capacityCommitmentId);
|
||||
|
||||
const [current] = await this.tx
|
||||
.select()
|
||||
.from(allocations)
|
||||
.where(eq(allocations.id, id))
|
||||
.limit(1)
|
||||
.for('update');
|
||||
if (!current) throw MutationError.notFound('Allocation');
|
||||
if (current.status === 'released') {
|
||||
throw new MutationError('already_released', 'The allocation is already released.', 409);
|
||||
}
|
||||
if (current.status === 'completed') {
|
||||
throw new MutationError(
|
||||
'completed_allocation',
|
||||
'Completed billable usage cannot be released.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const [allocation] = await this.tx
|
||||
.update(allocations)
|
||||
.set({ status: 'released', releasedAt: now, holdExpiresAt: null, updatedAt: now })
|
||||
.where(eq(allocations.id, id))
|
||||
.returning();
|
||||
if (!allocation) throw MutationError.notFound('Allocation');
|
||||
|
||||
const deal = current.demandDealId
|
||||
? await this.demandDeal(current.demandDealId)
|
||||
: undefined;
|
||||
return { allocation, accountId: deal?.accountId, previousStatus: current.status };
|
||||
}
|
||||
}
|
||||
|
||||
export const VALID_ALLOCATION_STATUSES = ALLOCATION_STATUSES;
|
||||
Reference in New Issue
Block a user