refactor(convex): separar heartbeat em tabela dedicada para evitar OOM
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
cf28ad2ee4
commit
4fbd521fa8
5 changed files with 111 additions and 51 deletions
30
convex/_generated/api.d.ts
vendored
30
convex/_generated/api.d.ts
vendored
|
|
@ -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<any, "public">
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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<any, "internal">
|
||||
>;
|
||||
|
||||
|
|
|
|||
16
convex/_generated/server.d.ts
vendored
16
convex/_generated/server.d.ts
vendored
|
|
@ -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<DataModel>
|
||||
| GenericMutationCtx<DataModel>
|
||||
| GenericQueryCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
|
|
@ -92,11 +85,12 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
|||
/**
|
||||
* 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -251,6 +251,20 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|||
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<number | null> {
|
||||
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<string, unknown> = {}
|
||||
if (args.metadata && typeof args.metadata === "object") {
|
||||
Object.assign(metadataPatch, args.metadata as Record<string, unknown>)
|
||||
|
|
@ -841,22 +869,37 @@ export const heartbeat = mutation({
|
|||
if (args.metrics && typeof args.metrics === "object") {
|
||||
metadataPatch.metrics = args.metrics as Record<string, unknown>
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue