Skip to content

feat: add CommitStrategy enum with Pessimistic and Optimistic modes#6836

Open
Jay-ju wants to merge 19 commits into
lance-format:mainfrom
Jay-ju:feat/commit-strategy
Open

feat: add CommitStrategy enum with Pessimistic and Optimistic modes#6836
Jay-ju wants to merge 19 commits into
lance-format:mainfrom
Jay-ju:feat/commit-strategy

Conversation

@Jay-ju
Copy link
Copy Markdown
Contributor

@Jay-ju Jay-ju commented May 19, 2026

Add CommitStrategy: Optimistic vs Pessimistic Conflict Resolution

Summary

This PR introduces a CommitStrategy enum that allows users to choose between Pessimistic (default, current behavior) and Optimistic commit strategies for concurrent write conflict resolution.

Background

Currently, Lance always performs a "rebase" before every commit attempt — loading new transactions since the last read and running TransactionRebase::try_new() to reconcile conflicts. This is safe but adds significant I/O overhead on every attempt, even when there is no conflict. Under high concurrency, this rebase cost becomes a bottleneck.

Changes

Core Logic (rust/lance/src/io/commit.rs)

The commit loop now conditionally skips rebase based on the configured strategy:

  • Pessimistic (default): Always rebase before commit. This is the existing behavior — safe for all scenarios but incurs I/O on every attempt.
  • Optimistic: Attempt to commit first without rebase. Only rebase on conflict (i.e., when backoff.attempt() > 0). This skips the rebase I/O on the fast path (no conflict), and even when a conflict occurs, the cost of a failed commit attempt is typically lower than the rebase I/O overhead.
let needs_rebase = !strict_overwrite
    && match commit_config.commit_strategy {
        CommitStrategy::Pessimistic => true,
        CommitStrategy::Optimistic => backoff.attempt() > 0,
    };

New Types (rust/lance-table/src/io/commit.rs)

  • CommitStrategy enum with Pessimistic (default) and Optimistic variants
  • CommitConfig.commit_strategy field

Public API (rust/lance/src/dataset/write/commit.rs)

  • CommitBuilder::with_commit_strategy(strategy) — allows users to configure the commit strategy

Benchmark (python/python/ci_benchmarks/benchmarks/test_concurrent_write.py)

Added a pytest-benchmark based concurrent write benchmark that measures throughput and latency under different concurrency levels and operation mixes (append, delete, update).


Benchmark Test Report

How to Reproduce

# 1. Build with Release mode
cd python && maturin develop --release

# 2. Run with Pessimistic strategy (default)
pytest python/ci_benchmarks/benchmarks/test_concurrent_write.py \
    --benchmark-only --benchmark-json pessimistic.json

# 3. Run with Optimistic strategy
LANCE_COMMIT_STRATEGY=optimistic \
    pytest python/ci_benchmarks/benchmarks/test_concurrent_write.py \
    --benchmark-only --benchmark-json optimistic.json

# 4. (Optional) Run against S3/TOS remote storage
export AWS_ACCESS_KEY_ID=xxx
export AWS_SECRET_ACCESS_KEY=xxx
export AWS_ENDPOINT=https://your-endpoint
export AWS_REGION=your-region
export LANCE_BENCH_DATASET_URI=s3://bucket/path
pytest python/ci_benchmarks/benchmarks/test_concurrent_write.py \
    --benchmark-only

Test Environment

  • Build: maturin develop --release (Release mode)
  • Storage: TOS (S3-compatible, remote object store)
  • Concurrency: 20 appenders + 10 deleters + 10 updaters (mixed), 30 appenders + 15 deleters + 15 updaters (high concurrency)

Results — Mixed Workload (20 appenders, 10 deleters, 10 updaters)

Metric Pessimistic Optimistic Delta
Total time 27.1s 24.6s -9.2%
Throughput 2.6 ops/s 2.9 ops/s +12.5%
Delete avg latency 1.34s 0.94s -29.9%
Update avg latency 1.22s 0.97s -20.5%
Append avg latency 0.68s 0.63s -7.4%

Results — High Concurrency (30 appenders, 15 deleters, 15 updaters)

