Created
April 6, 2026 15:44
-
-
Save redknightlois/017531841ebe02fbc963a558aac1770c to your computer and use it in GitHub Desktop.
RavenDB-25930: Benchmark showing compound field query plan degradation without the `right is null` fix in CoraxQueryBuilder.TrySetAsStreamingField()
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env dotnet-script | |
| // Benchmark: RavenDB-25930 Compound Field Query Plan Degradation | |
| // | |
| // Demonstrates the performance impact of the `right is null` fix in | |
| // CoraxQueryBuilder.TrySetAsStreamingField(). Without the fix, a compound | |
| // field definition (Name, Count) incorrectly captures the query plan for | |
| // `WHERE Name = '1' AND Count > 100 ORDER BY Count`, preventing the | |
| // CoraxBooleanItem merge that produces an efficient UnaryMatch range scan. | |
| // | |
| // Usage: | |
| // dotnet script bench-compound-field.csx [server-url] | |
| // dotnet script bench-compound-field.csx http://localhost:8080 | |
| // | |
| // Run against a server WITH the fix and WITHOUT the fix to compare. | |
| #r "nuget: RavenDB.Client, 7.0.2" | |
| using Raven.Client.Documents; | |
| using Raven.Client.Documents.Indexes; | |
| using Raven.Client.Documents.Operations; | |
| using Raven.Client.Documents.Operations.Indexes; | |
| using Raven.Client.Documents.Queries; | |
| using Raven.Client.Documents.Queries.Timings; | |
| using Raven.Client.Documents.Session; | |
| using Raven.Client.ServerWide; | |
| using Raven.Client.ServerWide.Operations; | |
| using System; | |
| using System.Collections.Generic; | |
| using System.Diagnostics; | |
| using System.Linq; | |
| // --------------------------------------------------------------------------- | |
| // Config | |
| // --------------------------------------------------------------------------- | |
| var serverUrl = Args.Count > 0 ? Args[0] : "http://localhost:8080"; | |
| const string DatabaseName = "Bench_RavenDB_25930"; | |
| const int TotalDocs = 500_000; | |
| const int WarmupSeconds = 3; | |
| const int MeasureSeconds = 10; | |
| const string Rql = "from index 'BenchIndex' where Name = '1' and Count > 100 order by Count as long"; | |
| Console.WriteLine($"=== RavenDB-25930: Compound Field Query Plan Benchmark ==="); | |
| Console.WriteLine($"Server : {serverUrl}"); | |
| Console.WriteLine($"Database: {DatabaseName}"); | |
| Console.WriteLine($"Docs : {TotalDocs:N0}"); | |
| Console.WriteLine(); | |
| // --------------------------------------------------------------------------- | |
| // Setup | |
| // --------------------------------------------------------------------------- | |
| var store = new DocumentStore { Urls = new[] { serverUrl }, Database = DatabaseName }; | |
| store.Initialize(); | |
| try | |
| { | |
| // Create database (delete if exists) | |
| try { store.Maintenance.Server.Send(new DeleteDatabasesOperation(DatabaseName, hardDelete: true)); } | |
| catch { /* ignore if not exists */ } | |
| store.Maintenance.Server.Send(new CreateDatabaseOperation(new DatabaseRecord(DatabaseName) | |
| { | |
| Settings = new Dictionary<string, string> | |
| { | |
| ["Indexing.Static.SearchEngineType"] = "Corax", | |
| ["Indexing.Auto.SearchEngineType"] = "Corax" | |
| } | |
| })); | |
| // Create index with compound field | |
| store.Maintenance.Send(new PutIndexesOperation(new IndexDefinition | |
| { | |
| Name = "BenchIndex", | |
| Maps = { "from doc in docs.Docs select new { doc.Name, doc.Count }" }, | |
| Fields = new Dictionary<string, IndexFieldOptions> | |
| { | |
| ["Count"] = new IndexFieldOptions { Storage = FieldStorage.No } | |
| }, | |
| CompoundFields = new List<string[]> { new[] { "Name", "Count" } } | |
| })); | |
| // Insert data | |
| Console.Write($"Inserting {TotalDocs:N0} documents... "); | |
| var insertSw = Stopwatch.StartNew(); | |
| using (var bulk = store.BulkInsert()) | |
| { | |
| for (int i = 0; i < TotalDocs; i++) | |
| { | |
| bulk.Store(new Doc { Name = (i % 100).ToString(), Count = i % 5_000 }, $"docs/{i}"); | |
| } | |
| } | |
| Console.WriteLine($"done ({insertSw.Elapsed.TotalSeconds:F1}s)"); | |
| // Wait for indexing | |
| Console.Write("Waiting for indexing... "); | |
| var idxSw = Stopwatch.StartNew(); | |
| while (true) | |
| { | |
| var stats = store.Maintenance.Send(new GetStatisticsOperation()); | |
| if (stats.StaleIndexes.Length == 0) break; | |
| System.Threading.Thread.Sleep(250); | |
| if (idxSw.Elapsed.TotalSeconds > 120) | |
| { | |
| Console.WriteLine("TIMEOUT waiting for indexing!"); | |
| return; | |
| } | |
| } | |
| Console.WriteLine($"done ({idxSw.Elapsed.TotalSeconds:F1}s)"); | |
| Console.WriteLine(); | |
| // ----------------------------------------------------------------------- | |
| // Query Plan Inspection | |
| // ----------------------------------------------------------------------- | |
| Console.WriteLine("=== Query Plan ==="); | |
| Console.WriteLine($"RQL: {Rql}"); | |
| Console.WriteLine(); | |
| using (var session = store.OpenSession(new SessionOptions { NoCaching = true })) | |
| { | |
| QueryTimings timings = null; | |
| var result = session.Advanced.DocumentQuery<object>("BenchIndex") | |
| .Timings(out timings) | |
| .WhereEquals("Name", "1") | |
| .AndAlso() | |
| .WhereGreaterThan("Count", (long)100) | |
| .OrderBy("Count", OrderingType.Long) | |
| .ToList(); | |
| Console.WriteLine($"Results: {result.Count} documents"); | |
| if (timings?.QueryPlan != null) | |
| { | |
| var plan = (QueryInspectionNode)timings.QueryPlan; | |
| PrintPlan(plan, 0); | |
| bool hasUnaryMatch = FindNode(plan, "UnaryMatch"); | |
| Console.WriteLine(); | |
| Console.WriteLine(hasUnaryMatch | |
| ? "[OK] Query plan contains UnaryMatch (correct - range scan preserved)" | |
| : "[DEGRADED] Query plan MISSING UnaryMatch (compound field captured the plan incorrectly)"); | |
| } | |
| } | |
| Console.WriteLine(); | |
| // ----------------------------------------------------------------------- | |
| // Server-side Timing Sample | |
| // ----------------------------------------------------------------------- | |
| Console.WriteLine("=== Server-Side Timing Sample (5 queries) ==="); | |
| var serverTimings = new List<double>(); | |
| for (int i = 0; i < 5; i++) | |
| { | |
| using (var session = store.OpenSession(new SessionOptions { NoCaching = true })) | |
| { | |
| QueryTimings t = null; | |
| _ = session.Advanced.DocumentQuery<object>("BenchIndex") | |
| .Timings(out t) | |
| .WhereEquals("Name", "1") | |
| .AndAlso() | |
| .WhereGreaterThan("Count", (long)100) | |
| .OrderBy("Count", OrderingType.Long) | |
| .ToList(); | |
| if (t != null) | |
| { | |
| serverTimings.Add(t.DurationInMs); | |
| Console.WriteLine($" Query {i + 1}: {t.DurationInMs:F2} ms (server-side)"); | |
| } | |
| } | |
| } | |
| if (serverTimings.Count > 0) | |
| { | |
| Console.WriteLine($" Avg: {serverTimings.Average():F2} ms Min: {serverTimings.Min():F2} ms Max: {serverTimings.Max():F2} ms"); | |
| } | |
| Console.WriteLine(); | |
| // ----------------------------------------------------------------------- | |
| // Throughput Benchmark | |
| // ----------------------------------------------------------------------- | |
| Console.WriteLine($"=== Throughput Benchmark ==="); | |
| Console.WriteLine($"Warmup: {WarmupSeconds}s Measure: {MeasureSeconds}s"); | |
| Console.WriteLine(); | |
| // Warmup | |
| Console.Write("Warming up... "); | |
| RunLoop(store, Rql, WarmupSeconds, out _, out _); | |
| Console.WriteLine("done"); | |
| // Measured run | |
| Console.Write("Measuring... "); | |
| RunLoop(store, Rql, MeasureSeconds, out long iterations, out double elapsed); | |
| Console.WriteLine("done"); | |
| Console.WriteLine(); | |
| double qps = iterations / elapsed; | |
| double avgMs = elapsed * 1000.0 / iterations; | |
| Console.WriteLine($" Iterations : {iterations:N0}"); | |
| Console.WriteLine($" Elapsed : {elapsed:F2} s"); | |
| Console.WriteLine($" Throughput : {qps:F0} queries/sec"); | |
| Console.WriteLine($" Avg latency: {avgMs:F3} ms/query (end-to-end including HTTP)"); | |
| Console.WriteLine(); | |
| // ----------------------------------------------------------------------- | |
| // Server-side Only Benchmark (Timings API) | |
| // ----------------------------------------------------------------------- | |
| Console.WriteLine("=== Server-Side Only Benchmark (10s, using Timings API) ==="); | |
| var serverOnly = new List<double>(); | |
| var ssSw = Stopwatch.StartNew(); | |
| while (ssSw.Elapsed.TotalSeconds < MeasureSeconds) | |
| { | |
| using (var session = store.OpenSession(new SessionOptions { NoCaching = true })) | |
| { | |
| QueryTimings t = null; | |
| _ = session.Advanced.DocumentQuery<object>("BenchIndex") | |
| .Timings(out t) | |
| .WhereEquals("Name", "1") | |
| .AndAlso() | |
| .WhereGreaterThan("Count", (long)100) | |
| .OrderBy("Count", OrderingType.Long) | |
| .ToList(); | |
| if (t != null) serverOnly.Add(t.DurationInMs); | |
| } | |
| } | |
| ssSw.Stop(); | |
| if (serverOnly.Count > 0) | |
| { | |
| serverOnly.Sort(); | |
| int count = serverOnly.Count; | |
| Console.WriteLine($" Queries : {count:N0}"); | |
| Console.WriteLine($" Avg : {serverOnly.Average():F3} ms"); | |
| Console.WriteLine($" Median : {serverOnly[count / 2]:F3} ms"); | |
| Console.WriteLine($" P95 : {serverOnly[(int)(count * 0.95)]:F3} ms"); | |
| Console.WriteLine($" P99 : {serverOnly[(int)(count * 0.99)]:F3} ms"); | |
| Console.WriteLine($" Min : {serverOnly.Min():F3} ms"); | |
| Console.WriteLine($" Max : {serverOnly.Max():F3} ms"); | |
| } | |
| Console.WriteLine(); | |
| // ----------------------------------------------------------------------- | |
| // Cleanup | |
| // ----------------------------------------------------------------------- | |
| store.Maintenance.Server.Send(new DeleteDatabasesOperation(DatabaseName, hardDelete: true)); | |
| Console.WriteLine("Database cleaned up. Done."); | |
| } | |
| finally | |
| { | |
| store.Dispose(); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Helpers | |
| // --------------------------------------------------------------------------- | |
| void RunLoop(IDocumentStore s, string rql, int seconds, out long iters, out double elapsedSec) | |
| { | |
| var sw = Stopwatch.StartNew(); | |
| long count = 0; | |
| var deadline = TimeSpan.FromSeconds(seconds); | |
| while (sw.Elapsed < deadline) | |
| { | |
| using (var session = s.OpenSession(new SessionOptions { NoCaching = true })) | |
| { | |
| _ = session.Advanced.RawQuery<object>(rql).ToList(); | |
| } | |
| count++; | |
| } | |
| sw.Stop(); | |
| iters = count; | |
| elapsedSec = sw.Elapsed.TotalSeconds; | |
| } | |
| void PrintPlan(QueryInspectionNode node, int indent) | |
| { | |
| Console.WriteLine($"{new string(' ', indent * 2)}{node.Operation}"); | |
| if (node.Children != null) | |
| foreach (var child in node.Children) | |
| PrintPlan(child, indent + 1); | |
| } | |
| bool FindNode(QueryInspectionNode node, string name, int depth = 0) | |
| { | |
| if (depth > 20) return false; | |
| if (node.Operation != null && node.Operation.Contains(name)) return true; | |
| if (node.Children != null) | |
| foreach (var child in node.Children) | |
| if (FindNode(child, name, depth + 1)) return true; | |
| return false; | |
| } | |
| public class Doc | |
| { | |
| public string Id { get; set; } | |
| public string Name { get; set; } | |
| public int Count { get; set; } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment