Skip to content

Commit 07deb7f

Browse files
committed
Order chunk metadata and snapshot scheduling with rollback
Ledger chunk sizes were appended, and snapshot scheduling rolled back, outside the lock guarding the rollback epoch. A transaction could therefore restore chunk metadata for an entry a concurrent view change had already discarded, leaving the chunker permanently ahead of the store and skewing every later chunk boundary. Take the version lock for both the rollback and the append, and skip the append when the batch's rollback epoch or view no longer holds. A rollback can only discard a batch's writes by truncating, which moves the epoch on; a rollback that does not truncate may still move the view, which consensus rejects - so both are checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 75d99c5d-6efa-4048-8032-8c78b97208d9
1 parent 9960d50 commit 07deb7f

3 files changed

Lines changed: 135 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
### Fixed
1313

14+
- Ledger chunk metadata and snapshot scheduling are no longer restored by a transaction whose writes a concurrent view change has already discarded. Both are now updated under the same lock as the rollback, and skipped when the transaction's rollback epoch or view no longer holds (#8243).
1415
- A transaction whose view changed while it was committing could apply its writes to the local key-value store and then fail to replicate, leaving state that never reached consensus. The transaction's view is now validated atomically with the allocation of its version, so it is rejected before any map is modified, and `ccf::kv::CommitResult::FAIL_NO_REPLICATE` no longer implies a locally applied write (#8242).
1516

1617
### Changed

src/kv/store.h

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -659,16 +659,6 @@ namespace ccf::kv
659659
// at the specified version.
660660
// No transactions can be prepared or committed during rollback.
661661

662-
if (snapshotter)
663-
{
664-
snapshotter->rollback(tx_id.seqno);
665-
}
666-
667-
if (chunker)
668-
{
669-
chunker->rolled_back_to(tx_id.seqno);
670-
}
671-
672662
std::lock_guard<ccf::pal::Mutex> mguard(maps_lock);
673663

