Skip to content

Commit 8609167

Browse files
ruojieranyishenlupengfan1
andauthored
fix(storage,raft): fix snapshot-logindex synchronization issues (#265)
* feat(raft): implement snapshot creation and restore with logindex sync This PR implements raft snapshot creation and installation with full logindex synchronization support: Snapshot creation: - Checkpoint-based snapshot creation (tar packaging of checkpoint + metadata) - Includes LogIndex collector state and cf_tracker state in snapshot metadata - Version-aware snapshot metadata with forward compatibility (< instead of !=) Snapshot installation: - ArcSwap-based Storage hot-swapping with pause/resume coordination - State updates (cf_tracker init, collector restore) happen BEFORE resume to prevent applied_state() from returning stale values - Refresh collector/cf_tracker from new storage after swap (not orphaned refs) - Proper error cleanup via pause_controller resume Logindex tracking: - Event listener (DB-level, not CF-level) for tracking (log_index, seqno) - LogIndexOfColumnFamilies tracks applied/flushed state per CF - Mappings persisted to SST table properties via custom collector - State sync during snapshot build/install for follower bootstrap - watchdog uses std::thread::spawn (no tokio runtime in RocksDB callback threads) CI fixes: - License header truncation in snapshot_logindex_test.rs - collapsible_match clippy warnings in hscan/sscan/zscan/message.rs - is_retryable for LogIndex errors: distinguish transient vs structural - CF metadata compile-time assertions to prevent drift - Import consolidation, pub use re-exports, doc additions, test improvements Co-Authored-By: github-actions * chore: ensure .claude path is ignored Cover both file and directory cases in .gitignore so personal Claude Code configuration cannot be committed. * docs: restore CLAUDE.md project documentation Replace local-path content with the canonical project documentation imported from main. * refactor(raft): drop unused KiwiSnapshotBuilder logindex fields The builder's collector and cf_tracker Arc fields were never read — the build_snapshot path always re-fetches the collector from the live storage. Carrying them is misleading: after install_snapshot swaps storage, the builder fields would point at orphaned instances anyway. Remove them so the builder only carries what it actually uses. * fix(raft): export logindex collector state per Storage instance Snapshot meta previously only captured instance 0's collector via `get_logindex_collector(0)`, so when `db_instance_num > 1` the (log_index, seqno) mappings written to non-zero instances were lost on snapshot install and the follower's collector started empty for those shards. `RaftSnapshotMeta` now stores a `Vec<Vec<String>>` (outer index = Storage instance id), and `KiwiSnapshotBuilder`/`install_snapshot` iterate every instance's collector. The state machine no longer caches stale collector references either — it always looks them up through `storage_swap` so they remain valid after a snapshot-install hot swap. * test(storage): keep TempDir alive in storage_basic_test `tempfile::tempdir().unwrap().path().to_path_buf()` drops the TempDir guard before `Storage::open()` runs, so the directory was deleted from underneath the test. Bind the guard so it lives until the end of the test. * refactor(storage): extract commit-and-track-logindex helper Move the (commit, latest_sequence_number, collector.update) sequence out of `Storage::on_binlog_write` and into a new `Redis::commit_batch_and_track_logindex` method. This gives us one place to reason about the seqno-after-commit pattern and to harden later (e.g. a per-instance write mutex or a future rust-rocksdb API exposing the batch's own sequence number) without touching every caller. No behavior change. * fix(storage): wire flush_trigger to RocksDB and stop logging snapshot stub as info `flush_trigger` was a `log::info!(...) + TODO`, so when the LogIndex collector exceeded its bound the listener "asked" for a flush that nothing performed — entries piled up. The trigger now resolves the CF handle through a shared `OnceCell<Arc<DB>>` (populated post-open from the same DB used by compaction filters) and calls `db.flush_cf`. `snapshot_callback` still cannot be wired without a storage→raft back-channel that does not exist today. Demote it to `log::debug!` and spell out in the comment that it is intentionally a no-op while the manual snapshot path remains the only trigger, instead of the misleading "will be connected" comment. * style: cargo fmt * docs(storage): document single-writer invariant on on_binlog_write Reviewer asked the call site to make the single-writer assumption explicit so future changes don't accidentally introduce a second writer that would inflate the captured seqno via latest_sequence_number(). * ci: skip snapshot_logindex_test under leak sanitizer RocksDB integration tests are excluded from LSan/TSan upstream because RocksDB internals trigger spurious leak reports. The new snapshot logindex tests exercise the same Storage::open path and need the same exclusion. * fix(raft): drop old Storage before restoring checkpoint The previous block-scoped placeholder pattern relied on the comment 'old Arc dropped at the end of this block', but `current_storage` is owned by the outer scope, so it lived through restore_checkpoint_layout(). With the old RocksDB handle still alive, restore could race with an open lock on db_path. Explicitly drop current_storage right after the swap so the restore runs with no live handle. Reported by AlexStocks in PR #265 review (P0). --------- Co-authored-by: lupengfan1 <lupengfan1@xiaomi.com>
1 parent e392797 commit 8609167

27 files changed

Lines changed: 1386 additions & 302 deletions

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,10 @@ jobs:
297297
cargo +nightly test -Z build-std --target x86_64-unknown-linux-gnu \
298298
--workspace --exclude storage \
299299
-- --skip cursor_snapshot_roundtrip \
300-
--skip install_snapshot_with_existing_data
300+
--skip install_snapshot_with_existing_data \
301+
--skip test_snapshot_with_logindex_state \
302+
--skip test_on_binlog_write_updates_collector \
303+
--skip test_collector_state_export_restore
301304
cargo +nightly test -Z build-std --target x86_64-unknown-linux-gnu \
302305
-p storage --lib
303306
} 2>&1 | tee sanitizer.log

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ test_debug_*
4646
STORAGE_COMPARISON.md
4747

4848
# Claude Code configuration
49+
.claude
4950
.claude/
5051

5152
# Claude Spec Workflow configuration

src/common/runtime/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,16 @@ impl DualRuntimeError {
182182
StorageError::Unknown { .. } => false,
183183
StorageError::OptionNone { .. } => false,
184184
StorageError::RedisErr { .. } => true, // Redis protocol errors are typically recoverable
185+
StorageError::LogIndex { message, .. } => {
186+
let msg = message.to_lowercase();
187+
// Structural/programming errors are not retryable
188+
if msg.contains("not found") || msg.contains("invalid") || msg.contains("unknown") {
189+
false
190+
} else {
191+
// RocksDB-originating errors may be transient
192+
true
193+
}
194+
}
185195
}
186196
}
187197

