perf: Add database indexes and optimize queries for MVP

Performance improvements for local development:
- Add indexes: Agents.Name, Conversations.Title, AgentExecutions.CompletedAt, ConversationMessages.CreatedAt
- Remove redundant ConversationMessages index (covered by composite)
- Add .Take() limit to SendMessage context query to prevent fetching excessive history
- Downgrade Microsoft.Extensions.Http from 9.0.10 to 8.0.1 for .NET 8 compatibility

All query handlers already had .AsNoTracking() for read operations.

Impact: Faster search/filter operations even with 10-20 agents, prevents N+1 on long conversations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Svrnty 2025-10-26 23:30:53 -04:00
parent 229a0698a3
commit a24f87a0d3
6 changed files with 464 additions and 6 deletions

View File

@ -200,6 +200,8 @@ public class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Sen
var contextMessages = await _dbContext.ConversationMessages
.AsNoTracking()
.Where(m => m.ConversationId == conversation.Id && m.IsInActiveWindow)
.OrderByDescending(m => m.MessageIndex)
.Take(agent.ConversationWindowSize)
.OrderBy(m => m.MessageIndex)
.ToListAsync(cancellationToken);

View File

@ -13,7 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="OpenHarbor.CQRS" Version="8.1.0-rc1" />
<PackageReference Include="OpenHarbor.CQRS.DynamicQuery.Abstractions" Version="8.1.0-rc1" />

View File

@ -60,6 +60,7 @@ public class CodexDbContext : DbContext
// Indexes
entity.HasIndex(a => new { a.Status, a.IsDeleted });
entity.HasIndex(a => a.Type);
entity.HasIndex(a => a.Name); // Performance: name searches
// Relationships
entity.HasMany(a => a.Tools)
@ -125,6 +126,7 @@ public class CodexDbContext : DbContext
entity.HasIndex(e => e.ConversationId);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.CompletedAt); // Performance: time-based queries
// Relationships
entity.HasOne(e => e.Conversation)
@ -156,6 +158,7 @@ public class CodexDbContext : DbContext
// Indexes
entity.HasIndex(c => new { c.IsActive, c.LastMessageAt })
.IsDescending(false, true); // IsActive ASC, LastMessageAt DESC
entity.HasIndex(c => c.Title); // Performance: title searches
// Relationships
entity.HasMany(c => c.Messages)
@ -178,10 +181,10 @@ public class CodexDbContext : DbContext
// Composite index for efficient conversation window queries
entity.HasIndex(m => new { m.ConversationId, m.IsInActiveWindow, m.MessageIndex });
// Index for ordering messages
entity.HasIndex(m => new { m.ConversationId, m.MessageIndex });
// Index for role filtering
entity.HasIndex(m => m.Role);
// Performance: time-based queries
entity.HasIndex(m => m.CreatedAt);
}
}

View File