674664
{
@@ -696,6 +686,16 @@ namespace ccf::kv
696686

697687
if (tx_id.seqno >= version)
698688
{
689+
if (snapshotter)
690+
{
691+
snapshotter->rollback(tx_id.seqno);
692+
}
693+
if (chunker)
694+
{
695+
// Keep this ordered with append_entry_size() below, so a commit
696+
// cannot restore chunk metadata after this rollback.
697+
chunker->rolled_back_to(tx_id.seqno);
698+
}
699699
return;
700700
}
701701

@@ -707,6 +707,16 @@ namespace ccf::kv
707707
unset_flag_unsafe(StoreFlag::SNAPSHOT_AT_NEXT_SIGNATURE);
708708
rollback_count++;
709709
pending_txs.clear();
710+
if (snapshotter)
711+
{
712+
snapshotter->rollback(tx_id.seqno);
713+
}
714+
if (chunker)
715+
{
716+
// Keep this ordered with append_entry_size() below, so a commit
717+
// cannot restore chunk metadata after this rollback.
718+
chunker->rolled_back_to(tx_id.seqno);
719+
}
710720
auto e = get_encryptor();
711721
if (e)
712722
{
@@ -1083,7 +1093,18 @@ namespace ccf::kv
10831093

10841094
if (chunker)
10851095
{
1086-
chunker->append_entry_size(data_shared->size());
1096+
std::lock_guard<ccf::pal::Mutex> vguard(version_lock);
1097+
// A rollback can only discard this batch's writes by truncating,
1098+
// which requires its target to be below `version` and therefore
1099+
// increments rollback_count. A rollback that does not truncate
1100+
// leaves the writes intact, but may still move the term on, which
1101+
// consensus will reject - so both are checked here.
1102+
if (
1103+
previous_rollback_count == rollback_count &&
1104+
replication_view == term_of_next_version)
1105+
{
1106+
chunker->append_entry_size(data_shared->size());
1107+
}
10871108
}
10881109

10891110
LOG_DEBUG_FMT(

src/kv/test/kv_test.cpp

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3424,6 +3424,108 @@ TEST_CASE("Range")
34243424
}
34253425
}
34263426

3427+
// Exposes the version the chunker has recorded entries up to, which is the
3428+
// state a rollback and a concurrent commit can disagree about.
3429+
class InspectableChunker : public ccf::kv::LedgerChunker
3430+
{
3431+
public:
3432+
ccf::kv::Version current_version()
3433+
{
3434+
ccf::pal::MutexGuard guard(chunker_lock);
3435+
return current_tx_version;
3436+
}
3437+
};
3438+
3439+
// A PendingTx which rolls the store back while Store::commit() is midway
3440+
// through the batch it belongs to. Store::commit() calls this after releasing
3441+
// version_lock, so it reproduces a rollback landing between a batch being
3442+
// assembled and its chunk metadata being recorded, without needing threads.
3443+
class RollingBackPendingTx : public ccf::kv::PendingTx
3444+
{
3445+
ccf::TxID txid;
3446+
ccf::kv::Store& store;
3447+
MapTypes::StringString& table;
3448+
ccf::TxID rollback_to;
3449+
ccf::kv::Term rollback_term;
3450+
3451+
public:
3452+
RollingBackPendingTx(
3453+
ccf::TxID txid_,
3454+
ccf::kv::Store& store_,
3455+
MapTypes::StringString& table_,
3456+
ccf::TxID rollback_to_,
3457+
ccf::kv::Term rollback_term_) :
3458+
txid(txid_),
3459+
store(store_),
3460+
table(table_),
3461+
rollback_to(rollback_to_),
3462+
rollback_term(rollback_term_)
3463+
{}
3464+
3465+
ccf::kv::PendingTxInfo call() override
3466+
{
3467+
auto tx = store.create_reserved_tx(txid);
3468+
tx.rw(table)->put("key", "value");
3469+
auto info = tx.commit_reserved();
3470+
store.rollback(rollback_to, rollback_term);
3471+
return info;
3472+
}
3473+
};
3474+
3475+
TEST_CASE("Chunk metadata is not restored by a batch a rollback discarded")
3476+
{
3477+
ccf::kv::Store store;
3478+
store.set_encryptor(std::make_shared<ccf::kv::NullTxEncryptor>());
3479+
auto consensus = std::make_shared<ccf::kv::test::PrimaryStubConsensus>();
3480+
store.set_consensus(consensus);
3481+
auto chunker = std::make_shared<InspectableChunker>();
3482+
store.set_chunker(chunker);
3483+
3484+
constexpr ccf::kv::Term initial_term = 2;
3485+
store.initialise_term(initial_term);
3486+
MapTypes::StringString map("public:map");
3487+
3488+
INFO("Commit an ordinary transaction to establish a baseline");
3489+
{
3490+
auto tx = store.create_tx();
3491+
tx.rw(map)->put("key", "initial");
3492+
REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS);
3493+
}
3494+
3495+
const auto baseline_txid = store.current_txid();
3496+
REQUIRE(chunker->current_version() == baseline_txid.seqno);
3497+
3498+
INFO(
3499+
"A batch whose writes are discarded by a rollback must not leave chunk "
3500+
"metadata behind");
3501+
{
3502+
const auto reserved = store.next_txid();
3503+
REQUIRE(reserved.seqno == baseline_txid.seqno + 1);
3504+
3505+
// The rollback target is below the reserved version, so it truncates and
3506+
// moves the rollback epoch on - exactly what a real election would do.
3507+
store.commit(
3508+
reserved,
3509+
std::make_unique<RollingBackPendingTx>(
3510+
reserved, store, map, baseline_txid, initial_term + 1),
3511+
false);
3512+
}
3513+
3514+
CHECK(store.current_txid() == baseline_txid);
3515+
CHECK(chunker->current_version() == baseline_txid.seqno);
3516+
3517+
INFO(
3518+
"The next transaction is chunked against its own version, with no "
3519+
"accumulated offset from the discarded batch");
3520+
{
3521+
auto tx = store.create_tx();
3522+
tx.rw(map)->put("key", "fresh");
3523+
REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS);
3524+
}
3525+
3526+
CHECK(chunker->current_version() == store.current_version());
3527+
}
3528+
34273529
TEST_CASE("Ledger entry chunk request")
34283530
{
34293531
ccf::kv::Store store;

0 commit comments

Comments
 (0)