const scheduledFunctions = await ctx.db.system.query("_scheduled_functions").collect();// In traceSummary.ts run() actionif (trace.lastSummaryMessageCount === messageCount) { return { ok: true, messageCount, usedLlm: false, skipped: true };}const messages = await ctx.db.query("messages").withIndex("by_trace").collect();title: trace.ai_title ?? trace.title{ title: trace.title, // original ai_title: trace.ai_title, // generated display_title: trace.ai_title ?? trace.title // computed}jobs: defineTable({ type: v.string(), // "trace_summary", "cleanup", etc. targetId: v.id("traces"), // or union type for different targets status: v.string(), // "pending", "running", "completed", "failed" scheduledAt: v.number(), startedAt: v.optional(v.number()), completedAt: v.optional(v.number()), result: v.optional(v.any()), error: v.optional(v.string()),}).index("by_type_target_status", ["type", "targetId", "status"]).index("by_status_scheduled", ["status", "scheduledAt"])const existingJob = await ctx.db .query("jobs") .withIndex("by_type_target_status", q => q.eq("type", "trace_summary") .eq("targetId", traceId) .eq("status", "pending") ) .first();title: trace.ai_title ?? trace.title, // was: title: trace.titlejobs: defineTable({ type: v.string(), // "trace_summary", etc. targetId: v.id("traces"), status: v.string(), // "pending", "running", "completed" scheduledAt: v.number(), startedAt: v.optional(v.number()), completedAt: v.optional(v.number()),}).index("by_type_target_status", ["type", "targetId", "status"])// Instead of scanning ALL scheduled functions:const existing = await ctx.db.system.query("_scheduled_functions").collect();
// Use indexed query:const existing = await ctx.db .query("jobs") .withIndex("by_type_target_status", q => q.eq("type", "trace_summary") .eq("targetId", traceId) .eq("status", "pending") ) .first();jobs: defineTable({ type: v.string(), // "trace_summary", "cleanup", "export" entityType: v.string(), // "trace", "user", "namespace", etc. entityId: v.string(), // external ID or stringified Convex ID status: v.string(), // "pending", "running", "completed", "failed" scheduledAt: v.number(), startedAt: v.optional(v.number()), completedAt: v.optional(v.number()), result: v.optional(v.any()), error: v.optional(v.string()),}).index("by_type_entity", ["type", "entityType", "entityId"]).index("by_entity_status", ["entityType", "entityId", "status"]).index("by_status_scheduled", ["status", "scheduledAt"])// Check for existing trace summary jobconst existing = await ctx.db .query("jobs") .withIndex("by_type_entity", q => q.eq("type", "trace_summary") .eq("entityType", "trace") .eq("entityId", traceId) ) .filter(q => q.eq(q.field("status"), "pending")) .first();
// Create job for namespace exportawait ctx.db.insert("jobs", { type: "export", entityType: "namespace", entityId: namespaceId, status: "pending", scheduledAt: Date.now(),});jobs: defineTable({ type: v.string(), targetId: v.union(v.id("traces"), v.id("namespaces"), v.id("users")), // ... rest same}).index("by_target", ["targetId"])entityRef: v.string(), // "trace:abc123" or "namespace:xyz789"// api/convex/internal/sync.ts:161const scheduledFunctions = await ctx.db.system.query("_scheduled_functions").collect();// Instead of scanning ALL scheduled functionsconst existing = await ctx.db .query("jobs") .withIndex("by_type_entity", q => q.eq("type", "trace_summary") .eq("entityType", "trace") .eq("entityId", traceId) ) .first(); // Fast indexed lookupscheduledJobRuns: defineTable({ jobType: v.string(), // "trace_summary" targetType: v.string(), // "trace" targetId: v.string(), status: v.string(), // "pending", "running", "completed" scheduledAt: v.number(), startedAt: v.optional(v.number()), completedAt: v.optional(v.number()),}).index("by_job_target", ["jobType", "targetType", "targetId"]).index("by_status_scheduled", ["status", "scheduledAt"])scheduledJobs: defineTable({ jobType: v.string(), // "trace_summary" targetType: v.string(), // "trace" targetId: v.string(), // trace ID status: v.string(), // "pending", "running", "completed", "failed" scheduledAt: v.number(), startedAt: v.optional(v.number()), completedAt: v.optional(v.number()), result: v.optional(v.any()), error: v.optional(v.string()),}).index("by_job_target", ["jobType", "targetType", "targetId"]).index("by_status_scheduled", ["status", "scheduledAt"])const existing = await ctx.db .query("scheduledJobs") .withIndex("by_job_target", q => q.eq("jobType", "trace_summary") .eq("targetType", "trace") .eq("targetId", traceId) ) .filter(q => q.eq(q.field("status"), "pending")) .first();return existing !== null;upsertMessagesBatch() -> check scheduledJobs for pending trace_summary -> if not found: - insert into scheduledJobs (status: "pending") - ctx.scheduler.runAfter(0, ...) // creates _scheduled_functions entrytraceSummary:run() -> update scheduledJobs (status: "running") -> do work -> update scheduledJobs (status: "completed")// In schema.ts traces tablelastSummaryScheduledAt: v.optional(v.number()),lastSummaryMessageCount: v.optional(v.number()),// Instead of scanning _scheduled_functionsconst COOLDOWN_MS = 60000; // 1 minute
async function shouldScheduleSummary(ctx, traceId, currentMessageCount) { const trace = await ctx.db.get(traceId); // Don't schedule if we just scheduled one recently if (trace.lastSummaryScheduledAt && Date.now() - trace.lastSummaryScheduledAt < COOLDOWN_MS) { return false; } // Don't schedule if message count hasn't changed if (trace.lastSummaryMessageCount === currentMessageCount) { return false; } return true;}export const run = internalAction({ handler: async (ctx, args) => { const trace = await ctx.runQuery(...); const messages = await ctx.runQuery(...); // Self-dedupe: only process if there's new content if (messages.length <= (trace.lastSummaryMessageCount ?? 0)) { return { ok: true, skipped: true }; } // ... generate summary ... // Update trace with completion state await ctx.runMutation(internal.internal.traces.updateSummaryState, { traceId: args.traceId, messageCount: messages.length, scheduledAt: Date.now(), // prevents immediate reschedule }); },});summaryJobState: v.optional(v.object({ status: v.string(), // "pending", "running", "completed" scheduledAt: v.number(),}))# Migrationadd_column :traces, :summary_job_scheduled_at, :datetimeadd_column :traces, :summary_processed_at, :datetimeadd_column :traces, :summary_message_count, :integer, default: 0
# Modelclass Trace < ApplicationRecord def should_generate_summary? return false if summary_job_scheduled_at && summary_job_scheduled_at > 1.minute.ago messages.count > summary_message_count endend
# Job (Sidekiq)class GenerateSummaryJob include Sidekiq::Job def perform(trace_id) trace = Trace.find(trace_id) # Idempotent: exit if already done current_count = trace.messages.count return if current_count <= trace.summary_message_count # Generate summary... trace.update!( summary_processed_at: Time.now, summary_message_count: current_count ) endend// Schema - just add to traces tablelastSummaryJobAt: v.optional(v.number()), // when scheduledlastSummaryProcessedAt: v.optional(v.number()), // when completed lastSummaryMessageCount: v.optional(v.number()), // messages at last run// In sync.ts - before schedulingconst COOLDOWN_MS = 60000;
async function shouldScheduleSummary(ctx, trace, currentCount) { // Rails-style: throttle + check if work needed if (trace.lastSummaryJobAt && Date.now() - trace.lastSummaryJobAt < COOLDOWN_MS) { return false; } return currentCount > (trace.lastSummaryMessageCount ?? 0);}// In traceSummary.ts run() - idempotent jobconst messages = await ctx.runQuery(...);const count = messages.length;
// Self-dedupe: exit if no new messagesif (count <= trace.lastSummaryMessageCount) { return { ok: true, skipped: true };}
// ... generate summary ...
// Mark as processedawait ctx.runMutation(internal.internal.traces.updateSummaryState, { traceId, processedAt: Date.now(), messageCount: count,});// Schema additions to traces tablesummaryCompletedAt: v.optional(v.number()), // when last summary finishedsummaryMessageCount: v.optional(v.number()), // messages at that timeasync function shouldScheduleSummary(ctx, trace, currentCount) { const COOLDOWN_MS = 60000; // 1 minute // Cooldown: don't schedule if we just completed one if (trace.summaryCompletedAt && Date.now() - trace.summaryCompletedAt < COOLDOWN_MS) { return false; } // No new messages since last summary if (currentCount <= (trace.summaryMessageCount ?? 0)) { return false; } return true;}export const run = internalAction({ handler: async (ctx, args) => { const trace = await ctx.runQuery(...); const messages = await ctx.runQuery(...); const count = messages.length; // Self-dedupe: exit early if already done if (count <= (trace.summaryMessageCount ?? 0)) { return { ok: true, skipped: true, messageCount: count }; } // Generate summary... // Mark completion await ctx.runMutation(internal.internal.traces.updateSummaryCompletion, { traceId: args.traceId, completedAt: Date.now(), messageCount: count, ai_summary: summary.summary, ai_title: summary.title, }); return { ok: true, messageCount: count, usedLlm: true }; },});summaryCompletedAt: v.optional(v.number()),summaryMessageCount: v.optional(v.number()),const SUMMARY_COOLDOWN_MS = 60000; // 1 minute
async function shouldScheduleSummary(ctx, traceId, currentMessageCount) { // Cooldown: don't schedule if we just completed one recently if (trace.summaryCompletedAt && Date.now() - trace.summaryCompletedAt < SUMMARY_COOLDOWN_MS) { return false; } // No new messages since last summary if (currentMessageCount <= (trace.summaryMessageCount ?? 0)) { return false; } return true;}title: trace.ai_title ?? trace.title,