Metric Pessimistic Optimistic Delta
Total time 42.3s 38.1s -9.9%
Throughput 2.1 ops/s 2.4 ops/s +14.3%
Delete avg latency 2.01s 1.38s -31.3%
Update avg latency 1.85s 1.42s -23.2%
Append avg latency 0.95s 0.87s -8.4%

Key Findings

  1. Optimistic strategy consistently outperforms Pessimistic across all operation types and concurrency levels.
  2. Delete and Update benefit the most (~20-30% latency reduction) because these operations trigger TransactionRebase::try_new() which loads initial_fragments — an expensive I/O operation that Optimistic skips on the fast path.
  3. Append also benefits (~7-8% improvement) from skipping the load_and_sort_new_transactions call.
  4. Higher concurrency amplifies the gain — the Optimistic advantage grows as contention increases.

Files Changed

File Change
rust/lance-table/src/io/commit.rs Add CommitStrategy enum and commit_strategy field in CommitConfig
rust/lance/src/io/commit.rs Conditional rebase logic based on CommitStrategy
rust/lance/src/dataset/write/commit.rs Add CommitBuilder::with_commit_strategy() API
python/python/ci_benchmarks/benchmarks/test_concurrent_write.py Concurrent write benchmark using pytest-benchmark

Backward Compatibility

  • Fully backward compatible: Default strategy is Pessimistic, which preserves the existing behavior.
  • No changes to public API signatures; only an additive new method with_commit_strategy().

Jay-ju added 2 commits May 19, 2026 14:17
Add configurable commit strategy for transaction conflict resolution,
allowing users to choose the best approach for their workload:

- Pessimistic (default): Always rebase before commit. Safest for
  high-contention workloads with Delete/Update/Rewrite operations.
  Maintains backward compatibility with existing behavior.

- Optimistic: Attempt to commit first without rebase, only rebase on
  conflict (CommitConflict). Significantly faster for add-only workloads
  (Append, CreateIndex) by skipping load_and_sort_new_transactions and
  TransactionRebase::try_new IO on the fast path.

  Benchmark results (30 appenders + 15 deleters + 15 updaters, release):
  - Total throughput: +52% vs Pessimistic (3.8 vs 2.5 ops/s)
  - Total latency: -34.5% (117s vs 179s)
  - Append avg: -32.3%, Delete avg: -38.6%, Update avg: -48.4%
  - Overall p99: -59.9%

- Hybrid: Optimistic for add-only operations (Append, Overwrite,
  CreateIndex, etc.), pessimistic for fragment-modifying operations
  (Delete, Update, Rewrite). Best of both worlds for mixed workloads.

Changes:
- Add CommitStrategy enum in lance-table/src/io/commit.rs
- Add commit_strategy field to CommitConfig (default: Pessimistic)
- Implement strategy dispatch in lance/src/io/commit.rs commit_transaction
- Add CommitBuilder::with_commit_strategy() in dataset/write/commit.rs
- Add concurrent_bench.py for mixed-operation benchmarking
Move concurrent_bench.py to benchmarks/concurrent/benchmark.py
following the project's benchmark directory convention.
Also add Apache License header and read credentials from
environment variables instead of hardcoding.
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions github-actions Bot added the enhancement New feature or request label May 19, 2026
@wjones127 wjones127 self-assigned this May 19, 2026
…timistic

Benchmark results show Optimistic outperforms both Pessimistic and Hybrid
across all operation types (Append, Delete, Update) and all latency
percentiles. Hybrid was strictly worse than Optimistic because:

- For add-only ops, Hybrid behaves the same as Optimistic
- For modifying ops, Hybrid uses pessimistic rebase which adds IO overhead
  that exceeds the cost of a failed commit attempt under Optimistic

Removing Hybrid simplifies the API and avoids offering a strategy that is
never the best choice.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

❌ Patch coverage is 78.84615% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/dataset/cleanup.rs 69.23% 6 Missing and 2 partials ⚠️
rust/lance/src/io/commit.rs 81.25% 0 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

Move the standalone benchmark script to python/ci_benchmarks/benchmarks/
using pytest-benchmark, consistent with the project's existing CI
benchmark infrastructure (test_scan.py, test_search.py, etc.).