@ -0,0 +1,384 @@
// <auto-generated />
using System;
using System.Text.Json;
using Codex.Dal;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Codex.Dal.Migrations
{
[DbContext(typeof(CodexDbContext))]
[Migration("20251027032413_AddPerformanceIndexes")]
partial class AddPerformanceIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Codex.Dal.Entities.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApiKeyEncrypted")
.HasColumnType("text");
b.Property<int>("ConversationWindowSize")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("EnableMemory")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("MaxTokens")
.HasColumnType("integer");
b.Property<string>("ModelEndpoint")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ModelName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ModelProvider")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ProviderType")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("SystemPrompt")
.IsRequired()
.HasColumnType("text");
b.Property<double>("Temperature")
.HasColumnType("double precision");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Name");
b.HasIndex("Type");
b.HasIndex("Status", "IsDeleted");
b.ToTable("Agents");
});
modelBuilder.Entity("Codex.Dal.Entities.AgentExecution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentId")
.HasColumnType("uuid");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<decimal?>("EstimatedCost")
.HasPrecision(18, 6)
.HasColumnType("numeric(18,6)");
b.Property<long?>("ExecutionTimeMs")
.HasColumnType("bigint");
b.Property<string>("Input")
.HasColumnType("text");
b.Property<int?>("InputTokens")
.HasColumnType("integer");
b.Property<string>("Output")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("");
b.Property<int?>("OutputTokens")
.HasColumnType("integer");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("ToolCallResults")
.HasColumnType("text");
b.Property<string>("ToolCalls")
.HasColumnType("text");
b.Property<int?>("TotalTokens")
.HasColumnType("integer");
b.Property<string>("UserPrompt")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.HasIndex("ConversationId");
b.HasIndex("Status");
b.HasIndex("AgentId", "StartedAt")
.IsDescending(false, true);
b.ToTable("AgentExecutions");
});
modelBuilder.Entity("Codex.Dal.Entities.AgentTool", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentId")
.HasColumnType("uuid");
b.Property<string>("ApiBaseUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ApiKeyEncrypted")
.HasColumnType("text");
b.Property<JsonDocument>("Configuration")
.HasColumnType("jsonb");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("McpAuthTokenEncrypted")
.HasColumnType("text");
b.Property<string>("McpServerUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ToolName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Type");
b.HasIndex("AgentId", "IsEnabled");
b.ToTable("AgentTools");
});
modelBuilder.Entity("Codex.Dal.Entities.Conversation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime>("LastMessageAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("MessageCount")
.HasColumnType("integer");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Summary")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("Title");
b.HasIndex("IsActive", "LastMessageAt")
.IsDescending(false, true);
b.ToTable("Conversations");
});
modelBuilder.Entity("Codex.Dal.Entities.ConversationMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ConversationId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ExecutionId")
.HasColumnType("uuid");
b.Property<bool>("IsInActiveWindow")
.HasColumnType("boolean");
b.Property<int>("MessageIndex")
.HasColumnType("integer");
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<int?>("TokenCount")
.HasColumnType("integer");
b.Property<string>("ToolCalls")
.HasColumnType("text");
b.Property<string>("ToolResults")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("ExecutionId");
b.HasIndex("Role");
b.HasIndex("ConversationId", "IsInActiveWindow", "MessageIndex");
b.ToTable("ConversationMessages");
});
modelBuilder.Entity("Codex.Dal.Entities.AgentExecution", b =>
{
b.HasOne("Codex.Dal.Entities.Agent", "Agent")
.WithMany("Executions")
.HasForeignKey("AgentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Codex.Dal.Entities.Conversation", "Conversation")
.WithMany("Executions")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Agent");
b.Navigation("Conversation");
});
modelBuilder.Entity("Codex.Dal.Entities.AgentTool", b =>
{
b.HasOne("Codex.Dal.Entities.Agent", "Agent")
.WithMany("Tools")
.HasForeignKey("AgentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Agent");
});
modelBuilder.Entity("Codex.Dal.Entities.ConversationMessage", b =>
{
b.HasOne("Codex.Dal.Entities.Conversation", "Conversation")
.WithMany("Messages")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Codex.Dal.Entities.AgentExecution", "Execution")
.WithMany("Messages")
.HasForeignKey("ExecutionId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Conversation");
b.Navigation("Execution");
});
modelBuilder.Entity("Codex.Dal.Entities.Agent", b =>
{
b.Navigation("Executions");
b.Navigation("Tools");
});
modelBuilder.Entity("Codex.Dal.Entities.AgentExecution", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("Codex.Dal.Entities.Conversation", b =>
{
b.Navigation("Executions");
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Codex.Dal.Migrations
{
/// <inheritdoc />
public partial class AddPerformanceIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ConversationMessages_ConversationId_MessageIndex",
table: "ConversationMessages");
migrationBuilder.CreateIndex(
name: "IX_Conversations_Title",
table: "Conversations",
column: "Title");
migrationBuilder.CreateIndex(
name: "IX_ConversationMessages_CreatedAt",
table: "ConversationMessages",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Agents_Name",
table: "Agents",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_AgentExecutions_CompletedAt",
table: "AgentExecutions",
column: "CompletedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Conversations_Title",
table: "Conversations");
migrationBuilder.DropIndex(
name: "IX_ConversationMessages_CreatedAt",
table: "ConversationMessages");
migrationBuilder.DropIndex(
name: "IX_Agents_Name",
table: "Agents");
migrationBuilder.DropIndex(
name: "IX_AgentExecutions_CompletedAt",
table: "AgentExecutions");
migrationBuilder.CreateIndex(
name: "IX_ConversationMessages_ConversationId_MessageIndex",
table: "ConversationMessages",
columns: new[] { "ConversationId", "MessageIndex" });
}
}
}

View File

@ -92,6 +92,8 @@ namespace Codex.Dal.Migrations
b.HasKey("Id");
b.HasIndex("Name");
b.HasIndex("Type");
b.HasIndex("Status", "IsDeleted");
@ -160,6 +162,8 @@ namespace Codex.Dal.Migrations
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.HasIndex("ConversationId");
b.HasIndex("Status");
@ -248,6 +252,8 @@ namespace Codex.Dal.Migrations
b.HasKey("Id");
b.HasIndex("Title");
b.HasIndex("IsActive", "LastMessageAt")
.IsDescending(false, true);
@ -293,12 +299,12 @@ namespace Codex.Dal.Migrations
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("ExecutionId");
b.HasIndex("Role");
b.HasIndex("ConversationId", "MessageIndex");
b.HasIndex("ConversationId", "IsInActiveWindow", "MessageIndex");
b.ToTable("ConversationMessages");