mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
feat: Add some foundation
This commit is contained in:
@@ -1,155 +0,0 @@
|
||||
import {
|
||||
type IngestionContext,
|
||||
IngestionStrategy,
|
||||
type IngestionStrategyCustomer,
|
||||
type IngestionStrategyExternalCustomer,
|
||||
} from "@polar-sh/ingestion";
|
||||
|
||||
// Define the context specific to bandwidth usage
|
||||
export type BandwidthStrategyContext = IngestionContext<{
|
||||
megabytesTransferred: number;
|
||||
durationSeconds: number;
|
||||
direction: "inbound" | "outbound" | "both";
|
||||
protocol?: string;
|
||||
}>;
|
||||
|
||||
// Bandwidth rates per MB
|
||||
const DEFAULT_CREDITS_PER_MB = 0.01; // 1 credit per 100 MB
|
||||
|
||||
// Client interface for bandwidth tracking
|
||||
export interface BandwidthClient {
|
||||
// Track bandwidth usage
|
||||
recordBandwidthUsage: (options: {
|
||||
megabytesTransferred: number;
|
||||
durationSeconds?: number;
|
||||
direction?: "inbound" | "outbound" | "both";
|
||||
protocol?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}) => Promise<{ creditsUsed: number }>;
|
||||
|
||||
// Start a bandwidth tracking session
|
||||
startBandwidthSession: () => {
|
||||
sessionId: string;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
// End a bandwidth tracking session with the total transferred
|
||||
endBandwidthSession: (options: {
|
||||
sessionId: string;
|
||||
megabytesTransferred: number;
|
||||
direction?: "inbound" | "outbound" | "both";
|
||||
protocol?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}) => Promise<{
|
||||
creditsUsed: number;
|
||||
durationSeconds: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Bandwidth tracking strategy
|
||||
export class BandwidthStrategy extends IngestionStrategy<BandwidthStrategyContext, BandwidthClient> {
|
||||
private creditsPerMB: number;
|
||||
private activeSessions: Map<string, {
|
||||
startTime: number;
|
||||
customer: IngestionStrategyCustomer | IngestionStrategyExternalCustomer;
|
||||
}> = new Map();
|
||||
|
||||
constructor(options: { creditsPerMB?: number } = {}) {
|
||||
super();
|
||||
this.creditsPerMB = options.creditsPerMB ?? DEFAULT_CREDITS_PER_MB;
|
||||
}
|
||||
|
||||
// Calculate credits for a specific amount of bandwidth
|
||||
private calculateCredits(megabytesTransferred: number): number {
|
||||
return megabytesTransferred * this.creditsPerMB;
|
||||
}
|
||||
|
||||
override client(
|
||||
customer: IngestionStrategyCustomer | IngestionStrategyExternalCustomer
|
||||
): BandwidthClient {
|
||||
const executionHandler = this.createExecutionHandler();
|
||||
|
||||
return {
|
||||
// Record a single bandwidth usage event
|
||||
recordBandwidthUsage: async ({
|
||||
megabytesTransferred,
|
||||
durationSeconds = 0,
|
||||
direction = "both",
|
||||
protocol = "webrtc",
|
||||
metadata = {}
|
||||
}) => {
|
||||
// Calculate credits used
|
||||
const creditsUsed = this.calculateCredits(megabytesTransferred);
|
||||
|
||||
// Create the bandwidth usage context
|
||||
const usageContext: BandwidthStrategyContext = {
|
||||
megabytesTransferred,
|
||||
durationSeconds,
|
||||
direction,
|
||||
protocol,
|
||||
...metadata
|
||||
};
|
||||
|
||||
// Send the usage data to Polar
|
||||
await executionHandler(usageContext, customer);
|
||||
|
||||
return { creditsUsed };
|
||||
},
|
||||
|
||||
// Start tracking a bandwidth session
|
||||
startBandwidthSession: () => {
|
||||
const sessionId = `bw-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Store the session data
|
||||
this.activeSessions.set(sessionId, {
|
||||
startTime,
|
||||
customer
|
||||
});
|
||||
|
||||
return { sessionId, startTime };
|
||||
},
|
||||
|
||||
// End a bandwidth tracking session
|
||||
endBandwidthSession: async ({
|
||||
sessionId,
|
||||
megabytesTransferred,
|
||||
direction = "both",
|
||||
protocol = "webrtc",
|
||||
metadata = {}
|
||||
}) => {
|
||||
// Get the session data
|
||||
const sessionData = this.activeSessions.get(sessionId);
|
||||
if (!sessionData) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
const durationSeconds = (Date.now() - sessionData.startTime) / 1000;
|
||||
|
||||
// Calculate credits used
|
||||
const creditsUsed = this.calculateCredits(megabytesTransferred);
|
||||
|
||||
// Create the bandwidth usage context
|
||||
const usageContext: BandwidthStrategyContext = {
|
||||
megabytesTransferred,
|
||||
durationSeconds,
|
||||
direction,
|
||||
protocol,
|
||||
...metadata
|
||||
};
|
||||
|
||||
// Send the usage data to Polar
|
||||
await executionHandler(usageContext, customer);
|
||||
|
||||
// Clean up session data
|
||||
this.activeSessions.delete(sessionId);
|
||||
|
||||
return {
|
||||
creditsUsed,
|
||||
durationSeconds
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
15
packages/core/src/billing/billing.sql.ts
Normal file
15
packages/core/src/billing/billing.sql.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { bigint, pgEnum, pgTable } from "drizzle-orm/pg-core";
|
||||
import { teamID, timestamps } from "../drizzle/types";
|
||||
|
||||
export const CreditsType = ["gpu", "bandwidth", "storage"] as const;
|
||||
export const creditsEnum = pgEnum('credits_type', CreditsType);
|
||||
|
||||
export const usage = pgTable(
|
||||
"usage",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
type: creditsEnum("type").notNull(),
|
||||
creditsUsed: bigint("credits_used", { mode: "number" }).notNull(),
|
||||
}
|
||||
)
|
||||
@@ -1,262 +0,0 @@
|
||||
import {
|
||||
type IngestionContext,
|
||||
IngestionStrategy,
|
||||
type IngestionStrategyCustomer,
|
||||
type IngestionStrategyExternalCustomer,
|
||||
} from "@polar-sh/ingestion";
|
||||
|
||||
// Define known GPU types and their credit rates
|
||||
export type GPUModel = "3080" | "4080" | "4090" | string;
|
||||
|
||||
// Map of GPU models to their credit costs per minute
|
||||
const GPU_CREDIT_RATES: Record<GPUModel, number> = {
|
||||
"3080": 3,
|
||||
"4080": 6,
|
||||
"4090": 9,
|
||||
// Default for unknown models
|
||||
"default": 1
|
||||
};
|
||||
|
||||
// Define the context specific to GPU usage
|
||||
export type GPUStrategyContext = IngestionContext<{
|
||||
gpuModel: GPUModel;
|
||||
durationMinutes: number;
|
||||
utilizationPercent: number;
|
||||
memoryUsedMB: number;
|
||||
}>;
|
||||
|
||||
// Define the client interface we'll return
|
||||
export interface GPUClient {
|
||||
startSession: (options: {
|
||||
gpuModel: GPUModel;
|
||||
instanceId: string;
|
||||
onMetrics?: (metrics: {
|
||||
utilizationPercent: number;
|
||||
memoryUsedMB: number;
|
||||
}) => void;
|
||||
}) => Promise<{ sessionId: string }>;
|
||||
|
||||
endSession: (sessionId: string) => Promise<{
|
||||
durationMinutes: number;
|
||||
creditsUsed: number;
|
||||
finalMetrics: {
|
||||
avgUtilization: number;
|
||||
peakMemoryUsed: number;
|
||||
}
|
||||
}>;
|
||||
|
||||
getActiveSessionMetrics: (sessionId: string) => Promise<{
|
||||
durationMinutes: number;
|
||||
currentUtilization: number;
|
||||
memoryUsedMB: number;
|
||||
} | null>;
|
||||
}
|
||||
|
||||
export class GPUUsageStrategy extends IngestionStrategy<GPUStrategyContext, GPUClient> {
|
||||
private activeSessions: Map<string, {
|
||||
startTime: number;
|
||||
gpuModel: GPUModel;
|
||||
instanceId: string;
|
||||
customer: IngestionStrategyCustomer | IngestionStrategyExternalCustomer;
|
||||
metrics: {
|
||||
utilizationSamples: number[];
|
||||
memoryUsageSamples: number[];
|
||||
lastUpdateTime: number;
|
||||
};
|
||||
}> = new Map();
|
||||
|
||||
private metricsPollingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Start the metrics polling system
|
||||
this.startMetricsPolling();
|
||||
}
|
||||
|
||||
private startMetricsPolling() {
|
||||
// Poll every 30 seconds to collect metrics from active sessions
|
||||
this.metricsPollingInterval = setInterval(() => {
|
||||
this.collectMetricsForActiveSessions();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private async collectMetricsForActiveSessions() {
|
||||
for (const [sessionId, sessionData] of this.activeSessions.entries()) {
|
||||
try {
|
||||
// In a real implementation, you would call your GPU monitoring service here
|
||||
const metrics = await this.fetchGPUMetrics(sessionData.instanceId, sessionData.gpuModel);
|
||||
|
||||
// Store the metrics
|
||||
sessionData.metrics.utilizationSamples.push(metrics.utilizationPercent);
|
||||
sessionData.metrics.memoryUsageSamples.push(metrics.memoryUsedMB);
|
||||
sessionData.metrics.lastUpdateTime = Date.now();
|
||||
|
||||
// Update the session data
|
||||
this.activeSessions.set(sessionId, sessionData);
|
||||
} catch (error) {
|
||||
console.error(`Failed to collect metrics for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This would connect to your actual GPU monitoring system
|
||||
private async fetchGPUMetrics(instanceId: string, gpuModel: GPUModel): Promise<{
|
||||
utilizationPercent: number;
|
||||
memoryUsedMB: number;
|
||||
}> {
|
||||
// Mock implementation - in a real system you would:
|
||||
// 1. Call your GPU monitoring API
|
||||
// 2. Parse and return the actual metrics
|
||||
|
||||
// For this example, we're generating random values
|
||||
return {
|
||||
utilizationPercent: Math.floor(Math.random() * 100),
|
||||
memoryUsedMB: Math.floor(Math.random() *
|
||||
(gpuModel === "4090" ? 24000 :
|
||||
gpuModel === "4080" ? 16000 :
|
||||
gpuModel === "3080" ? 10000 : 8000))
|
||||
};
|
||||
}
|
||||
|
||||
// Get credit rate for a given GPU model
|
||||
private getCreditsForGPU(gpuModel: GPUModel): number {
|
||||
return GPU_CREDIT_RATES[gpuModel] || GPU_CREDIT_RATES.default;
|
||||
}
|
||||
|
||||
// Calculate average from array of samples
|
||||
private calculateAverage(samples: number[]): number {
|
||||
if (samples.length === 0) return 0;
|
||||
return samples.reduce((sum, value) => sum + value, 0) / samples.length;
|
||||
}
|
||||
|
||||
// Find maximum value in array of samples
|
||||
private findPeakValue(samples: number[]): number {
|
||||
if (samples.length === 0) return 0;
|
||||
return Math.max(...samples);
|
||||
}
|
||||
|
||||
// Clean up resources when the strategy is no longer needed
|
||||
public cleanup() {
|
||||
if (this.metricsPollingInterval) {
|
||||
clearInterval(this.metricsPollingInterval);
|
||||
this.metricsPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
override client(
|
||||
customer: IngestionStrategyCustomer | IngestionStrategyExternalCustomer
|
||||
): GPUClient {
|
||||
const executionHandler = this.createExecutionHandler();
|
||||
|
||||
return {
|
||||
startSession: async ({ gpuModel, instanceId, onMetrics }) => {
|
||||
const sessionId = `${instanceId}-${Date.now()}`;
|
||||
|
||||
// Initialize the session tracking
|
||||
this.activeSessions.set(sessionId, {
|
||||
startTime: Date.now(),
|
||||
gpuModel,
|
||||
instanceId,
|
||||
customer,
|
||||
metrics: {
|
||||
utilizationSamples: [],
|
||||
memoryUsageSamples: [],
|
||||
lastUpdateTime: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
// Set up a callback for metrics if provided
|
||||
if (onMetrics) {
|
||||
const metricsInterval = setInterval(async () => {
|
||||
const sessionData = this.activeSessions.get(sessionId);
|
||||
if (!sessionData) {
|
||||
clearInterval(metricsInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestMetrics = await this.fetchGPUMetrics(instanceId, gpuModel);
|
||||
onMetrics(latestMetrics);
|
||||
}, 60000); // Send metrics callback once per minute
|
||||
}
|
||||
|
||||
return { sessionId };
|
||||
},
|
||||
|
||||
endSession: async (sessionId) => {
|
||||
const sessionData = this.activeSessions.get(sessionId);
|
||||
if (!sessionData) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
// Calculate duration in minutes
|
||||
const durationMinutes = (Date.now() - sessionData.startTime) / (1000 * 60);
|
||||
|
||||
// Calculate average utilization and peak memory
|
||||
const avgUtilization = this.calculateAverage(sessionData.metrics.utilizationSamples);
|
||||
const peakMemoryUsed = this.findPeakValue(sessionData.metrics.memoryUsageSamples);
|
||||
|
||||
// Get credit rate for this GPU model
|
||||
const creditsPerMinute = this.getCreditsForGPU(sessionData.gpuModel);
|
||||
|
||||
// Calculate total credits used
|
||||
const totalCredits = creditsPerMinute * durationMinutes;
|
||||
|
||||
// Create the usage context for this GPU session
|
||||
const usageContext: GPUStrategyContext = {
|
||||
gpuModel: sessionData.gpuModel,
|
||||
durationMinutes,
|
||||
utilizationPercent: avgUtilization,
|
||||
memoryUsedMB: peakMemoryUsed
|
||||
};
|
||||
|
||||
// Send the usage data to Polar
|
||||
await executionHandler(usageContext, sessionData.customer);
|
||||
|
||||
// Clean up session tracking
|
||||
this.activeSessions.delete(sessionId);
|
||||
|
||||
return {
|
||||
durationMinutes,
|
||||
creditsUsed: Math.ceil(totalCredits),
|
||||
finalMetrics: {
|
||||
avgUtilization,
|
||||
peakMemoryUsed
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
getActiveSessionMetrics: async (sessionId) => {
|
||||
const sessionData = this.activeSessions.get(sessionId);
|
||||
if (!sessionData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentDurationMinutes = (Date.now() - sessionData.startTime) / (1000 * 60);
|
||||
|
||||
// Get the most recent metrics or fetch new ones if needed
|
||||
const lastMetricsUpdateAgeMs = Date.now() - sessionData.metrics.lastUpdateTime;
|
||||
|
||||
// If metrics are older than 2 minutes, fetch fresh data
|
||||
let currentUtilization = 0;
|
||||
let memoryUsedMB = 0;
|
||||
|
||||
if (lastMetricsUpdateAgeMs > 120000) {
|
||||
const freshMetrics = await this.fetchGPUMetrics(sessionData.instanceId, sessionData.gpuModel);
|
||||
currentUtilization = freshMetrics.utilizationPercent;
|
||||
memoryUsedMB = freshMetrics.memoryUsedMB;
|
||||
} else if (sessionData.metrics.utilizationSamples.length > 0) {
|
||||
// Use the most recent sample
|
||||
const lastIdx = sessionData.metrics.utilizationSamples.length - 1;
|
||||
currentUtilization = sessionData.metrics.utilizationSamples[lastIdx];
|
||||
memoryUsedMB = sessionData.metrics.memoryUsageSamples[lastIdx];
|
||||
}
|
||||
|
||||
return {
|
||||
durationMinutes: currentDurationMinutes,
|
||||
currentUtilization,
|
||||
memoryUsedMB
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { CreditsType } from "./billing.sql";
|
||||
|
||||
export namespace Billing {
|
||||
|
||||
export const Info = z.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Usage.id,
|
||||
}),
|
||||
creditsUsed: z.number().openapi({
|
||||
description: "The credits used",
|
||||
example: Examples.Usage.creditsUsed
|
||||
}),
|
||||
type: z.enum(CreditsType).openapi({
|
||||
description: "The type of credits this was billed on"
|
||||
}),
|
||||
// game:
|
||||
// session:
|
||||
})
|
||||
.openapi({
|
||||
ref: "Billing",
|
||||
description: "Represents a usage billing",
|
||||
example: Examples.Usage,
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
type IngestionContext,
|
||||
IngestionStrategy,
|
||||
type IngestionStrategyCustomer,
|
||||
type IngestionStrategyExternalCustomer,
|
||||
} from "@polar-sh/ingestion";
|
||||
|
||||
// Define the context specific to storage usage
|
||||
export type StorageStrategyContext = IngestionContext<{
|
||||
storageSizeGB: number;
|
||||
storageType: string;
|
||||
storageLocation: string;
|
||||
}>;
|
||||
|
||||
// Define the client interface for storage operations
|
||||
export interface StorageClient {
|
||||
// Record a storage snapshot for a customer
|
||||
recordStorageUsage: (options: {
|
||||
storageSizeGB: number;
|
||||
storageType?: string;
|
||||
storageLocation?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}) => Promise<void>;
|
||||
|
||||
// Calculate costs for a specific storage amount
|
||||
calculateStorageCost: (sizeGB: number) => {
|
||||
creditsUsed: number;
|
||||
ratePerGB: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Storage strategy implementation
|
||||
export class StorageStrategy extends IngestionStrategy<StorageStrategyContext, StorageClient> {
|
||||
// Rate in credits per GB
|
||||
private creditsPerGB: number;
|
||||
|
||||
constructor(options: { creditsPerGB?: number } = {}) {
|
||||
super();
|
||||
// Default to 3 credits per GB, but allow customization
|
||||
this.creditsPerGB = options.creditsPerGB ?? 3;
|
||||
}
|
||||
|
||||
override client(
|
||||
customer: IngestionStrategyCustomer | IngestionStrategyExternalCustomer
|
||||
): StorageClient {
|
||||
const executionHandler = this.createExecutionHandler();
|
||||
|
||||
return {
|
||||
// Record storage usage for a customer
|
||||
recordStorageUsage: async ({
|
||||
storageSizeGB,
|
||||
storageType = "default",
|
||||
storageLocation = "default",
|
||||
metadata = {}
|
||||
}) => {
|
||||
// Create the storage usage context
|
||||
const usageContext: StorageStrategyContext = {
|
||||
storageSizeGB,
|
||||
storageType,
|
||||
storageLocation,
|
||||
...metadata
|
||||
};
|
||||
|
||||
// Send the usage data to Polar
|
||||
await executionHandler(usageContext, customer);
|
||||
},
|
||||
|
||||
// Calculate the cost for a specific storage amount
|
||||
calculateStorageCost: (sizeGB: number) => {
|
||||
const creditsUsed = sizeGB * this.creditsPerGB;
|
||||
return {
|
||||
creditsUsed,
|
||||
ratePerGB: this.creditsPerGB
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,22 @@ export namespace Examples {
|
||||
steamAccounts: [Steam]
|
||||
};
|
||||
|
||||
export const game = {
|
||||
id: Id("game")
|
||||
}
|
||||
|
||||
export const session = {
|
||||
id: Id("session")
|
||||
}
|
||||
|
||||
export const Usage = {
|
||||
id: Id("usage"),
|
||||
creditsUsed: 20,
|
||||
type: "gpu" as const, //or bandwidth, storage
|
||||
game: [game],
|
||||
session: [session]
|
||||
}
|
||||
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "RTX 4090",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { teamIndexes } from "../team/team.sql";
|
||||
import { timestamps, utc, teamID } from "../drizzle/types";
|
||||
import { index, pgTable, text, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
import { index, pgEnum, pgTable, text, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const role = ["admin", "member", "owner"] as const;
|
||||
const pgRole = pgEnum("role", role)
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"member",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
role: text("role", { enum: role }).notNull(),
|
||||
role: pgRole().notNull(),
|
||||
timeSeen: utc("time_seen"),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,9 @@ export const prefixes = {
|
||||
subscription: "sub",
|
||||
invite: "inv",
|
||||
product: "prd",
|
||||
usage: "usg",
|
||||
game: "gme",
|
||||
session: "ssn"
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user