Resume bucket garbage collection after the preceding batch
Problem
VShard garbage-collects a transferred bucket in bounded transactions. When one transaction reaches BUCKET_CHUNK_SIZE, gc_bucket_in_space_xc() commits it and jumps back to restart, where it opens a new iterator at {bucket_id}. The next transaction therefore starts scanning the bucket secondary index from the bucket prefix again instead of continuing after the last tuple deleted by the preceding transaction.
This is particularly expensive for large Vinyl buckets. Deleted index entries remain as LSM history and tombstones until compaction, and a Vinyl secondary-index iterator performs primary-index lookups to materialize and validate tuples. Each new batch can revisit an increasingly large deleted prefix before reaching the next live tuple. The resulting prefix work grows with both bucket cardinality and the number of batches, making smaller garbage-collection transactions disproportionately expensive.
The sender does not have the same behavior: it keeps one iterator while emitting multiple transfer chunks. The repeated-prefix scan is specific to bucket garbage collection across transaction boundaries.
Existing behavior
The current loop is equivalent to:
::restart::
box.begin()
for _, tuple in bucket_index:pairs({bucket_id}) do
space:delete(extract_primary_key(tuple))
if the_batch_is_full then
box.commit()
goto restart
end
end
box.commit()
For a bucket containing N tuples and a batch size B, this creates approximately N / B iterators at the same prefix. On an LSM index, the repeatedly encountered deleted prefix can make the amount of scan work grow approximately like N² / (2B), depending on the engine state and compaction history.
Proposal
When supported by Tarantool and the bucket index is a TREE index, retain the last tuple returned by the bucket iterator and pass it through the after iterator option when opening the next transaction:
local after = nil
::restart::
box.begin()
for _, tuple in bucket_index:pairs({bucket_id}, {after = after}) do
space:delete(extract_primary_key(tuple))
if the_batch_is_full then
after = tuple
box.commit()
goto restart
end
end
box.commit()
The tuple used as the position was deleted by the preceding transaction, but Tarantool pagination explicitly supports an after tuple that is no longer present in the index. Bucket state continues to be checked after each commit as it is today.
Tarantool added index pagination in 2.11, while VShard supports Tarantool 1.10. The optimization must therefore be feature-gated. Tarantool 2.11 and later can use the resumable cursor for TREE indexes; older versions and unsupported index types must retain the current prefix-restarting behavior.
Correctness considerations
A bucket being garbage-collected is no longer writable through VShard. Cursor resumption relies on the same lifecycle invariant as bucket sending: tuples are not inserted into the bucket behind an iterator that has already passed their position. Direct writes that bypass VShard bucket-state checks are outside this invariant.
The implementation should preserve the current behavior when a transaction fails, when the bucket generation or state changes after a commit, and when pagination is unavailable. The cursor is local to one bucket and one space and is discarded when the function returns or raises.
Benchmark
A representative local benchmark used a 50,000-tuple Vinyl bucket with three indexes, synchronous replication, and a garbage-collection batch size of 100. Values are medians across three fresh-cluster runs.
| Iteration behavior |
Cleanup wall time |
Cleanup CPU time |
| Restart from bucket prefix |
4.906 s |
4.757 s |
| Resume after last tuple |
0.602 s |
0.414 s |
The cursor reduced cleanup wall time by approximately 88% and cleanup CPU by approximately 91% without changing the number of tuple deletes, WAL rows, transactions, index updates, or synchronous commits.
Latency percentiles measured only for the duration of each cleanup are intentionally omitted from the table. Cursor cleanup finished in approximately 0.6 seconds and collected about 225 probe observations, while prefix-restarting cleanup ran for approximately 4.9 seconds and collected about 1,815 observations. Comparing percentiles from those unequal exposure periods is misleading: the cursor p99 is approximately the third-highest observation, whereas the prefix-restart p99 is approximately the nineteenth-highest observation.
A separate comparison kept direct source probes running for the same eight-second wall-clock window after starting cleanup. Both cases deleted the same 50,000-tuple bucket, and each case collected approximately 3,300 probe observations. Values are medians across three fresh-cluster runs.
| Iteration behavior |
Cleanup duration |
Probe average |
Probe p50 |
Probe p95 |
Probe p99 |
| Restart from bucket prefix |
4.906 s |
0.118 ms |
0.089 ms |
0.234 ms |
0.632 ms |
| Resume after last tuple |
0.501 s |
0.085 ms |
0.057 ms |
0.217 ms |
0.582 ms |
Over the equal wall-clock window, cursor resumption did not regress the direct probe p99 and improved the average, p50, p95, and p99. The p99 difference was modest, so these results should be interpreted as evidence against a material regression rather than as a precise latency improvement. These direct probes measure TX-thread responsiveness and are not a substitute for end-to-end client latency measurements.
In a separate batch-size matrix, cursor-based cleanup took 0.543, 0.511, 0.495, and 0.483 seconds for batch sizes 100, 250, 500, and 1000 respectively. Without the cursor, the same cases took 4.999, 2.301, 1.407, and 0.970 seconds. Resuming the iterator made cleanup throughput nearly independent of the transaction batch size while retaining smaller transactions as the control for the TX scheduling quantum.
Testing
- Verify that Tarantool 2.11 and later pass the last deleted tuple through
after on subsequent garbage-collection batches.
- Verify that the first batch starts at the bucket prefix.
- Verify that the fallback path never passes
after when pagination is unavailable.
- Verify complete deletion with MemTX and Vinyl, with and without MVCC.
- Verify that a deleted cursor tuple is accepted as the next iterator position.
- Run the complete garbage-collector suite on supported Tarantool versions, including the Tarantool 1.10 compatibility job.
Related work
Resume bucket garbage collection after the preceding batch
Problem
VShard garbage-collects a transferred bucket in bounded transactions. When one transaction reaches
BUCKET_CHUNK_SIZE,gc_bucket_in_space_xc()commits it and jumps back torestart, where it opens a new iterator at{bucket_id}. The next transaction therefore starts scanning the bucket secondary index from the bucket prefix again instead of continuing after the last tuple deleted by the preceding transaction.This is particularly expensive for large Vinyl buckets. Deleted index entries remain as LSM history and tombstones until compaction, and a Vinyl secondary-index iterator performs primary-index lookups to materialize and validate tuples. Each new batch can revisit an increasingly large deleted prefix before reaching the next live tuple. The resulting prefix work grows with both bucket cardinality and the number of batches, making smaller garbage-collection transactions disproportionately expensive.
The sender does not have the same behavior: it keeps one iterator while emitting multiple transfer chunks. The repeated-prefix scan is specific to bucket garbage collection across transaction boundaries.
Existing behavior
The current loop is equivalent to:
For a bucket containing
Ntuples and a batch sizeB, this creates approximatelyN / Biterators at the same prefix. On an LSM index, the repeatedly encountered deleted prefix can make the amount of scan work grow approximately likeN² / (2B), depending on the engine state and compaction history.Proposal
When supported by Tarantool and the bucket index is a TREE index, retain the last tuple returned by the bucket iterator and pass it through the
afteriterator option when opening the next transaction:The tuple used as the position was deleted by the preceding transaction, but Tarantool pagination explicitly supports an
aftertuple that is no longer present in the index. Bucket state continues to be checked after each commit as it is today.Tarantool added index pagination in 2.11, while VShard supports Tarantool 1.10. The optimization must therefore be feature-gated. Tarantool 2.11 and later can use the resumable cursor for TREE indexes; older versions and unsupported index types must retain the current prefix-restarting behavior.
Correctness considerations
A bucket being garbage-collected is no longer writable through VShard. Cursor resumption relies on the same lifecycle invariant as bucket sending: tuples are not inserted into the bucket behind an iterator that has already passed their position. Direct writes that bypass VShard bucket-state checks are outside this invariant.
The implementation should preserve the current behavior when a transaction fails, when the bucket generation or state changes after a commit, and when pagination is unavailable. The cursor is local to one bucket and one space and is discarded when the function returns or raises.
Benchmark
A representative local benchmark used a 50,000-tuple Vinyl bucket with three indexes, synchronous replication, and a garbage-collection batch size of 100. Values are medians across three fresh-cluster runs.
The cursor reduced cleanup wall time by approximately 88% and cleanup CPU by approximately 91% without changing the number of tuple deletes, WAL rows, transactions, index updates, or synchronous commits.
Latency percentiles measured only for the duration of each cleanup are intentionally omitted from the table. Cursor cleanup finished in approximately 0.6 seconds and collected about 225 probe observations, while prefix-restarting cleanup ran for approximately 4.9 seconds and collected about 1,815 observations. Comparing percentiles from those unequal exposure periods is misleading: the cursor p99 is approximately the third-highest observation, whereas the prefix-restart p99 is approximately the nineteenth-highest observation.
A separate comparison kept direct source probes running for the same eight-second wall-clock window after starting cleanup. Both cases deleted the same 50,000-tuple bucket, and each case collected approximately 3,300 probe observations. Values are medians across three fresh-cluster runs.
Over the equal wall-clock window, cursor resumption did not regress the direct probe p99 and improved the average, p50, p95, and p99. The p99 difference was modest, so these results should be interpreted as evidence against a material regression rather than as a precise latency improvement. These direct probes measure TX-thread responsiveness and are not a substitute for end-to-end client latency measurements.
In a separate batch-size matrix, cursor-based cleanup took 0.543, 0.511, 0.495, and 0.483 seconds for batch sizes 100, 250, 500, and 1000 respectively. Without the cursor, the same cases took 4.999, 2.301, 1.407, and 0.970 seconds. Resuming the iterator made cleanup throughput nearly independent of the transaction batch size while retaining smaller transactions as the control for the TX scheduling quantum.
Testing
afteron subsequent garbage-collection batches.afterwhen pagination is unavailable.Related work