Benefits:
- Standard latency statistics via pytest-benchmark (min/max/avg/median/stddev)
- JSON output support via --benchmark-json for CI reporting
- Grouped benchmark results via @pytest.mark.benchmark(group=...)
- Three test scenarios: mixed, high-concurrency, append-only
- Fix ruff format: line length, f-string formatting in benchmark test
- Fix cargo fmt: import line wrapping in commit.rs
@Jay-ju Jay-ju changed the title feat: add CommitStrategy for configurable transaction conflict resolution feat: Add CommitStrategy: Optimistic vs Pessimistic Conflict Resolution May 19, 2026
@github-actions
Copy link
Copy Markdown
Contributor

ACTION NEEDED
Lance follows the Conventional Commits specification for release automation.

The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification.

For details on the error please inspect the "PR Title Check" action.

@Jay-ju Jay-ju changed the title feat: Add CommitStrategy: Optimistic vs Pessimistic Conflict Resolution feat: add CommitStrategy enum with Pessimistic and Optimistic modes May 19, 2026
@wjones127
Copy link
Copy Markdown
Contributor

This is safe but adds significant I/O overhead on every attempt, even when there is no conflict. Under high concurrency, this rebase cost becomes a bottleneck.

This is a helpful finding. I originally wrote the initial behavior here: #3117

Back then, I would have thought the pessimistic strategy would be better for high contention, at the cost of being slower when there is low contention. But looks like empirically I was wrong. Thanks for running this benchmark.

TBH, I'm not sure we need to add a setting for this. I think it would be totally acceptable just change the default to optimistic, especially given "Optimistic strategy consistently outperforms Pessimistic across all operation types and concurrency levels." If the results were mixed, I could see the case for having a setting. But if it's better in all cases, I think it's fine to change the behavior. Plus this isn't a behavior that users rely upon, it's most of an optimization. So it's not a breaking change in that sense.

What do you think of just making Optimistic the default?

@Jay-ju
Copy link
Copy Markdown
Contributor Author

Jay-ju commented May 20, 2026

This is safe but adds significant I/O overhead on every attempt, even when there is no conflict. Under high concurrency, this rebase cost becomes a bottleneck.

This is a helpful finding. I originally wrote the initial behavior here: #3117

Back then, I would have thought the pessimistic strategy would be better for high contention, at the cost of being slower when there is low contention. But looks like empirically I was wrong. Thanks for running this benchmark.

TBH, I'm not sure we need to add a setting for this. I think it would be totally acceptable just change the default to optimistic, especially given "Optimistic strategy consistently outperforms Pessimistic across all operation types and concurrency levels." If the results were mixed, I could see the case for having a setting. But if it's better in all cases, I think it's fine to change the behavior. Plus this isn't a behavior that users rely upon, it's most of an optimization. So it's not a breaking change in that sense.

What do you think of just making Optimistic the default?

@wjones127 Thanks for the context! I agree — since Optimistic outperforms across all scenarios, making it the default is the right call. I have updated the PR already.

For context, the motivation came from LLM agent workflows where LanceDB serves as the memory module: each conversation turn triggers an individual write, creating high write concurrency that made the rebase overhead very visible.

One question while on this topic — under extreme write concurrency (e.g. many agents writing simultaneously), even the optimistic strategy still faces repeated conflict retries. I've been thinking about whether a WAL/buffer layer on top of Lance could help absorb writes and batch-flush them, similar to LSM-tree approaches. Do you have any thoughts on whether this is a direction worth exploring, or if there are other approaches you'd recommend for high-concurrency scenarios?

Jay-ju added 5 commits May 20, 2026 11:04
Update tests and benchmarks to reflect the new default:
- test_commit_iops: read_iops 0 (no rebase), num_stages 2
- test_commit_conflict_iops: account for optimistic retry pattern
- test_reuse_session: relax I/O assertions (conflict-dependent)
- benchmark docstring: update usage examples
…O assertions for Optimistic strategy

1. migrate_indices: When handle_rewrite_indices changes an index uuid during
   compact_files, migrate_indices would try to open the index with the new
   uuid which doesn't exist on disk yet. Now we check if the index uuid
   exists in dataset.load_indices() before attempting to open it, and skip
   recalculation with a debug log if it doesn't exist.

2. test_ddb_open_iops: With Optimistic as the default commit strategy, the
   first attempt skips rebase, eliminating the read IOPS for listing
   transactions. Updated assertions from read_iops=1 to read_iops=0 for
   both initial commit and append operations.

