From 4fbd521fa8ebc180b5e14bc4ed85f2c56de30720 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Tue, 9 Dec 2025 23:26:30 -0300 Subject: [PATCH] refactor(convex): separar heartbeat em tabela dedicada para evitar OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Criar tabela machineHeartbeats para armazenar lastHeartbeatAt separadamente - Modificar heartbeat para so atualizar machines quando ha mudancas reais - Atualizar queries listByTenant e getById para usar nova tabela - Reducao drastica de versoes de documentos criadas a cada heartbeat Antes: ~54 versoes por maquina (3524 linhas para 65 maquinas) Agora: heartbeat atualiza documento leve, machines so muda com dados novos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- convex/_generated/api.d.ts | 30 +++++++---- convex/_generated/server.d.ts | 16 ++---- convex/_generated/server.js | 13 +++-- convex/machines.ts | 95 ++++++++++++++++++++++++++--------- convex/schema.ts | 8 +++ 5 files changed, 111 insertions(+), 51 deletions(-) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index d6ef58d..e8599fb 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -49,14 +49,6 @@ import type { FunctionReference, } from "convex/server"; -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ declare const fullApi: ApiFromModules<{ alerts: typeof alerts; bootstrap: typeof bootstrap; @@ -93,14 +85,30 @@ declare const fullApi: ApiFromModules<{ usbPolicy: typeof usbPolicy; users: typeof users; }>; -declare const fullApiWithMounts: typeof fullApi; +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ export declare const api: FilterApi< - typeof fullApiWithMounts, + typeof fullApi, FunctionReference >; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ export declare const internal: FilterApi< - typeof fullApiWithMounts, + typeof fullApi, FunctionReference >; diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts index b5c6828..bec05e6 100644 --- a/convex/_generated/server.d.ts +++ b/convex/_generated/server.d.ts @@ -10,7 +10,6 @@ import { ActionBuilder, - AnyComponents, HttpActionBuilder, MutationBuilder, QueryBuilder, @@ -19,15 +18,9 @@ import { GenericQueryCtx, GenericDatabaseReader, GenericDatabaseWriter, - FunctionReference, } from "convex/server"; import type { DataModel } from "./dataModel.js"; -type GenericCtx = - | GenericActionCtx - | GenericMutationCtx - | GenericQueryCtx; - /** * Define a query in this Convex app's public API. * @@ -92,11 +85,12 @@ export declare const internalAction: ActionBuilder; /** * Define an HTTP action. * - * This function will be used to respond to HTTP requests received by a Convex - * deployment if the requests matches the path and method where this action - * is routed. Be sure to route your action in `convex/http.js`. + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export declare const httpAction: HttpActionBuilder; diff --git a/convex/_generated/server.js b/convex/_generated/server.js index 4a21df4..bf3d25a 100644 --- a/convex/_generated/server.js +++ b/convex/_generated/server.js @@ -16,7 +16,6 @@ import { internalActionGeneric, internalMutationGeneric, internalQueryGeneric, - componentsGeneric, } from "convex/server"; /** @@ -81,10 +80,14 @@ export const action = actionGeneric; export const internalAction = internalActionGeneric; /** - * Define a Convex HTTP action. + * Define an HTTP action. * - * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object - * as its second. - * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export const httpAction = httpActionGeneric; diff --git a/convex/machines.ts b/convex/machines.ts index 9194caa..8f3be5e 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -251,6 +251,20 @@ function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value) } +// Busca o lastHeartbeatAt da tabela machineHeartbeats (fonte de verdade) +// Fallback para machine.lastHeartbeatAt para retrocompatibilidade durante migracao +async function getMachineLastHeartbeat( + ctx: QueryCtx | MutationCtx, + machineId: Id<"machines">, + fallback?: number | null +): Promise { + const hb = await ctx.db + .query("machineHeartbeats") + .withIndex("by_machine", (q) => q.eq("machineId", machineId)) + .first() + return hb?.lastHeartbeatAt ?? fallback ?? null +} + // Campos do inventory que sao muito grandes e nao devem ser persistidos // para evitar OOM no Convex (documentos de ~100KB cada) const INVENTORY_BLOCKLIST = new Set(["software", "extended"]) @@ -827,6 +841,20 @@ export const heartbeat = mutation({ const { machine, token } = await getActiveToken(ctx, args.machineToken) const now = Date.now() + // 1. SEMPRE atualizar machineHeartbeats (documento pequeno, upsert) + // Isso evita criar versoes do documento machines a cada heartbeat + const existingHeartbeat = await ctx.db + .query("machineHeartbeats") + .withIndex("by_machine", (q) => q.eq("machineId", machine._id)) + .first() + + if (existingHeartbeat) { + await ctx.db.patch(existingHeartbeat._id, { lastHeartbeatAt: now }) + } else { + await ctx.db.insert("machineHeartbeats", { machineId: machine._id, lastHeartbeatAt: now }) + } + + // 2. Preparar patch de metadata (se houver mudancas) const metadataPatch: Record = {} if (args.metadata && typeof args.metadata === "object") { Object.assign(metadataPatch, args.metadata as Record) @@ -841,22 +869,37 @@ export const heartbeat = mutation({ if (args.metrics && typeof args.metrics === "object") { metadataPatch.metrics = args.metrics as Record } - const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata - await ctx.db.patch(machine._id, { - hostname: args.hostname ?? machine.hostname, - displayName: machine.displayName ?? args.hostname ?? machine.hostname, - osName: args.os?.name ?? machine.osName, - osVersion: args.os?.version ?? machine.osVersion, - architecture: args.os?.architecture ?? machine.architecture, - devicePlatform: args.os?.name ?? machine.devicePlatform, - deviceType: machine.deviceType ?? "desktop", - managementMode: machine.managementMode ?? "agent", - lastHeartbeatAt: now, - updatedAt: now, - status: args.status ?? "online", - metadata: mergedMetadata, - }) + // 3. Verificar se ha mudancas reais nos dados que justifiquem atualizar o documento machines + const hasMetadataChanges = Object.keys(metadataPatch).length > 0 + const hasHostnameChange = args.hostname && args.hostname !== machine.hostname + const hasOsChange = args.os && ( + args.os.name !== machine.osName || + args.os.version !== machine.osVersion || + args.os.architecture !== machine.architecture + ) + const hasStatusChange = args.status && args.status !== machine.status + const needsMachineUpdate = hasMetadataChanges || hasHostnameChange || hasOsChange || hasStatusChange + + // 4. So atualizar machines se houver mudancas reais (evita criar versoes desnecessarias) + // NOTA: lastHeartbeatAt agora vive na tabela machineHeartbeats, nao atualizamos mais aqui + if (needsMachineUpdate) { + const mergedMetadata = hasMetadataChanges ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata + + await ctx.db.patch(machine._id, { + hostname: args.hostname ?? machine.hostname, + displayName: machine.displayName ?? args.hostname ?? machine.hostname, + osName: args.os?.name ?? machine.osName, + osVersion: args.os?.version ?? machine.osVersion, + architecture: args.os?.architecture ?? machine.architecture, + devicePlatform: args.os?.name ?? machine.devicePlatform, + deviceType: machine.deviceType ?? "desktop", + managementMode: machine.managementMode ?? "agent", + updatedAt: now, + status: args.status ?? "online", + metadata: mergedMetadata, + }) + } if (remoteAccessSnapshot) { await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now) @@ -869,7 +912,7 @@ export const heartbeat = mutation({ }) // Evaluate posture/alerts & optionally create ticket - const fresh = (await ctx.db.get(machine._id)) as Doc<"machines"> + const fresh = needsMachineUpdate ? (await ctx.db.get(machine._id)) as Doc<"machines"> : machine await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata }) return { @@ -958,6 +1001,8 @@ export const listByTenant = query({ return Promise.all( machines.map(async (machine) => { const activeToken = await findActiveMachineToken(ctx, machine._id, now) + // Busca heartbeat da tabela separada (fonte de verdade), fallback para legado + const lastHeartbeatAt = await getMachineLastHeartbeat(ctx, machine._id, machine.lastHeartbeatAt) const offlineThresholdMs = getOfflineThresholdMs() const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs) const manualStatus = (machine.status ?? "").toLowerCase() @@ -966,8 +1011,8 @@ export const listByTenant = query({ derivedStatus = "deactivated" } else if (["maintenance", "blocked"].includes(manualStatus)) { derivedStatus = manualStatus - } else if (machine.lastHeartbeatAt) { - const age = now - machine.lastHeartbeatAt + } else if (lastHeartbeatAt) { + const age = now - lastHeartbeatAt if (age <= offlineThresholdMs) { derivedStatus = "online" } else if (age <= staleThresholdMs) { @@ -1050,8 +1095,8 @@ export const listByTenant = query({ linkedUsers, status: derivedStatus, isActive: machine.isActive ?? true, - lastHeartbeatAt: machine.lastHeartbeatAt ?? null, - heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, + lastHeartbeatAt: lastHeartbeatAt, + heartbeatAgeMs: lastHeartbeatAt ? now - lastHeartbeatAt : null, registeredBy: machine.registeredBy ?? null, createdAt: machine.createdAt, updatedAt: machine.updatedAt, @@ -1100,6 +1145,8 @@ export async function getByIdHandler( const resolvedCompany = companyFromId ?? companyFromSlug const activeToken = await findActiveMachineToken(ctx, machine._id, now) + // Busca heartbeat da tabela separada (fonte de verdade), fallback para legado + const lastHeartbeatAt = await getMachineLastHeartbeat(ctx, machine._id, machine.lastHeartbeatAt) const offlineThresholdMs = getOfflineThresholdMs() const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs) @@ -1109,8 +1156,8 @@ export async function getByIdHandler( derivedStatus = "deactivated" } else if (["maintenance", "blocked"].includes(manualStatus)) { derivedStatus = manualStatus - } else if (machine.lastHeartbeatAt) { - const age = now - machine.lastHeartbeatAt + } else if (lastHeartbeatAt) { + const age = now - lastHeartbeatAt if (age <= offlineThresholdMs) { derivedStatus = "online" } else if (age <= staleThresholdMs) { @@ -1179,8 +1226,8 @@ export async function getByIdHandler( linkedUsers, status: derivedStatus, isActive: machine.isActive ?? true, - lastHeartbeatAt: machine.lastHeartbeatAt ?? null, - heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, + lastHeartbeatAt: lastHeartbeatAt, + heartbeatAgeMs: lastHeartbeatAt ? now - lastHeartbeatAt : null, registeredBy: machine.registeredBy ?? null, createdAt: machine.createdAt, updatedAt: machine.updatedAt, diff --git a/convex/schema.ts b/convex/schema.ts index f0b6f28..f4c7698 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -707,6 +707,14 @@ export default defineSchema({ .index("by_tenant_created", ["tenantId", "createdAt"]) .index("by_tenant_machine", ["tenantId", "machineId"]), + // Tabela separada para heartbeats - evita criar versoes do documento machines a cada heartbeat + // O documento machines so e atualizado quando ha mudancas reais nos dados (metadata, inventory, etc) + machineHeartbeats: defineTable({ + machineId: v.id("machines"), + lastHeartbeatAt: v.number(), + }) + .index("by_machine", ["machineId"]), + machineTokens: defineTable({ tenantId: v.string(), machineId: v.id("machines"),