673 lines
22 KiB
TypeScript
673 lines
22 KiB
TypeScript
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;
|