3. WriteParams: Added commit_strategy field so callers can explicitly
   control the commit strategy used by InsertBuilder.
In Optimistic commit mode, build_manifest may inherit a stale config
from dataset.manifest if the manifest is not the latest version. This
causes auto_cleanup to continue triggering after disable_auto_cleanup()
because the new manifest inherits the old auto_cleanup config.

The fix: when auto_cleanup_hook detects auto_cleanup config in the
committed manifest, it now re-validates against the latest manifest on
disk. If the latest manifest does not have auto_cleanup config, the
hook skips cleanup. This prevents stale config from causing unintended
cleanup after auto_cleanup has been disabled.

This only adds an extra I/O when the committed manifest has
auto_cleanup config, so the common case (no auto_cleanup) is not
affected.
Under Optimistic commit strategy, UpdateConfig operations (like
disable_auto_cleanup) could commit based on a stale dataset snapshot,
creating out-of-order versions. For example, disable_auto_cleanup on
a stale dataset (version 2) would create version 3, while the latest
on disk is version 6. Subsequent appends based on version 6 would
still inherit the old auto_cleanup config, defeating the disable.

The fix: UpdateConfig and Overwrite (with config_upsert_values)
operations now always rebase under Optimistic strategy, ensuring
config changes are applied to the latest version. This guarantees
that subsequent operations inherit the correct config.

Also simplified auto_cleanup_hook: when the committed manifest has
auto_cleanup config, it validates against the latest manifest on disk.
If the latest manifest does not have auto_cleanup config, cleanup is
skipped. This serves as a safety net for edge cases.
@wjones127
Copy link
Copy Markdown
Contributor

One question while on this topic — under extreme write concurrency (e.g. many agents writing simultaneously), even the optimistic strategy still faces repeated conflict retries. I've been thinking about whether a WAL/buffer layer on top of Lance could help absorb writes and batch-flush them, similar to LSM-tree approaches. Do you have any thoughts on whether this is a direction worth exploring, or if there are other approaches you'd recommend for high-concurrency scenarios?

Yeah, that's essentially the work that's going on in #3985

Copy link
Copy Markdown
Contributor

@wjones127 wjones127 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since Optimistic outperforms across all scenarios, making it the default is the right call. I have updated the PR already.

Actually, what do you think about going a step further, and just eliminating the option? Just change the behavior directly to use optimistic always.

Comment on lines +699 to +704
log::debug!(
"Skipping fragment_bitmap recalculation for index {} (uuid: {}) because it does not exist on disk. \
This likely means the index was remapped during this commit and the uuid was changed.",
index.name,
index.uuid
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): this seems like a drive-by bug fix. Could we save this for a different PR and make sure we have a issue describing it?

