fix(downloads): make remote usage billing resilient

This commit is contained in:
saltbo
2026-06-07 12:47:07 -04:00
parent e17e2018d3
commit ecb38df84d
9 changed files with 230 additions and 50 deletions
+2
View File
@@ -7,6 +7,7 @@ import { createLibsqlPlatform } from './platform/libsql'
import { createNodePlatform } from './platform/node'
import { syncPendingCloudTrafficReports } from './services/cloud-traffic-metering'
import { runLicensingRefresh } from './services/licensing-refresh-runner'
import { syncPendingRemoteDownloadUsageReports } from './services/remote-download-usage'
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
@@ -40,4 +41,5 @@ setInterval(() => {
console.log('traffic.sync.scheduler.started interval=10m')
setInterval(() => {
void syncPendingCloudTrafficReports({ db: platform.db, cloudBaseUrl })
void syncPendingRemoteDownloadUsageReports({ db: platform.db, cloudBaseUrl })
}, TRAFFIC_SYNC_INTERVAL_MS)
@@ -160,7 +160,7 @@ describe('object download cloud traffic reporting', () => {
])
})
it('records a failed report without refunding local traffic when Cloud returns a mismatched event id', async () => {
it('records a reported report without refunding local traffic when Cloud returns its own usage event id', async () => {
const { app, db } = await createTestApp()
await seedTrafficBinding(db)
vi.stubGlobal(
@@ -181,7 +181,7 @@ describe('object download cloud traffic reporting', () => {
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(rows[0].trafficUsed).toBe(125)
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'failed' }])
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'reported', error: null }])
})
it('keeps the pre-presign Cloud report and refunds local traffic when presign fails', async () => {
@@ -1,12 +1,16 @@
import type { Downloader, DownloadTask } from '@shared/types'
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { remoteDownloadUsageReports } from '../db/schema'
import { createLicenseBinding } from '../licensing/license-state'
import type { Database } from '../platform/interface'
import { S3Service } from '../services/s3.js'
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
type DownloadTaskList = { items: DownloadTask[] }
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'presignUpload').mockResolvedValue('https://presigned-upload.example.com')
vi.spyOn(S3Service.prototype, 'createMultipartUpload').mockResolvedValue('upload-1')
@@ -59,6 +63,15 @@ function transferProgress(input: {
}
}
function makeCloudResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response
}
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
const now = Date.now()
await db.run(sql`
@@ -75,6 +88,19 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
`)
}
async function seedCloudBinding(db: Database) {
await createLicenseBinding(db, {
cloudBindingId: 'download-billing-binding',
cloudStoreId: 'store-download-billing',
instanceId: 'test-instance',
cloudAccountId: 'test-account',
refreshToken: 'test-refresh-token',
cachedCert: 'test-certificate',
cachedExpiresAt: Math.floor(Date.now() / 1000) + 3600,
lastRefreshAt: Math.floor(Date.now() / 1000),
})
}
async function registerDownloaderThroughDeviceLogin(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
name: string,
@@ -596,6 +622,82 @@ describe('Download tasks API integration', () => {
expect(task.status.progress.upload.bytes).toBe(10 * 1024 * 1024)
})
it('accepts Cloud usage event ids that differ from local remote download idempotency keys', async () => {
const { app, db } = await createTestApp({
DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret',
ZPAN_CLOUD_URL: 'https://cloud.example',
})
await insertStorage(db)
await seedCloudBinding(db)
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
makeCloudResponse({ data: { accepted: true, duplicate: false, eventId: 'different-event-id' } }),
),
)
const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'billing-transient-downloader')
await db.run(sql`
UPDATE downloaders
SET remote_download_credit_billing_enabled = 1,
remote_download_credit_unit_bytes = ${5 * 1024 * 1024},
remote_download_credit_per_unit = 1
WHERE id = ${createdDownloader.downloader.id}
`)
const downloaderHeaders = {
Authorization: `Bearer ${createdDownloader.token}`,
'Content-Type': 'application/json',
}
const heartbeatRes = await app.request('/api/downloader/heartbeat', {
method: 'POST',
headers: downloaderHeaders,
body: JSON.stringify({ ...heartbeat, currentTasks: 0 }),
})
expect(heartbeatRes.status).toBe(200)
const user = await authedHeaders(app, 'download-billing-transient-user@example.com')
const createTaskRes = await app.request('/api/download-tasks', {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: { type: 'http', uri: 'https://example.com/billing-transient.bin' },
targetFolder: 'Remote Downloads',
}),
})
expect(createTaskRes.status).toBe(201)
const task = (await createTaskRes.json()) as DownloadTask
const patchRes = await app.request(`/api/download-tasks/${task.id}`, {
method: 'PATCH',
headers: downloaderHeaders,
body: JSON.stringify({
status: 'downloading',
...transferProgress({
downloadBytes: 5 * 1024 * 1024,
totalBytes: 10 * 1024 * 1024,
downloadBps: 512_000,
}),
}),
})
expect(patchRes.status).toBe(200)
await expect(patchRes.json()).resolves.toMatchObject({
status: {
state: 'downloading',
billing: { state: 'ok', chargedBytes: 5 * 1024 * 1024, chargedCredits: 1 },
},
})
await expect(db.select().from(remoteDownloadUsageReports)).resolves.toMatchObject([
{
eventId: `remote_download:${task.id}:1`,
status: 'reported',
error: null,
},
])
})
it('stores downloader runtime reports as snapshots while progress remains patchable', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
+6 -2
View File
@@ -8,6 +8,7 @@ import { normalizeHost } from '../licensing/verify'
import type { Env } from '../middleware/platform'
import { syncPendingCloudTrafficReports } from '../services/cloud-traffic-metering'
import { runLicensingRefresh } from '../services/licensing-refresh-runner'
import { syncPendingRemoteDownloadUsageReports } from '../services/remote-download-usage'
function configuredPublicHost(c: Context<Env>): string | null {
const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL')
@@ -63,9 +64,12 @@ const app = new Hono<Env>()
const db = c.get('platform').db
const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
const result = await syncPendingCloudTrafficReports({ db, cloudBaseUrl })
const [traffic, remoteDownload] = await Promise.all([
syncPendingCloudTrafficReports({ db, cloudBaseUrl }),
syncPendingRemoteDownloadUsageReports({ db, cloudBaseUrl }),
])
return c.json({ ok: true, ...result })
return c.json({ ok: true, ...traffic, remoteDownload })
})
export default app
+12 -1
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { syncPendingCloudTrafficReports } from '../server/services/cloud-traffic-metering'
import { runLicensingRefresh } from '../server/services/licensing-refresh-runner'
import { syncPendingRemoteDownloadUsageReports } from '../server/services/remote-download-usage'
import { handleScheduled } from '../workers/scheduled'
vi.mock('../server/platform/cloudflare', () => ({
@@ -17,16 +18,25 @@ vi.mock('../server/services/licensing-refresh-runner', () => ({
runLicensingRefresh: vi.fn(),
}))
vi.mock('../server/services/remote-download-usage', () => ({
syncPendingRemoteDownloadUsageReports: vi.fn(),
}))
describe('handleScheduled', () => {
beforeEach(() => {
vi.mocked(syncPendingCloudTrafficReports).mockReset()
vi.mocked(syncPendingRemoteDownloadUsageReports).mockReset()
vi.mocked(runLicensingRefresh).mockReset()
})
it('syncs traffic reports on the traffic cron only', async () => {
it('syncs usage reports on the traffic cron only', async () => {
await handleScheduled({ cron: '*/10 * * * *' }, { DB: {} as D1Database, ZPAN_CLOUD_URL: 'https://cloud.example' })
expect(syncPendingCloudTrafficReports).toHaveBeenCalledWith({ db: 'db', cloudBaseUrl: 'https://cloud.example' })
expect(syncPendingRemoteDownloadUsageReports).toHaveBeenCalledWith({
db: 'db',
cloudBaseUrl: 'https://cloud.example',
})
expect(runLicensingRefresh).not.toHaveBeenCalled()
})
@@ -35,5 +45,6 @@ describe('handleScheduled', () => {
expect(runLicensingRefresh).toHaveBeenCalledWith('db', 'https://cloud.example')
expect(syncPendingCloudTrafficReports).not.toHaveBeenCalled()
expect(syncPendingRemoteDownloadUsageReports).not.toHaveBeenCalled()
})
})
+5 -10
View File
@@ -248,15 +248,15 @@ describe('cloud traffic metering', () => {
])
})
it('marks reports failed when Cloud does not accept the same event id', async () => {
const { db, platform } = await createTestApp()
it('marks reports reported when Cloud returns its own usage event id', async () => {
const { db, platform } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' })
await seedTrafficBinding(db)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(makeResponse({ data: { accepted: true, duplicate: false, eventId: 'other_evt' } })),
)
await reportTrafficEgress({
const result = await reportTrafficEgress({
platform,
orgId: 'org_1',
bytes: 1024,
@@ -265,15 +265,10 @@ describe('cloud traffic metering', () => {
eventId: 'evt_mismatch',
...meteredStorage,
})
await expect(syncPendingCloudTrafficReports({ db, cloudBaseUrl: 'https://cloud.example' })).resolves.toEqual({
attempted: 1,
reported: 0,
blocked: 0,
failed: 1,
})
expect(result).toMatchObject({ status: 'reported', eventId: 'evt_mismatch', duplicate: false })
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
{ eventId: 'evt_mismatch', status: 'failed', error: 'cloud_usage_report_rejected' },
{ eventId: 'evt_mismatch', status: 'reported', error: null },
])
})
+1 -1
View File
@@ -173,7 +173,7 @@ async function syncTrafficReport(params: {
},
)
const response = usageResponseSchema.parse(data)
if (!response.accepted || response.eventId !== report.eventId) throw new Error('cloud_usage_report_rejected')
if (!response.accepted) throw new Error('cloud_usage_report_rejected')
await updateTrafficReport(db, report.eventId, 'reported', null, now)
return 'reported'
} catch (error) {
+98 -34
View File
@@ -1,9 +1,10 @@
import { eq } from 'drizzle-orm'
import { asc, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { z } from 'zod'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { remoteDownloadUsageReports } from '../db/schema'
import { loadActiveLicenseBinding } from '../licensing/license-state'
import type { Platform } from '../platform/interface'
import type { Database, Platform } from '../platform/interface'
import { postBoundCloudJson } from './licensing-cloud'
export class RemoteDownloadBillingBlockedError extends Error {
@@ -13,6 +14,15 @@ export class RemoteDownloadBillingBlockedError extends Error {
}
}
const usageResponseSchema = z.object({
accepted: z.boolean(),
duplicate: z.boolean().optional(),
eventId: z.string().min(1),
})
type RemoteDownloadUsageStatus = 'pending' | 'reported' | 'skipped_unbound' | 'blocked' | 'failed'
type RemoteDownloadUsageReport = typeof remoteDownloadUsageReports.$inferSelect
export async function reportRemoteDownloadUnit(params: {
platform: Platform
orgId: string
@@ -22,15 +32,15 @@ export async function reportRemoteDownloadUnit(params: {
unitBytes: number
creditsPerUnit: number
enabled: boolean
}): Promise<void> {
if (!params.enabled) return
}): Promise<{ status: RemoteDownloadUsageStatus; eventId: string }> {
if (!params.enabled) return { status: 'reported', eventId: '' }
const eventId = `remote_download:${params.taskId}:${params.unitIndex}`
const existing = await params.platform.db
.select()
.from(remoteDownloadUsageReports)
.where(eq(remoteDownloadUsageReports.eventId, eventId))
.limit(1)
if (existing[0]?.status === 'reported') return
if (existing[0]?.status === 'reported') return { status: 'reported', eventId }
if (existing[0]?.status === 'blocked') throw new RemoteDownloadBillingBlockedError()
const now = new Date()
@@ -51,46 +61,100 @@ export async function reportRemoteDownloadUnit(params: {
})
}
const binding = await loadActiveLicenseBinding(params.platform.db)
const status = await syncRemoteDownloadUsageReport({
db: params.platform.db,
cloudBaseUrl: params.platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT,
report: (await loadRemoteDownloadUsageReport(params.platform.db, eventId))!,
now,
})
if (status === 'blocked') throw new RemoteDownloadBillingBlockedError()
return { status, eventId }
}
export async function syncPendingRemoteDownloadUsageReports(params: {
db: Database
cloudBaseUrl: string
limit?: number
now?: Date
}): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> {
const { db, cloudBaseUrl, limit = 100, now = new Date() } = params
const binding = await loadActiveLicenseBinding(db)
if (!binding?.refreshToken || !binding.cloudStoreId) return { attempted: 0, reported: 0, blocked: 0, failed: 0 }
const reports = await db
.select()
.from(remoteDownloadUsageReports)
.where(inArray(remoteDownloadUsageReports.status, ['pending', 'failed']))
.orderBy(asc(remoteDownloadUsageReports.createdAt))
.limit(limit)
const result = { attempted: reports.length, reported: 0, blocked: 0, failed: 0 }
for (const report of reports) {
const status = await syncRemoteDownloadUsageReport({ db, cloudBaseUrl, report, now })
result[status] += 1
}
return result
}
async function syncRemoteDownloadUsageReport(params: {
db: Database
cloudBaseUrl: string
report: RemoteDownloadUsageReport
now: Date
}): Promise<'reported' | 'blocked' | 'failed'> {
const { db, cloudBaseUrl, report, now } = params
const binding = await loadActiveLicenseBinding(db)
if (!binding?.refreshToken || !binding.cloudStoreId) {
await mark(params.platform, eventId, 'reported', null)
return
await mark(db, report.eventId, 'skipped_unbound', null, now)
return 'reported'
}
try {
const response = (await postBoundCloudJson(
params.platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT,
`/api/stores/${encodeURIComponent(binding.cloudStoreId)}/billing/usage-events`,
binding.refreshToken,
{
resource: 'remote_download',
unit: 'byte',
bytes: params.unitBytes,
eventId,
idempotencyKey: eventId,
customerId: params.orgId,
source: 'remote_download',
sourceId: params.taskId,
usageContext: { downloaderId: params.downloaderId },
pricing: { unitQuantity: params.unitBytes, creditsPerUnit: params.creditsPerUnit },
},
)) as { accepted?: boolean; eventId?: string; error?: { code?: string } }
if (!response.accepted || response.eventId !== eventId) throw new Error('cloud_usage_report_rejected')
await mark(params.platform, eventId, 'reported', null)
const response = usageResponseSchema.parse(
await postBoundCloudJson(
cloudBaseUrl,
`/api/stores/${encodeURIComponent(binding.cloudStoreId)}/billing/usage-events`,
binding.refreshToken,
{
resource: 'remote_download',
unit: 'byte',
bytes: report.unitBytes,
eventId: report.eventId,
idempotencyKey: report.eventId,
customerId: report.orgId,
source: 'remote_download',
sourceId: report.taskId,
usageContext: { downloaderId: report.downloaderId },
pricing: { unitQuantity: report.unitBytes, creditsPerUnit: report.creditsPerUnit },
},
),
)
if (!response.accepted) throw new Error('cloud_usage_report_rejected')
await mark(db, report.eventId, 'reported', null, now)
return 'reported'
} catch (error) {
const message = error instanceof Error ? error.message : 'cloud_usage_report_failed'
if (message === 'insufficient_credits' || message === 'overage_cap_exceeded') {
await mark(params.platform, eventId, 'blocked', message)
throw new RemoteDownloadBillingBlockedError()
await mark(db, report.eventId, 'blocked', message, now)
return 'blocked'
}
await mark(params.platform, eventId, 'failed', message)
throw error
await mark(db, report.eventId, 'failed', message, now)
return 'failed'
}
}
async function mark(platform: Platform, eventId: string, status: string, error: string | null) {
await platform.db
async function loadRemoteDownloadUsageReport(db: Database, eventId: string) {
const rows = await db
.select()
.from(remoteDownloadUsageReports)
.where(eq(remoteDownloadUsageReports.eventId, eventId))
.limit(1)
return rows[0]
}
async function mark(db: Database, eventId: string, status: string, error: string | null, now: Date) {
await db
.update(remoteDownloadUsageReports)
.set({ status, error, updatedAt: new Date() })
.set({ status, error, updatedAt: now })
.where(eq(remoteDownloadUsageReports.eventId, eventId))
}
+2
View File
@@ -3,6 +3,7 @@
import { createCloudflarePlatform } from '../server/platform/cloudflare'
import { syncPendingCloudTrafficReports } from '../server/services/cloud-traffic-metering'
import { runLicensingRefresh } from '../server/services/licensing-refresh-runner'
import { syncPendingRemoteDownloadUsageReports } from '../server/services/remote-download-usage'
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
// Subset of the worker Env used by the scheduled handler.
@@ -21,6 +22,7 @@ export async function handleScheduled(event: ScheduledTrigger, env: ScheduledEnv
const cloudBaseUrl = env.ZPAN_CLOUD_URL ?? ZPAN_CLOUD_URL_DEFAULT
if (event.cron === TRAFFIC_SYNC_CRON) {
await syncPendingCloudTrafficReports({ db: platform.db, cloudBaseUrl })
await syncPendingRemoteDownloadUsageReports({ db: platform.db, cloudBaseUrl })
return
}