Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config/bench.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
emmanuel-keller marked this conversation as resolved.

# ============================================================================
# limit
# ============================================================================
Expand Down
13 changes: 13 additions & 0 deletions config/ci.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand Down
43 changes: 35 additions & 8 deletions src/arangodb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -273,20 +273,35 @@ impl BenchmarkClient for ArangoDBClient {
}
}

async fn scan_u32(&self, scan: &Scan, _ctx: ScanContext) -> Result<usize> {
async fn scan_u32(&self, scan: &Scan, ctx: ScanContext) -> Result<usize> {
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<usize> {
async fn scan_string(&self, scan: &Scan, ctx: ScanContext) -> Result<usize> {
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(());
Comment thread
emmanuel-keller marked this conversation as resolved.
}
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<Item = (u32, BenchValue)> + Send,
Expand Down Expand Up @@ -475,7 +490,7 @@ impl ArangoDBClient {
Ok(())
}

async fn scan(&self, scan: &Scan) -> Result<usize> {
async fn scan(&self, scan: &Scan, ctx: ScanContext) -> Result<usize> {
// Extract parameters
let l = match (scan.start, scan.limit) {
(Some(s), Some(l)) => format!("LIMIT {s}, {l}"),
Expand All @@ -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 => {
Expand Down Expand Up @@ -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<Value> = self.database.aql_str(&stm).await.unwrap();
let count = res.first().unwrap().as_i64().unwrap();
Ok(count as usize)
Expand Down
60 changes: 59 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,15 +338,40 @@ fn expand_scan_specs(specs: Vec<ScanSpec>) -> Result<Scans> {
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 {
Expand Down Expand Up @@ -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<String>,
Comment thread
emmanuel-keller marked this conversation as resolved.
/// Whether the index enforces uniqueness when supported by the backend.
pub(crate) unique: Option<bool>,
Expand Down Expand Up @@ -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<super::ScanSpec> =
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<super::ScanSpec> = 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<super::ScanSpec> = 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::<Vec<super::ScanSpec>>(
Expand Down
17 changes: 16 additions & 1 deletion src/mariadb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Comment thread
emmanuel-keller marked this conversation as resolved.
}
// Get the unique flag
let unique = if spec.unique.unwrap_or(false) {
"UNIQUE"
Expand Down Expand Up @@ -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<mysql_async::Row> =
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(())
}

Expand Down
13 changes: 13 additions & 0 deletions src/mongodb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Comment thread
emmanuel-keller marked this conversation as resolved.
}
// Define the index document
let mut doc = Document::new();
// Translate bench `tags.*` array-element paths to the base array field so
Expand Down Expand Up @@ -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(())
}
Expand Down
16 changes: 15 additions & 1 deletion src/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Comment thread
emmanuel-keller marked this conversation as resolved.
}
// Get the unique flag
let unique = if spec.unique.unwrap_or(false) {
"UNIQUE"
Expand Down Expand Up @@ -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<mysql_async::Row> =
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(())
}

Expand Down
27 changes: 23 additions & 4 deletions src/neo4j.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions src/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/surrealdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading
Loading