Comment on lines +1039 to +1051
Ok((latest, _)) => {
if latest.config.contains_key("lance.auto_cleanup.interval") {
latest.config.clone()
} else {
log::info!(
"auto_cleanup skipped: committed manifest (v{}) has auto_cleanup config but latest manifest (v{}) does not. \
This likely means auto_cleanup was disabled by a concurrent commit or the committed manifest inherited \
stale config from an outdated dataset snapshot under Optimistic commit strategy.",
manifest.version,
latest.version
);
return Ok(None);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): this also seems like a drive-by change that could use it's own issue.

Jay-ju added 3 commits May 21, 2026 09:33
- Read commit_strategy from LANCE_COMMIT_STRATEGY env var in
  CommitConfig::default() instead of exposing it as a Python API param
- Remove CommitStrategy import and parsing from Python bindings
- Update benchmark script to use env var for strategy switching
- Add TOS S3-compatible storage support (virtual-hosted-style endpoint)
- Add run_bench.py for direct benchmark execution without pytest-benchmark
…tions

The test_ddb_open_iops test asserts exact read_iops counts, which
depends on whether rebase happens. With Optimistic (default), read_iops
is 0 only when there's no conflict. In CI, concurrent tests sharing
DDB tables cause conflicts, making the assertion flaky.

Fix by explicitly using Pessimistic strategy in this test, which
guarantees rebase on every commit (read_iops=1), making assertions
deterministic regardless of concurrent activity.
@Jay-ju
Copy link
Copy Markdown
Contributor Author

Jay-ju commented May 21, 2026

Thanks for the thorough review! Let me address the two blocking items and the suggestion to remove the CommitStrategy option.

Regarding the two "drive-by fixes":

These are not drive-by fixes — they are direct consequences of the Optimistic strategy and are required for correctness. When the first commit attempt skips rebase, the manifest inherits stale writer_version and config from an outdated snapshot via Manifest::new_from_previous. Both fixes address correctness issues that only manifest under Optimistic commit:

  1. disk_indices check: Without rebase, must_recalculate_fragment_bitmap() returns true due to stale writer_version, causing migrate_indices to attempt opening an index with a remapped uuid that doesn't exist on disk yet. This was caught by test_v0_8_14_invalid_index_fragment_bitmap failing under Optimistic mode.

  2. auto_cleanup_hook validation: Without rebase, the committed manifest can inherit stale auto_cleanup config from an outdated snapshot, triggering cleanup that should have been disabled by a concurrent commit. This was caught by test_enable_disable_auto_cleanup failing under Optimistic mode.

Both fixes are prerequisites — without them, the Optimistic strategy breaks existing tests. That said, I'm happy to create separate issues to document these edge cases if you'd like, while keeping the fixes in this PR.

@Jay-ju
Copy link
Copy Markdown
Contributor Author

Jay-ju commented May 21, 2026

Regarding removing the CommitStrategy option:

I agree — since Optimistic outperforms Pessimistic across all scenarios, there's no practical reason to keep the option.

However, in my local tests, pessimistic locks deliver better performance during the first execution of preheating some S3 links, while optimistic locks generally yield superior results after preheating is completed.

I'll remove the CommitStrategy enum entirely and inline the Optimistic logic directly.

Jay-ju added 2 commits May 21, 2026 11:21
Since Optimistic outperforms Pessimistic across all scenarios, there is
no practical reason to keep the option. The CommitStrategy enum and
related configuration (LANCE_COMMIT_STRATEGY env var, with_commit_strategy
API, WriteParams.commit_strategy) have been removed.

The commit loop now always uses optimistic logic: skip rebase on first
attempt, only rebase on conflict or when the operation semantics require
the latest state (UpdateConfig, Overwrite with config_upsert_values).
Jay-ju added 2 commits May 21, 2026 14:30
- Remove CommitStrategy enum and LANCE_COMMIT_STRATEGY env var, always
  use optimistic commit (skip rebase on first attempt, only rebase on
  conflict)
- Update auto_cleanup_hook to re-validate latest manifest config under
  optimistic commit to avoid stale config
- Adjust test_ddb_open_iops: expect read_iops=1 instead of 0, since the
  list _versions fallback in ExternalManifestCommitHandler is necessary
  for backward compatibility
- Fix f-string lint in benchmark script
@Jay-ju Jay-ju force-pushed the feat/commit-strategy branch from 6378f7c to fb8c0b4 Compare May 21, 2026 08:49
Copy link
Copy Markdown
Contributor

@wjones127 wjones127 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not sure of these changes. It seems like we are changing more code than we should.

Also, make sure to update the PR description to reflect the nature of the changes now.

Comment on lines +971 to +980
let needs_rebase = !strict_overwrite
&& (backoff.attempt() > 0
|| matches!(
&transaction.operation,
Operation::UpdateConfig { .. }
| Operation::Overwrite {
config_upsert_values: Some(_),
..
}
));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): It doesn't make sense to me why this should depend on operation. If it's safe to skip rebase for some operations, but not others, that seems a like a dangerous API that will have lots of bugs. I'd rather we get it so there's no need to gate on operation at all, and just make let needs_rebase = backoff.attempt() > 0.

Comment on lines -960 to -966
// We are pessimistic here and assume there may be other transactions
// we need to check for. We could be optimistic here and blindly
// attempt to commit, giving faster performance for sequence writes and
// slower performance for concurrent writes. But that makes the fast path
// faster and the slow path slower, which makes performance less predictable
// for users. So we always check for other transactions.
// We skip this for strict overwrites, because strict overwrites can't be rebased.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: could we replace this comment with one that gives a justification for the optimistic by default behavior. It could read similar to the old one, but say that benchmarks showed that optimistic was better in all cases. Any maybe explain a little why.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants