diff --git a/config/bench.toml b/config/bench.toml index a833c41b..446d9c37 100644 --- a/config/bench.toml +++ b/config/bench.toml @@ -61,6 +61,19 @@ name = "count()" projection = "COUNT" iterations = 1000 +# ============================================================================ +# count_count_idx +# ============================================================================ + +[[scans]] +id = "count_count_idx" +name = "count() with COUNT index" +projection = "COUNT" +samples = 1000 + +[scans.with_index] +index_type = "count" + # ============================================================================ # limit # ============================================================================ diff --git a/config/ci.toml b/config/ci.toml index c48f88eb..10293ed5 100644 --- a/config/ci.toml +++ b/config/ci.toml @@ -62,6 +62,19 @@ name = "count()" projection = "COUNT" iterations = 10 +# ============================================================================ +# count_count_idx +# ============================================================================ + +[[scans]] +id = "count_count_idx" +name = "count() with COUNT index" +projection = "COUNT" +samples = 10 + +[scans.with_index] +index_type = "count" + # ============================================================================ # limit # ============================================================================ diff --git a/src/arangodb.rs b/src/arangodb.rs index e83b79e9..812c1963 100644 --- a/src/arangodb.rs +++ b/src/arangodb.rs @@ -7,7 +7,7 @@ use crate::engine::{BenchmarkClient, BenchmarkEngine, ScanContext}; use crate::memory::Config; use crate::value::BenchValue; use crate::valueprovider::Columns; -use crate::{Benchmark, KeyType, Projection, Scan}; +use crate::{Benchmark, Index, KeyType, Projection, Scan}; use anyhow::{Result, bail}; use arangors::aql::AqlQuery; use arangors::client::ClientExt; @@ -273,20 +273,35 @@ impl BenchmarkClient for ArangoDBClient { } } - async fn scan_u32(&self, scan: &Scan, _ctx: ScanContext) -> Result { + async fn scan_u32(&self, scan: &Scan, ctx: ScanContext) -> Result { match self.keytype { KeyType::String506 => bail!(NOT_SUPPORTED_ERROR), - _ => self.scan(scan).await, + _ => self.scan(scan, ctx).await, } } - async fn scan_string(&self, scan: &Scan, _ctx: ScanContext) -> Result { + async fn scan_string(&self, scan: &Scan, ctx: ScanContext) -> Result { match self.keytype { KeyType::String506 => bail!(NOT_SUPPORTED_ERROR), - _ => self.scan(scan).await, + _ => self.scan(scan, ctx).await, } } + async fn build_index(&self, spec: &Index, _name: &str) -> Result<()> { + // COUNT-style scans are answered by ArangoDB's collection count with no + // DDL required — the speedup lives in the scan query branch below. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } + bail!(NOT_SUPPORTED_ERROR) + } + + async fn drop_index(&self, _name: &str) -> Result<()> { + // Symmetric to `build_index`: the only path that returns `Ok` above is + // the COUNT no-op, so there's no real index to drop. + Ok(()) + } + async fn batch_create_u32( &self, key_vals: impl Iterator + Send, @@ -475,7 +490,7 @@ impl ArangoDBClient { Ok(()) } - async fn scan(&self, scan: &Scan) -> Result { + async fn scan(&self, scan: &Scan, ctx: ScanContext) -> Result { // Extract parameters let l = match (scan.start, scan.limit) { (Some(s), Some(l)) => format!("LIMIT {s}, {l}"), @@ -486,6 +501,14 @@ impl ArangoDBClient { let c = ArangoDBDialect::filter_clause(scan)?; let o = ArangoDBDialect::sort_clause(scan)?; let p = scan.projection()?; + // Indexed leg of a COUNT-index scan: ArangoDB tracks an exact collection + // count, exposed via LENGTH(). Limited to predicate-free, unpaged scans. + let count_idx = ctx == ScanContext::WithIndex + && matches!(p, Projection::Count) + && scan.with_index.as_ref().and_then(|idx| idx.index_type.as_deref()) == Some("count") + && c.is_empty() + && o.is_empty() + && l.is_empty(); // Perform the relevant projection scan type match p { Projection::Id => { @@ -517,8 +540,12 @@ impl ArangoDBClient { Ok(count) } Projection::Count => { - let stm = - format!("FOR r IN record {c} {l} COLLECT WITH COUNT INTO count RETURN count"); + let stm = if count_idx { + // Collection count: O(1), exact. + "RETURN LENGTH(record)".to_string() + } else { + format!("FOR r IN record {c} {l} COLLECT WITH COUNT INTO count RETURN count") + }; let res: Vec = self.database.aql_str(&stm).await.unwrap(); let count = res.first().unwrap().as_i64().unwrap(); Ok(count as usize) diff --git a/src/main.rs b/src/main.rs index 5d7b3fee..3fc17f5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,15 +338,40 @@ fn expand_scan_specs(specs: Vec) -> Result { Ok(scans) } +/// Index types whose DDL applies to the whole table and accepts no `FIELDS` clause. +/// Empty `Index.fields` is only valid when `index_type` is one of these. +const FIELDLESS_INDEX_TYPES: &[&str] = &["count"]; + +fn is_fieldless_index_type(kind: Option<&str>) -> bool { + matches!(kind, Some(k) if FIELDLESS_INDEX_TYPES.contains(&k)) +} + /// Every scan with a non-skipped `with_index` must supply a non-empty `id` for datastore index names. /// Vector-search scans use `vector_query.field` to drive both index creation and the KNN query; /// `with_index` is reserved for non-vector indexed scans and rejected on vector entries. +/// `with_index.fields` must be non-empty unless `index_type` is one of [`FIELDLESS_INDEX_TYPES`] — +/// otherwise the per-backend DDL builders emit broken `FIELDS ` clauses and fail at runtime. fn validate_scan_index_ids(scans: &[Scan]) -> Result<()> { for scan in scans { if let Some(ref idx) = scan.with_index && !idx.skip { scan.required_index_id()?; + let kind = idx.index_type.as_deref(); + if is_fieldless_index_type(kind) { + if !idx.fields.is_empty() { + bail!( + "scan `{}`: with_index.index_type = `{}` takes no `fields` — remove the `fields` entry", + scan.name, + kind.unwrap() + ); + } + } else if idx.fields.is_empty() { + bail!( + "scan `{}`: with_index.fields must be non-empty (only fieldless index types such as `count` may omit fields)", + scan.name + ); + } } if let Some(ref vq) = scan.vector_query { if vq.top_k == 0 { @@ -393,7 +418,10 @@ pub(crate) struct Index { /// When true, skip index create/drop but still run the query leg (table scan). #[serde(default)] pub(crate) skip: bool, - /// Columns or paths included in the index. + /// Columns or paths included in the index. Optional for index types that + /// don't take field arguments (e.g. SurrealDB COUNT indexes apply to the + /// whole table). + #[serde(default)] pub(crate) fields: Vec, /// Whether the index enforces uniqueness when supported by the backend. pub(crate) unique: Option, @@ -1069,6 +1097,36 @@ mod test { assert!(super::validate_scan_index_ids(&scans).is_ok()); } + #[test] + fn scan_with_index_rejects_empty_fields_without_fieldless_type() { + let specs: Vec = + serde_json::from_str(r#"[{"id":"x","name":"y","samples":1,"with_index":{}}]"#).unwrap(); + let scans = super::expand_scan_specs(specs).unwrap(); + let err = super::validate_scan_index_ids(&scans).unwrap_err(); + assert!(err.to_string().contains("with_index.fields must be non-empty")); + } + + #[test] + fn scan_with_index_allows_empty_fields_for_count() { + let specs: Vec = serde_json::from_str( + r#"[{"id":"x","name":"y","samples":1,"with_index":{"index_type":"count"}}]"#, + ) + .unwrap(); + let scans = super::expand_scan_specs(specs).unwrap(); + assert!(super::validate_scan_index_ids(&scans).is_ok()); + } + + #[test] + fn scan_with_index_rejects_fields_for_count() { + let specs: Vec = serde_json::from_str( + r#"[{"id":"x","name":"y","samples":1,"with_index":{"index_type":"count","fields":["n"]}}]"#, + ) + .unwrap(); + let scans = super::expand_scan_specs(specs).unwrap(); + let err = super::validate_scan_index_ids(&scans).unwrap_err(); + assert!(err.to_string().contains("takes no `fields`")); + } + #[test] fn scan_spec_with_writes_rejects_single_object() { let err = serde_json::from_str::>( diff --git a/src/mariadb.rs b/src/mariadb.rs index ac3072e2..1b0b9cda 100644 --- a/src/mariadb.rs +++ b/src/mariadb.rs @@ -217,6 +217,11 @@ impl BenchmarkClient for MariadbClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no MariaDB equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" @@ -252,8 +257,18 @@ impl BenchmarkClient for MariadbClient { } async fn drop_index(&self, name: &str) -> Result<()> { + // MariaDB's `DROP INDEX` supports `IF EXISTS`, but we go through a + // `SHOW INDEX` lookup for parity with the MySQL adapter. Paired with + // the COUNT-index no-op `build_index` above, a missing index here is + // not an error. + let mut conn = self.conn.lock().await; + let exists: Option = + conn.query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")).await?; + if exists.is_none() { + return Ok(()); + } let stmt = format!("DROP INDEX {name} ON record"); - self.conn.lock().await.query_drop(&stmt).await?; + conn.query_drop(&stmt).await?; Ok(()) } diff --git a/src/mongodb.rs b/src/mongodb.rs index 6206de78..2188ddb9 100644 --- a/src/mongodb.rs +++ b/src/mongodb.rs @@ -270,6 +270,12 @@ impl BenchmarkClient for MongoDBClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no MongoDB equivalent (estimatedDocumentCount + // is approximate, not exact); the indexed scan leg runs the same query + // as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Define the index document let mut doc = Document::new(); // Translate bench `tags.*` array-element paths to the base array field so @@ -311,6 +317,13 @@ impl BenchmarkClient for MongoDBClient { } async fn drop_index(&self, name: &str) -> Result<()> { + // Paired with the COUNT-index no-op `build_index` above, the named + // index may not exist — check the catalog and skip the drop in that + // case so the cleanup leg of the new `count_count_idx` scan succeeds. + let names = self.collection().list_index_names().await?; + if !names.iter().any(|n| n == name) { + return Ok(()); + } self.collection().drop_index(name).await?; Ok(()) } diff --git a/src/mysql.rs b/src/mysql.rs index ac1bc4d8..fe79007d 100644 --- a/src/mysql.rs +++ b/src/mysql.rs @@ -213,6 +213,11 @@ impl BenchmarkClient for MysqlClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no MySQL equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" @@ -248,8 +253,17 @@ impl BenchmarkClient for MysqlClient { } async fn drop_index(&self, name: &str) -> Result<()> { + // MySQL's `DROP INDEX` has no `IF EXISTS`. Paired with the COUNT-index + // no-op `build_index` above, a missing index here is not an error — + // check existence first and skip the DDL when there's nothing to drop. + let mut conn = self.conn.lock().await; + let exists: Option = + conn.query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")).await?; + if exists.is_none() { + return Ok(()); + } let stmt = format!("DROP INDEX {name} ON record"); - self.conn.lock().await.query_drop(&stmt).await?; + conn.query_drop(&stmt).await?; Ok(()) } diff --git a/src/neo4j.rs b/src/neo4j.rs index 70a05e83..ab9fc538 100644 --- a/src/neo4j.rs +++ b/src/neo4j.rs @@ -151,6 +151,11 @@ impl BenchmarkClient for Neo4jClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style scans are answered by Neo4j's label count store with no + // DDL required — the speedup lives in the scan query branch below. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Reject wildcard array specs (`tags.*`). Cypher has no btree equivalent // for array-element indexing, and records are flattened so there is no // single property to index. Other dialects work around this with JSON @@ -555,6 +560,16 @@ impl Neo4jClient { .and_then(|idx| idx.index_type.as_ref()) .map(|t| t == "fulltext") .unwrap_or(false); + // Indexed leg of a COUNT-index scan: hit Neo4j's label count store with + // a labeled, predicate-free match. Limited to scans with no filter / + // ordering / paging — those would force a scan and bypass the store. + let count_idx = ctx == ScanContext::WithIndex + && matches!(p, Projection::Count) + && scan.with_index.as_ref().and_then(|idx| idx.index_type.as_deref()) == Some("count") + && c.is_empty() + && o.is_empty() + && s.is_empty() + && l.is_empty(); // Perform the relevant projection scan type match p { Projection::Id => { @@ -588,11 +603,15 @@ impl Neo4jClient { Ok(count) } Projection::Count => { - let stm = match fts { - true => format!( + let stm = if count_idx { + // Label count store: O(1), exact. + "MATCH (n:Record) RETURN count(n) as count".to_string() + } else if fts { + format!( "CALL db.index.fulltext.queryNodes('{n}', '{c}') YIELD node as r WITH r {s} {l} RETURN count(r) as count" - ), - false => format!("MATCH (r) {c} WITH r {s} {l} RETURN count(r) as count"), + ) + } else { + format!("MATCH (r) {c} WITH r {s} {l} RETURN count(r) as count") }; let mut res = self.graph.execute(query(&stm)).await.unwrap(); let count: i64 = res.next().await.unwrap().unwrap().get("count").unwrap(); diff --git a/src/postgres.rs b/src/postgres.rs index b165e482..0bf21130 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -250,6 +250,11 @@ impl BenchmarkClient for PostgresClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no Postgres equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" diff --git a/src/sqlite.rs b/src/sqlite.rs index 8c73a471..8217aa78 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -220,6 +220,11 @@ impl BenchmarkClient for SqliteClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no SQLite equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" diff --git a/src/surrealdb.rs b/src/surrealdb.rs index 3fea1dc7..b5e7007e 100644 --- a/src/surrealdb.rs +++ b/src/surrealdb.rs @@ -604,6 +604,10 @@ impl BenchmarkClient for SurrealDBClient { "DEFINE INDEX {name} ON TABLE record FIELDS {fields} FULLTEXT ANALYZER {name} BM25 CONCURRENTLY" ) } + Some(kind) if kind == "count" => { + // COUNT indexes apply to the whole table and accept no FIELDS / UNIQUE. + format!("DEFINE INDEX {name} ON TABLE record COUNT CONCURRENTLY") + } _ => { format!("DEFINE INDEX {name} ON TABLE record FIELDS {fields} {unique} CONCURRENTLY") } diff --git a/src/surrealdb2.rs b/src/surrealdb2.rs index 5614a278..7677a0b1 100644 --- a/src/surrealdb2.rs +++ b/src/surrealdb2.rs @@ -467,6 +467,13 @@ impl BenchmarkClient for SurrealDB2Client { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // SurrealDB 2.x has no COUNT-index feature; the indexed scan leg falls + // back to the same query as the baseline so the row still populates. + // Paired with `REMOVE INDEX IF EXISTS` in `drop_index` — the cleanup + // leg is already tolerant of a missing index. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } let unique = if spec.unique.unwrap_or(false) { "UNIQUE" } else {