src/engine/src/engine.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,10 @@ pub trait Engine: Send + Sync {
127127

128128
/// Latest sequence number after writes.
129129
fn latest_sequence_number(&self) -> u64;
130+
131+
/// Get properties of all SST files for a column family.
132+
fn get_properties_of_all_tables_cf(
133+
&self,
134+
cf: &ColumnFamilyRef<'_>,
135+
) -> Result<rocksdb::table_properties::TablePropertiesCollection>;
130136
}

src/engine/src/rocksdb_engine.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@ impl Engine for RocksdbEngine {
207207
fn latest_sequence_number(&self) -> u64 {
208208
self.db.latest_sequence_number()
209209
}
210+
211+
fn get_properties_of_all_tables_cf(
212+
&self,
213+
cf: &ColumnFamilyRef<'_>,
214+
) -> Result<rocksdb::table_properties::TablePropertiesCollection> {
215+
self.db.get_properties_of_all_tables_cf(cf)
216+
}
210217
}
211218

212219
impl Clone for RocksdbEngine {

src/raft/src/db_access.rs

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,7 @@
1515
// See the License for the specific language governing permissions and
1616
// limitations under the License.
1717

18-
use rocksdb::table_properties::TablePropertiesCollection;
18+
//! Shim module that re-exports from storage::logindex for backward compatibility.
19+
//! Tests that use `raft::db_access::DbCfAccess` can continue to work.
1920
20-
/// Error type (reuse rocksdb::Error or custom)
21-
pub type Result<T> = std::result::Result<T, rocksdb::Error>;
22-
23-
/// Thin wrapper interface: provides ability to get TableProperties by CF
24-
///
25-
/// Equivalent to C++ Redis's GetDB() + GetColumnFamilyHandles()[cf_id],
26-
/// used for LogIndexOfColumnFamilies::Init to iterate CFs and call GetPropertiesOfAllTables.
27-
pub trait DbCfAccess {
28-
/// Get TableProperties of all SSTs for specified CF
29-
///
30-
/// # Arguments
31-
/// * `cf_id` - ColumnFamily index, range [0, COLUMN_FAMILY_COUNT)
32-
fn get_properties_of_all_tables_cf(&self, cf_id: usize) -> Result<TablePropertiesCollection>;
33-
}
21+
pub use storage::logindex::DbCfAccess;

src/raft/src/lib.rs

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
// See the License for the specific language governing permissions and
1616
// limitations under the License.
1717

18-
pub mod cf_tracker;
19-
pub mod collector;
18+
//! Raft module for Kiwi
19+
//!
20+
//! Re-exports logindex types from storage::logindex to avoid code duplication.
21+
2022
pub mod conversion;
21-
pub mod db_access;
22-
pub mod event_listener;
23+
pub mod db_access; // Shim for backward compatibility with tests
2324
pub mod grpc;
2425
pub mod leader_gate;
2526
pub mod log_store;
@@ -28,24 +29,25 @@ pub mod network;
2829
pub mod node;
2930
pub mod snapshot_archive;
3031
pub mod state_machine;
32+
3133
pub mod raft_proto {
3234
// 使用版本化的 proto 包名 kiwi.raft.v1
3335
tonic::include_proto!("kiwi.raft.v1");
3436
pub const FILE_DESCRIPTOR_SET: &[u8] =
3537
tonic::include_file_descriptor_set!("kiwi.raft.v1_descriptor");
3638
}
3739

38-
pub mod table_properties;
39-
pub mod types;
40-
41-
pub use crate::cf_tracker::{LogIndexOfColumnFamilies, SmallestIndexRes};
42-
pub use crate::collector::LogIndexAndSequenceCollector;
43-
pub use crate::event_listener::LogIndexAndSequenceCollectorPurger;
44-
pub use table_properties::{
45-
LogIndexTablePropertiesCollectorFactory, PROPERTY_KEY, get_largest_log_index_from_collection,
40+
// Re-export logindex types from storage::logindex (no duplicate implementations)
41+
pub use storage::logindex::{
42+
DbCfAccess, FlushTrigger, LogIndex, LogIndexAndSequenceCollector,
43+
LogIndexAndSequenceCollectorPurger, LogIndexAndSequencePair, LogIndexOfColumnFamilies,
44+
LogIndexSeqnoPair, LogIndexTablePropertiesCollectorFactory, PROPERTY_KEY, SequenceNumber,
45+
SmallestIndexRes, SnapshotCallback, cf_name_to_index, get_largest_log_index_from_collection,
4646
read_stats_from_table_props,
4747
};
48-
pub use types::{LogIndex, LogIndexAndSequencePair, LogIndexSeqnoPair, SequenceNumber};
48+
49+
// Re-export error types for tests implementing DbCfAccess trait
50+
pub use storage::logindex::types::LogIndexError;
4951

5052
/// Number of column families, consistent with storage::ColumnFamilyIndex::COUNT
5153
pub const COLUMN_FAMILY_COUNT: usize = storage::ColumnFamilyIndex::COUNT;
@@ -65,11 +67,6 @@ const _: () = assert!(
6567
"CF_NAMES length must match storage::ColumnFamilyIndex::COUNT"
6668
);
6769

68-
/// Convert CF name to index
69-
pub fn cf_name_to_index(name: &[u8]) -> Option<usize> {
70-
CF_NAMES.iter().position(|n| n.as_bytes() == name)
71-
}
72-
7370
#[cfg(test)]
7471
mod tests {
7572
use super::*;

src/raft/src/node.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,21 @@ impl Default for RaftConfig {
145145
}
146146

147147
fn build_raft_config(config: &RaftConfig) -> Result<Arc<Config>, anyhow::Error> {
148+
// Validate snapshot configuration parameters
149+
if config.snapshot_logs_threshold == 0 {
150+
return Err(anyhow::anyhow!("snapshot_logs_threshold must be > 0"));
151+
}
152+
if config.snapshot_max_chunk_size == 0 {
153+
return Err(anyhow::anyhow!("snapshot_max_chunk_size must be > 0"));
154+
}
155+
if config.install_snapshot_timeout == 0 {
156+
return Err(anyhow::anyhow!("install_snapshot_timeout must be > 0"));
157+
}
158+
if config.replication_lag_threshold == 0 {
159+
return Err(anyhow::anyhow!("replication_lag_threshold must be > 0"));
160+
}
161+
// max_in_snapshot_log_to_keep: 0 is intentionally allowed (keep no in-snapshot logs)
162+
148163
let raft_config = Config {
149164
heartbeat_interval: config.heartbeat_interval,
150165
election_timeout_min: config.election_timeout_min,
@@ -168,6 +183,9 @@ pub async fn create_raft_node(
168183
let snapshot_work_dir = config.data_dir.join("snapshots");
169184
fs::create_dir_all(&snapshot_work_dir)?;
170185

186+
// Per-instance LogIndex collectors / cf_trackers live in the Storage; the state
187+
// machine looks them up through storage_swap so it sees the right ones after a
188+
// snapshot install hot-swaps Storage.
171189
let mut state_machine = KiwiStateMachine::new(
172190
config.node_id,
173191
storage_swap.clone(),

src/raft/src/snapshot_archive.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
// See the License for the specific language governing permissions and
1616
// limitations under the License.
1717

18+
//! Tar packing/unpacking for Raft snapshot checkpoints.
19+
//!
20+
//! TODO: stream snapshot bytes (read/write without holding the full tar in memory) for
21+
//! build and install paths; align with OpenRaft snapshot APIs when switching off in-memory buffers.
22+
1823
use std::io::{self, Cursor};
1924
use std::path::Path;
2025

@@ -56,6 +61,12 @@ fn append_dir_all_skip_lock<W: std::io::Write>(
5661
Ok(())
5762
}
5863

64+
/// Unpack a tar archive (from `build_snapshot` / OpenRaft `SnapshotData`) into `dst`.
65+
///
66+
/// Security: validates paths to prevent path traversal attacks.
67+
/// - Rejects if dst exists but is not a directory
68+
/// - Rejects tar entries containing ".." components
69+
/// - Rejects paths that escape the destination directory
5970
pub fn unpack_tar_to_dir(bytes: &[u8], dst: &Path) -> io::Result<()> {
6071
if dst.exists() && !dst.is_dir() {
6172
return Err(io::Error::new(
@@ -92,6 +103,7 @@ pub fn unpack_tar_to_dir(bytes: &[u8], dst: &Path) -> io::Result<()> {
92103
Ok(())
93104
}
94105

106+
/// Directory inside `unpack_tar_to_dir` output that contains `0/`, `1/`, … and `__raft_snapshot_meta`.
95107
pub fn unpacked_checkpoint_root(unpack_root: &Path) -> std::path::PathBuf {
96108
unpack_root.join("snap")
97109
}

src/raft/src/state_machine.rs

Lines changed: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ fn persist_current_snapshot(
7777
// Use temporary files + atomic rename to prevent TOCTOU race conditions.
7878
let data_tmp = work_dir.join(format!(".{}.tmp", CURRENT_SNAPSHOT_DATA));
7979
let meta_tmp = work_dir.join(format!(".{}.tmp", CURRENT_SNAPSHOT_META));
80-
8180
std::fs::write(&data_tmp, bytes).map_err(|e| {
8281
StorageError::from_io_error(ErrorSubject::Snapshot(None), ErrorVerb::Write, e)
8382
})?;
@@ -190,6 +189,10 @@ pub struct KiwiStateMachine {
190189

191190
impl KiwiStateMachine {
192191
/// Create a new state machine.
192+
///
193+
/// Per-instance LogIndex collectors and cf_trackers are owned by the underlying
194+
/// Storage; snapshot build/install paths look them up through `storage_swap` so
195+
/// they remain valid after a hot swap.
193196
pub fn new(
194197
node_id: u64,
195198
storage_swap: Arc<ArcSwap<Storage>>,
@@ -208,6 +211,13 @@ impl KiwiStateMachine {
208211
}
209212
}
210213

214+
/// Initialize cf_tracker from restored SST properties after snapshot install
215+
/// or from existing DB on startup
216+
pub fn init_cf_tracker(&self) -> Result<(), io::Error> {
217+
let storage = self.storage_swap.load_full();
218+
storage.init_cf_trackers().map_err(io::Error::other)
219+
}
220+
211221
/// Set pause controller for coordinating with StorageServer.
212222
pub fn set_pause_controller(&mut self, controller: Arc<dyn PauseController>) {
213223
self.pause_controller = Some(controller);
@@ -349,31 +359,17 @@ impl RaftStateMachine<KiwiTypeConfig> for KiwiStateMachine {
349359
let db_id = current_storage.db_id;
350360

351361
// Close old Storage to release RocksDB lock before restoring checkpoint.
352-
// ArcSwap guarantees we have exclusive access during pause.
353-
{
354-
// Note: During normal operation, there may be other Arc references from
355-
// pending requests or Raft apply operations. We need to close via ArcSwap.
356-
// Use a different approach: create a temporary "closed" placeholder.
357-
//
358-
// Actually, we can call close() on the Storage directly since RocksDB
359-
// close doesn't require exclusive ownership - it's idempotent.
360-
// But to ensure proper cleanup, we create a new empty Storage as placeholder,
361-
// swap it in, then drop the old one.
362-
363-
// Create placeholder Storage (not opened)
364-
let placeholder = Arc::new(Storage::new(db_instance_num, db_id));
365-
366-
// Swap placeholder in - this releases ArcSwap's reference to old Storage
367-
self.storage_swap.swap(placeholder);
368-
369-
// Now current_storage (the old Arc) is the only reference left
370-
// It will be dropped at the end of this block, releasing RocksDB lock
371-
}
362+
// pause_controller has already drained pending requests, so swapping the
363+
// placeholder in and dropping `current_storage` here is the only remaining
364+
// reference — `restore_checkpoint_layout` below must run with no live
365+
// RocksDB handle on `db_path`.
366+
let placeholder = Arc::new(Storage::new(db_instance_num, db_id));
367+
self.storage_swap.swap(placeholder);
368+
drop(current_storage);
372369

373370
log::info!("Old Storage dropped, RocksDB lock released");
374371

375372
// ========== Phase 4: Restore checkpoint (atomic operation) ==========
376-
// Now we can safely restore checkpoint since placeholder Storage holds no lock.
377373
restore_checkpoint_layout(&checkpoint_root, &self.db_path, db_instance_num).map_err(
378374
|e| {
379375
cleanup_on_error();
@@ -394,16 +390,45 @@ impl RaftStateMachine<KiwiTypeConfig> for KiwiStateMachine {
394390
self.storage_swap.swap(Arc::new(new_storage));
395391
log::info!("Storage swapped to new instance after snapshot installation");
396392

397-
// ========== Phase 6: Resume StorageServer ==========
398-
if let Some(ctrl) = &self.pause_controller {
399-
ctrl.resume();
393+
// ========== Phase 7: Update state and persist ==========
394+
// Initialize cf_tracker from restored SST properties
395+
self.init_cf_tracker().map_err(|e| {
396+
cleanup_on_error();
397+
StorageError::from_io_error(ErrorSubject::Snapshot(None), ErrorVerb::Write, e)
398+
})?;
399+
400+
// Restore each instance's collector state from snapshot metadata. The new
401+
// storage created its own collector/tracker instances during open(), so we
402+
// must look them up through storage_swap rather than reusing pre-swap refs.
403+
let storage_after_swap = self.storage_swap.load();
404+
let collectors: Vec<_> = (0..db_instance_num)
405+
.filter_map(|i| storage_after_swap.get_logindex_collector(i))
406+
.collect();
407+
file_meta.restore_collector_states(&collectors);
408+
409+
// Purge collector entries for indices compacted into the snapshot.
410+
// This immediately compacts restored pairs to a single boundary entry at
411+
// last_included_index, which is acceptable since the follower will receive
412+
// new entries via replication after this snapshot is installed.
413+
let purge_idx = file_meta.last_included_index as storage::logindex::LogIndex;
414+
for c in &collectors {
415+
c.purge(purge_idx);
400416
}
417+
drop(storage_after_swap);
401418

402-
// ========== Phase 7: Update state and persist ==========
403419
self.last_applied = meta.last_log_id;
404420
self.last_membership = meta.last_membership.clone();
405421

406-
persist_current_snapshot(&self.snapshot_work_dir, meta, &bytes)?;
422+
persist_current_snapshot(&self.snapshot_work_dir, meta, &bytes).inspect_err(|_| {
423+
cleanup_on_error();
424+
})?;
425+
426+
// ========== Phase 8: Resume StorageServer ==========
427+
// Resume only after all state updates are complete, so applied_state()
428+
// returns correct values if queries arrive immediately after resume.
429+
if let Some(ctrl) = &self.pause_controller {
430+
ctrl.resume();
431+
}
407432

408433
drop(unpack_root);
409434
log::info!("Snapshot installation complete");
@@ -420,6 +445,18 @@ impl RaftStateMachine<KiwiTypeConfig> for KiwiStateMachine {
420445
&mut self,
421446
) -> Result<(Option<LogId<u64>>, StoredMembership<u64, KiwiNode>), openraft::StorageError<u64>>
422447
{
448+
// On first access, lazily load from persisted snapshot to recover last_applied
449+
// after restart (otherwise openraft would scan from index 0 and fail if logs were purged).
450+
if self.last_applied.is_none() {
451+
if let Some(snap) = load_current_snapshot(&self.snapshot_work_dir)? {
452+
self.last_applied = snap.meta.last_log_id;
453+
self.last_membership = snap.meta.last_membership.clone();
454+
log::info!(
455+
"Recovered last_applied={:?} from persisted snapshot",
456+
self.last_applied
457+
);
458+
}
459+
}
423460
Ok((self.last_applied, self.last_membership.clone()))
424461
}
425462
}
@@ -446,9 +483,14 @@ impl RaftSnapshotBuilder<KiwiTypeConfig> for KiwiSnapshotBuilder {
446483
} else {
447484
(0, 0)
448485
};
449-
let raft_meta = RaftSnapshotMeta::new(last_idx, last_term);
450486

487+
// Snapshot meta carries each instance's collector state so the receiver can
488+
// rebuild every (log_index, seqno) mapping, not just instance 0's.
451489
let storage = self._storage.load_full();
490+
let collectors: Vec<_> = (0..storage.db_instance_num)
491+
.filter_map(|i| storage.get_logindex_collector(i))
492+
.collect();
493+
let raft_meta = RaftSnapshotMeta::with_collector_states(last_idx, last_term, &collectors);
452494
storage
453495
.create_checkpoint(&dir, &raft_meta)
454496
.map_err(storage_err_to_raft)?;
@@ -470,6 +512,13 @@ impl RaftSnapshotBuilder<KiwiTypeConfig> for KiwiSnapshotBuilder {
470512

471513
persist_current_snapshot(&self.snapshot_work_dir, &meta, &bytes)?;
472514

515+
// Purge collector entries that are now covered by the snapshot.
516+
// This prevents unbounded memory growth as the leader continues accepting writes.
517+
let purge_idx = raft_meta.last_included_index as storage::logindex::LogIndex;
518+
for c in &collectors {
519+
c.purge(purge_idx);
520+
}
521+
473522
Ok(Snapshot {
474523
meta,
475524
snapshot: Box::new(Cursor::new(bytes)),

0 commit comments

Comments
 (0)