perf(api): чистить старые журналы и облегчить синк и dashboard
- раз в сутки удаляются notification_log и sync_log старше 90 дней и vps_health_checks старше 30 дней; синки, зависшие в running дольше часа, помечаются ошибкой - dashboard stats не грузит тарифы, домены и проекты - запросы характеристик VPS в BILLmanager идут по 4 параллельно Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b9b1cad54
commit
80af888cdb
9 files changed
+198
-16
No files matched your search
@@ -135,3 +135,55 @@ describe('space viewer cannot write', () => {
|
|||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('whole-DB operations need a global admin', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.AUTH_REQUIRED = 'true'
|
||||||
|
process.env.AUTH_JWT_SECRET = 'test-secret-at-least-8'
|
||||||
|
resetTestDb()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.AUTH_REQUIRED
|
||||||
|
delete process.env.AUTH_JWT_SECRET
|
||||||
|
closeDb()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('403 for settings admin, 200 for global admin', async () => {
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = (sub: string, isAdmin: boolean) =>
|
||||||
|
app.jwt.sign({
|
||||||
|
sub,
|
||||||
|
email: `${sub}@t`,
|
||||||
|
name: sub,
|
||||||
|
apps: ['vps'],
|
||||||
|
permissions: ['vps:settings:admin'],
|
||||||
|
is_admin: isAdmin,
|
||||||
|
iss: app.authConfig.issuer,
|
||||||
|
})
|
||||||
|
const get = (t: string) =>
|
||||||
|
app.inject({ url: '/api/backup/json', headers: { authorization: `Bearer ${t}` } })
|
||||||
|
expect((await get(token('space-admin', false))).statusCode).toBe(403)
|
||||||
|
const migrate = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/migrate',
|
||||||
|
headers: { authorization: `Bearer ${token('space-admin', false)}` },
|
||||||
|
payload: {},
|
||||||
|
})
|
||||||
|
expect(migrate.statusCode).toBe(403)
|
||||||
|
expect((await get(token('root', true))).statusCode).toBe(200)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dashboard stats', () => {
|
||||||
|
beforeEach(() => resetTestDb())
|
||||||
|
afterEach(() => closeDb())
|
||||||
|
|
||||||
|
it('computes from the slim snapshot', async () => {
|
||||||
|
const app = await buildApp()
|
||||||
|
const res = await app.inject({ url: '/api/dashboard/stats' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json()).toMatchObject({ totalVpsCount: 0, providerCount: 0 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mapConcurrent } from './sync.js'
|
||||||
|
|
||||||
|
describe('mapConcurrent', () => {
|
||||||
|
it('keeps order and caps parallelism', async () => {
|
||||||
|
let active = 0
|
||||||
|
let peak = 0
|
||||||
|
const out = await mapConcurrent([5, 1, 4, 2, 3, 0], 2, async (n) => {
|
||||||
|
active++
|
||||||
|
peak = Math.max(peak, active)
|
||||||
|
await new Promise((r) => setTimeout(r, n))
|
||||||
|
active--
|
||||||
|
return n * 10
|
||||||
|
})
|
||||||
|
expect(out).toEqual([50, 10, 40, 20, 30, 0])
|
||||||
|
expect(peak).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,6 +88,26 @@ async function mapVdsWithSpecs(
|
|||||||
return vps
|
return vps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** vds.edit lookups are one HTTP call per VPS — run a few at once, not all (hoster rate limits). */
|
||||||
|
const VDS_SPECS_CONCURRENCY = 4
|
||||||
|
|
||||||
|
export async function mapConcurrent<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<R>,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const out: R[] = new Array(items.length)
|
||||||
|
let next = 0
|
||||||
|
const worker = async () => {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++
|
||||||
|
out[i] = await fn(items[i]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncFromBillmanager(
|
export async function syncFromBillmanager(
|
||||||
account: BillmanagerSyncAccount,
|
account: BillmanagerSyncAccount,
|
||||||
opts: SyncFromBillmanagerOptions = {},
|
opts: SyncFromBillmanagerOptions = {},
|
||||||
@@ -138,20 +158,9 @@ export async function syncFromBillmanager(
|
|||||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||||
|
|
||||||
if (fetchVpsPayments) {
|
if (fetchVpsPayments) {
|
||||||
const mappedRaw: Awaited<ReturnType<typeof mapVdsWithSpecs>>[] = []
|
const mappedRaw = await mapConcurrent(vdsItems, VDS_SPECS_CONCURRENCY, (item) =>
|
||||||
for (const item of vdsItems) {
|
mapVdsWithSpecs(profile, item, providerId, accountId, tariffItems, apiBaseUrl, authinfo),
|
||||||
mappedRaw.push(
|
|
||||||
await mapVdsWithSpecs(
|
|
||||||
profile,
|
|
||||||
item,
|
|
||||||
providerId,
|
|
||||||
accountId,
|
|
||||||
tariffItems,
|
|
||||||
apiBaseUrl,
|
|
||||||
authinfo,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
const mappedList = profile.map.enrichVdsBatch
|
const mappedList = profile.map.enrichVdsBatch
|
||||||
? profile.map.enrichVdsBatch(mappedRaw, {
|
? profile.map.enrichVdsBatch(mappedRaw, {
|
||||||
balance: dashboardInfo?.balance ?? null,
|
balance: dashboardInfo?.balance ?? null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
import { getDashboardSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||||
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
|
import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health'
|
||||||
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
|
import { accountBalanceApi } from '@cfdm/shared/utils/account-balance'
|
||||||
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
import { isSyncApiType } from '@cfdm/shared/contracts/provider'
|
||||||
@@ -76,7 +76,7 @@ export interface DashboardStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function computeDashboardStats(): DashboardStats {
|
export function computeDashboardStats(): DashboardStats {
|
||||||
const snap = getSnapshot()
|
const snap = getDashboardSnapshot()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
const activeVps = snap.vps.filter((v) => v.status === 'active')
|
const activeVps = snap.vps.filter((v) => v.status === 'active')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import { getDb, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
|
import { getDb, getSqlite, pruneOldLogs, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
|
||||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||||
|
|
||||||
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
|
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
|
||||||
@@ -25,6 +25,8 @@ let notifyIntervalId: ReturnType<typeof setInterval> | null = null
|
|||||||
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let cfdmSyncIntervalId: ReturnType<typeof setInterval> | null = null
|
let cfdmSyncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let cfdmSyncInFlight = false
|
let cfdmSyncInFlight = false
|
||||||
|
let retentionIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
const RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||||
type SettingsRow = typeof schema.settings.$inferSelect
|
type SettingsRow = typeof schema.settings.$inferSelect
|
||||||
@@ -109,6 +111,12 @@ export const runNotificationTick = () => exclusive('notify', notificationTick)
|
|||||||
export const runScheduledSync = () => exclusive('sync', scheduledSync)
|
export const runScheduledSync = () => exclusive('sync', scheduledSync)
|
||||||
export const runScheduledSyncTariffs = () => exclusive('tariffs', scheduledSyncTariffs)
|
export const runScheduledSyncTariffs = () => exclusive('tariffs', scheduledSyncTariffs)
|
||||||
export const runScheduledUptimeChecks = () => exclusive('uptime', scheduledUptimeChecks)
|
export const runScheduledUptimeChecks = () => exclusive('uptime', scheduledUptimeChecks)
|
||||||
|
export const runRetention = () =>
|
||||||
|
exclusive('retention', async () => {
|
||||||
|
const r = pruneOldLogs(getSqlite())
|
||||||
|
const total = r.notificationLog + r.syncLog + r.vpsHealthChecks + r.staleSyncs
|
||||||
|
if (total > 0) console.log('Retention:', JSON.stringify(r))
|
||||||
|
})
|
||||||
|
|
||||||
async function notificationTick(): Promise<void> {
|
async function notificationTick(): Promise<void> {
|
||||||
for (const settings of allSettings()) {
|
for (const settings of allSettings()) {
|
||||||
@@ -334,6 +342,8 @@ export function startScheduler(): void {
|
|||||||
uptimeIntervalId = null
|
uptimeIntervalId = null
|
||||||
if (cfdmSyncIntervalId) clearInterval(cfdmSyncIntervalId)
|
if (cfdmSyncIntervalId) clearInterval(cfdmSyncIntervalId)
|
||||||
cfdmSyncIntervalId = null
|
cfdmSyncIntervalId = null
|
||||||
|
if (retentionIntervalId) clearInterval(retentionIntervalId)
|
||||||
|
retentionIntervalId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = allSettings()
|
const rows = allSettings()
|
||||||
@@ -350,6 +360,8 @@ export function startScheduler(): void {
|
|||||||
)
|
)
|
||||||
void runNotificationTick()
|
void runNotificationTick()
|
||||||
void runScheduledUptimeChecks()
|
void runScheduledUptimeChecks()
|
||||||
|
retentionIntervalId = setInterval(() => void runRetention(), RETENTION_INTERVAL_MS)
|
||||||
|
void runRetention()
|
||||||
|
|
||||||
const parts = [
|
const parts = [
|
||||||
`notify every ${notifyInterval} min`,
|
`notify every ${notifyInterval} min`,
|
||||||
@@ -393,6 +405,8 @@ export function stopScheduler(): void {
|
|||||||
uptimeIntervalId = null
|
uptimeIntervalId = null
|
||||||
if (cfdmSyncIntervalId) clearInterval(cfdmSyncIntervalId)
|
if (cfdmSyncIntervalId) clearInterval(cfdmSyncIntervalId)
|
||||||
cfdmSyncIntervalId = null
|
cfdmSyncIntervalId = null
|
||||||
|
if (retentionIntervalId) clearInterval(retentionIntervalId)
|
||||||
|
retentionIntervalId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restartScheduler(): void {
|
export function restartScheduler(): void {
|
||||||
|
|||||||
@@ -98,3 +98,4 @@ export {
|
|||||||
consolidateProviderApiFromAccounts,
|
consolidateProviderApiFromAccounts,
|
||||||
heuristicBillmanagerProviderApi,
|
heuristicBillmanagerProviderApi,
|
||||||
} from './maintenance/consolidate-api.js'
|
} from './maintenance/consolidate-api.js'
|
||||||
|
export { pruneOldLogs, RETENTION_DAYS, type PruneResult } from './maintenance/retention.js'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ensureRuntimeSchema, resetRuntimeMigrate } from '../runtime-migrate.js'
|
||||||
|
import { pruneOldLogs } from './retention.js'
|
||||||
|
|
||||||
|
describe('pruneOldLogs', () => {
|
||||||
|
it('drops old rows, keeps fresh ones, closes stale running syncs', () => {
|
||||||
|
const db = new Database(':memory:')
|
||||||
|
resetRuntimeMigrate()
|
||||||
|
ensureRuntimeSchema(db)
|
||||||
|
resetRuntimeMigrate()
|
||||||
|
const now = new Date('2026-06-01T00:00:00Z')
|
||||||
|
const old = '2026-01-01T00:00:00Z'
|
||||||
|
const fresh = '2026-05-31T23:30:00Z'
|
||||||
|
db.exec(`PRAGMA foreign_keys = OFF`)
|
||||||
|
for (const [id, at] of [['n-old', old], ['n-new', fresh]]) {
|
||||||
|
db.prepare(`INSERT INTO notification_log (id, event, channel, status, createdAt) VALUES (?, 'e', 'telegram', 'sent', ?)`).run(id, at)
|
||||||
|
db.prepare(`INSERT INTO vps_health_checks (id, vpsId, checkedAt, status) VALUES (?, 'v', ?, 'up')`).run(id, at)
|
||||||
|
}
|
||||||
|
db.prepare(`INSERT INTO sync_log (id, accountId, startedAt, status) VALUES ('s-old', 'a', ?, 'ok')`).run(old)
|
||||||
|
db.prepare(`INSERT INTO sync_log (id, accountId, startedAt, status) VALUES ('s-stuck', 'a', '2026-05-31T20:00:00Z', 'running')`).run()
|
||||||
|
db.prepare(`INSERT INTO sync_log (id, accountId, startedAt, status) VALUES ('s-live', 'a', ?, 'running')`).run(fresh)
|
||||||
|
|
||||||
|
expect(pruneOldLogs(db, now)).toEqual({ notificationLog: 1, syncLog: 1, vpsHealthChecks: 1, staleSyncs: 1 })
|
||||||
|
const status = (id: string) => (db.prepare(`SELECT status FROM sync_log WHERE id = ?`).get(id) as { status: string }).status
|
||||||
|
expect(status('s-stuck')).toBe('error')
|
||||||
|
expect(status('s-live')).toBe('running')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type Database from 'better-sqlite3'
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export const RETENTION_DAYS = {
|
||||||
|
notificationLog: 90,
|
||||||
|
syncLog: 90,
|
||||||
|
vpsHealthChecks: 30,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** A sync that is still "running" after this long was cut off by a crash/restart. */
|
||||||
|
const STALE_RUNNING_SYNC_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
export interface PruneResult {
|
||||||
|
notificationLog: number
|
||||||
|
syncLog: number
|
||||||
|
vpsHealthChecks: number
|
||||||
|
staleSyncs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete old journal rows (all spaces) and close sync_log rows left 'running' by a dead process. */
|
||||||
|
export function pruneOldLogs(sqlite: Database.Database, now = new Date()): PruneResult {
|
||||||
|
const before = (days: number) => new Date(now.getTime() - days * DAY_MS).toISOString()
|
||||||
|
const nowIso = now.toISOString()
|
||||||
|
return sqlite.transaction(() => ({
|
||||||
|
staleSyncs: sqlite
|
||||||
|
.prepare(
|
||||||
|
`UPDATE sync_log SET status = 'error', finishedAt = ?, error = 'Прервано: процесс перезапущен'
|
||||||
|
WHERE status = 'running' AND startedAt < ?`,
|
||||||
|
)
|
||||||
|
.run(nowIso, new Date(now.getTime() - STALE_RUNNING_SYNC_MS).toISOString()).changes,
|
||||||
|
notificationLog: sqlite
|
||||||
|
.prepare(`DELETE FROM notification_log WHERE createdAt < ?`)
|
||||||
|
.run(before(RETENTION_DAYS.notificationLog)).changes,
|
||||||
|
syncLog: sqlite
|
||||||
|
.prepare(`DELETE FROM sync_log WHERE startedAt < ?`)
|
||||||
|
.run(before(RETENTION_DAYS.syncLog)).changes,
|
||||||
|
vpsHealthChecks: sqlite
|
||||||
|
.prepare(`DELETE FROM vps_health_checks WHERE checkedAt < ?`)
|
||||||
|
.run(before(RETENTION_DAYS.vpsHealthChecks)).changes,
|
||||||
|
}))()
|
||||||
|
}
|
||||||
@@ -49,6 +49,23 @@ function listVpsWithShared(): VpsDto[] {
|
|||||||
return [...owned, ...shared]
|
return [...owned, ...shared]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DashboardSnapshot = Pick<
|
||||||
|
Snapshot,
|
||||||
|
'vps' | 'providers' | 'providerAccounts' | 'payments' | 'balanceLedger' | 'syncLog'
|
||||||
|
>
|
||||||
|
|
||||||
|
/** What dashboard stats read — skips tariffs, domains, projects and settings of the full snapshot. */
|
||||||
|
export function getDashboardSnapshot(): DashboardSnapshot {
|
||||||
|
return {
|
||||||
|
vps: listVpsWithShared(),
|
||||||
|
providers: providersRepository.list(),
|
||||||
|
providerAccounts: providerAccountsRepository.list(),
|
||||||
|
payments: paymentsRepository.list(),
|
||||||
|
balanceLedger: balanceLedgerRepository.list(),
|
||||||
|
syncLog: syncLogRepository.listRecent(50),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getSnapshot(): Snapshot {
|
export function getSnapshot(): Snapshot {
|
||||||
const spaceId = getCurrentSpaceId()
|
const spaceId = getCurrentSpaceId()
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in new issue
Block a user