Skip to content

Commit ec09722

Browse files
AlexStocksOmX
andcommitted
Make legacy storage migration crash recoverable
Kiwi could open unclassified Base-v1 and Vector-v1 layouts through live RocksDB creation paths, leaving missing Column Families, partial manifest writes, and interrupted directory switches ambiguous after a restart. This decision adds a durable Root-manifest migration journal, strict source-profile classification, shadow-copy verification, per-instance atomic promotion, production-open confirmation, and a verified pre-admission rollback path. Resume validation now accepts only the one-step filesystem progress that can occur between a durable rename and its journal update. Constraint: Rollback is available only before RollbackWindowClosed; after admission, startup validates the live v2 topology without requiring the intentionally stale backup to remain byte-identical. Rejected: Upgrade a live RocksDB instance in place | A crash between Column Family creation, manifest persistence, and multi-instance switching cannot prove which copy is authoritative. Confidence: high Scope-risk: broad Directive: Keep create_missing_column_families disabled outside fresh, manifest-authorized instance creation. Tested: WSL 32-test migration and fault-injection matrix; WSL Storage and Server Clippy with -D warnings; Windows Storage checks with and without test-fault-injection; Windows Server check; cargo fmt --check; git diff --check. Not-tested: Snapshot restore and mixed-version cluster behavior remain Task 3 and later; exact historical executable compatibility remains Task 7. Related: #421 Co-authored-by: OmX <omx@oh-my-codex.dev> Signed-off-by: Xin.Zh <alexstocks@foxmail.com>
1 parent 0aa42af commit ec09722

13 files changed

Lines changed: 3609 additions & 57 deletions

src/server/src/main.rs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,36 @@ async fn initialize_storage(config: &Config) -> Result<GlobalStorage, DualRuntim
254254
let mut storage = Storage::new(config.db_instance_num, 0);
255255

256256
info!("Opening storage at path: {:?}", data_dir);
257-
let bg_task_receiver = storage
258-
.open(storage_options, &data_dir)
259-
.map_err(|e| DualRuntimeError::storage_runtime(format!("Failed to open storage: {}", e)))?;
257+
let mut bg_task_receiver = match storage.open(Arc::clone(&storage_options), &data_dir) {
258+
Ok(receiver) => receiver,
259+
Err(error) => {
260+
let rollback = storage::recover_or_rollback_before_admission(
261+
&data_dir,
262+
config.db_instance_num,
263+
&storage_options,
264+
);
265+
let rollback_context = match rollback {
266+
Ok(true) => "; verified legacy backup restored before admission".to_string(),
267+
Ok(false) => String::new(),
268+
Err(rollback_error) => format!("; rollback also failed: {rollback_error}"),
269+
};
270+
return Err(DualRuntimeError::storage_runtime(format!(
271+
"Failed to open storage: {error}{rollback_context}"
272+
)));
273+
}
274+
};
275+
if storage::close_rollback_window(&data_dir).map_err(|error| {
276+
DualRuntimeError::storage_runtime(format!("Failed to close rollback window: {error}"))
277+
})? {
278+
drop(bg_task_receiver);
279+
bg_task_receiver = storage
280+
.reopen(storage_options, &data_dir)
281+
.map_err(|error| {
282+
DualRuntimeError::storage_runtime(format!(
283+
"Failed to reopen storage after closing rollback window: {error}"
284+
))
285+
})?;
286+
}
260287
info!("Storage opened successfully");
261288

262289
tokio::spawn(async move {

src/storage/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mod format_member_data_key;
2222
pub mod format_vector;
2323
pub mod format_vector_member_key;
2424
mod storage_manifest;
25+
mod storage_migration;
2526
pub mod vector;
2627
pub mod vector_fault;
2728
mod vector_flat;
@@ -89,6 +90,8 @@ pub use format_base_key::BaseMetaKey;
8990
pub use format_base_value::*;
9091
pub use format_zset_score_key::{ScoreMember, ZsetScoreMember};
9192
pub use options::StorageOptions;
93+
#[cfg(any(test, feature = "test-fault-injection"))]
94+
pub use redis::fail_next_redis_open;
9295
pub use redis::{GenerationProvider, Redis, TypeCheckState};
9396
pub use redis_vectors::VectorDataSample;
9497
pub use statistics::KeyStatistics;
@@ -102,6 +105,13 @@ pub use storage_manifest::{
102105
ROOT_STORAGE_MANIFEST_VERSION, RootStorageManifestV2, SLOT_MAPPING_VERSION,
103106
STORAGE_MANIFEST_FILE, STORAGE_SCHEMA_VERSION_V2, slot_mapping_digest,
104107
};
108+
#[cfg(any(test, feature = "test-fault-injection"))]
109+
pub use storage_migration::fail_next_storage_migration;
110+
pub use storage_migration::{
111+
MigrationFaultPoint, MigrationLayout, classify_storage_root, close_rollback_window,
112+
finalize_migration_after_storage_open, prepare_or_resume_migration,
113+
recover_or_rollback_before_admission,
114+
};
105115
pub use storage_schema::{
106116
CANONICAL_COLUMN_FAMILIES, CANONICAL_COLUMN_FAMILY_NAMES, ColumnFamilyIndex, ColumnFamilyRole,
107117
ColumnFamilySpec, ComparatorId, canonical_column_family_names,

src/storage/src/options.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ impl Default for StorageOptions {
9090
let block_cache_size = 8 << 30; // 8GB
9191
let mut options = Options::default();
9292
options.create_if_missing(true);
93-
options.create_missing_column_families(true);
93+
options.create_missing_column_families(false);
9494
options.set_max_open_files(10000);
9595
options.set_write_buffer_size(64 << 20); // 64MB
9696
options.set_max_write_buffer_number(3);
@@ -124,7 +124,8 @@ impl StorageOptions {
124124

125125
/// Build StorageOptions from a loaded [`conf::config::Config`].
126126
pub fn from_config(config: &conf::config::Config) -> Self {
127-
let rocksdb_opts = config.get_rocksdb_options();
127+
let mut rocksdb_opts = config.get_rocksdb_options();
128+
rocksdb_opts.create_missing_column_families(false);
128129
// Build the shared block cache once when sharing is enabled and a
129130
// memory budget is configured. Every `Redis` instance receives the
130131
// same `Arc<StorageOptions>` and therefore reuses this single cache.

src/storage/src/redis.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@
1616
// limitations under the License.
1717

1818
use std::collections::HashMap;
19+
#[cfg(any(test, feature = "test-fault-injection"))]
20+
use std::collections::HashSet;
1921
use std::ops::Deref;
2022
use std::path::Path;
23+
#[cfg(any(test, feature = "test-fault-injection"))]
24+
use std::path::PathBuf;
25+
#[cfg(any(test, feature = "test-fault-injection"))]
26+
use std::sync::LazyLock;
2127
use std::sync::Mutex;
2228
use std::sync::OnceLock;
2329
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -65,6 +71,63 @@ use crate::storage_schema::{CANONICAL_COLUMN_FAMILIES, ColumnFamilySpec, Compara
6571
/// log index that created the key (wired up by the raft layer later).
6672
pub type GenerationProvider = Arc<dyn Fn() -> Result<u64> + Send + Sync>;
6773

74+
#[cfg(any(test, feature = "test-fault-injection"))]
75+
static REDIS_OPEN_FAILURES: LazyLock<Mutex<HashSet<PathBuf>>> =
76+
LazyLock::new(|| Mutex::new(HashSet::new()));
77+
78+
#[cfg(any(test, feature = "test-fault-injection"))]
79+
#[doc(hidden)]
80+
pub struct RedisOpenFailureGuard {
81+
db_path: PathBuf,
82+
}
83+
84+
#[cfg(any(test, feature = "test-fault-injection"))]
85+
impl Drop for RedisOpenFailureGuard {
86+
fn drop(&mut self) {
87+
if let Ok(mut failures) = REDIS_OPEN_FAILURES.lock() {
88+
failures.remove(&self.db_path);
89+
}
90+
}
91+
}
92+
93+
#[cfg(any(test, feature = "test-fault-injection"))]
94+
#[doc(hidden)]
95+
#[must_use]
96+
pub fn fail_next_redis_open(db_path: &Path) -> RedisOpenFailureGuard {
97+
let db_path = db_path.to_path_buf();
98+
let inserted = REDIS_OPEN_FAILURES
99+
.lock()
100+
.map(|mut failures| failures.insert(db_path.clone()))
101+
.unwrap_or(false);
102+
assert!(
103+
inserted,
104+
"Redis open failure already registered for {}",
105+
db_path.display()
106+
);
107+
RedisOpenFailureGuard { db_path }
108+
}
109+
110+
fn maybe_fail_redis_open(db_path: &Path) -> Result<()> {
111+
#[cfg(any(test, feature = "test-fault-injection"))]
112+
{
113+
let should_fail = REDIS_OPEN_FAILURES
114+
.lock()
115+
.map(|mut failures| failures.remove(db_path))
116+
.unwrap_or(false);
117+
if should_fail {
118+
return Err(InvalidFormatSnafu {
119+
message: format!(
120+
"injected Redis open failure after RocksDB open: {}",
121+
db_path.display()
122+
),
123+
}
124+
.build());
125+
}
126+
}
127+
let _ = db_path;
128+
Ok(())
129+
}
130+
68131
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69132
pub enum TypeCheckState {
70133
Missing,
@@ -439,6 +502,8 @@ impl Redis {
439502
let db_directory = Path::new(db_path);
440503
let manifest_path = db_directory.join(crate::storage_manifest::STORAGE_MANIFEST_FILE);
441504
let current_path = db_directory.join("CURRENT");
505+
db_opts.create_if_missing(allow_manifest_creation && !current_path.exists());
506+
db_opts.create_missing_column_families(allow_manifest_creation && !current_path.exists());
442507
if !manifest_path.exists() && current_path.exists() {
443508
return Err(InvalidFormatSnafu {
444509
message: format!(
@@ -502,6 +567,7 @@ impl Redis {
502567
let db = RocksDbOwner::new(
503568
DB::open_cf_descriptors(&db_opts, db_path, column_families).context(RocksSnafu)?,
504569
);
570+
maybe_fail_redis_open(db_directory)?;
505571
let _ = db_once_cell.set(db.downgrade());
506572

507573
let handles = CANONICAL_COLUMN_FAMILIES

src/storage/src/storage.rs

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ use crate::expiration_manager::ExpirationManager;
3030
use crate::format_base_value::DataType;
3131
use crate::options::OptionType;
3232
use crate::slot_indexer::SlotIndexer;
33-
use crate::storage_manifest::{load_or_create_root_manifest, validate_existing_instance_manifests};
33+
use crate::storage_manifest::{
34+
MigrationPhase, RootStorageManifestV2, load_or_create_root_manifest,
35+
validate_existing_instance_manifests,
36+
};
37+
use crate::storage_migration::{
38+
finalize_migration_after_storage_open, prepare_or_resume_migration,
39+
};
3440
use crate::storage_scan::{SCAN_CURSOR_STATE_CAPACITY, ScanCursorState};
3541
use crate::{ColumnFamilyIndex, Redis, StorageOptions, data_type_to_tag};
3642
use conf::raft_type::{Binlog, OperateType};
@@ -197,16 +203,71 @@ impl Storage {
197203
db_path: impl AsRef<Path>,
198204
) -> Result<mpsc::Receiver<BgTask>> {
199205
let db_path = db_path.as_ref();
200-
let root_load = load_or_create_root_manifest(db_path, self.db_instance_num)?;
206+
prepare_or_resume_migration(db_path, self.db_instance_num, &options)?;
207+
let mut root_load = load_or_create_root_manifest(db_path, self.db_instance_num)?;
201208
validate_existing_instance_manifests(
202209
db_path,
203210
&root_load.manifest,
204211
root_load.created_this_call,
205212
)?;
206-
let root_manifest = root_load.manifest;
213+
let mut root_manifest = root_load.manifest;
207214

208215
let (handler, receiver) = BgTaskHandler::new();
209216
let handler_arc = Arc::new(handler);
217+
let mut opened_instances = self.open_instances(
218+
Arc::clone(&options),
219+
db_path,
220+
&root_manifest,
221+
root_load.created_this_call,
222+
&handler_arc,
223+
)?;
224+
225+
let requires_runtime_finalize = root_manifest.migration().is_some_and(|transaction| {
226+
matches!(
227+
transaction.phase,
228+
MigrationPhase::ShadowPromoted | MigrationPhase::NewStorageOpened
229+
)
230+
});
231+
if requires_runtime_finalize {
232+
drop(opened_instances);
233+
finalize_migration_after_storage_open(db_path, self.db_instance_num, &options)?;
234+
root_load = load_or_create_root_manifest(db_path, self.db_instance_num)?;
235+
validate_existing_instance_manifests(
236+
db_path,
237+
&root_load.manifest,
238+
root_load.created_this_call,
239+
)?;
240+
root_manifest = root_load.manifest;
241+
opened_instances = self.open_instances(
242+
Arc::clone(&options),
243+
db_path,
244+
&root_manifest,
245+
false,
246+
&handler_arc,
247+
)?;
248+
}
249+
250+
let expiration_manager = Arc::new(ExpirationManager::new(Arc::clone(&handler_arc)));
251+
let cleanup_task = expiration_manager.start_cleanup_task();
252+
253+
self.scan_cursor_states.clear();
254+
self.insts = opened_instances;
255+
self.bg_task_handler = Some(handler_arc);
256+
self.expiration_manager = Some(expiration_manager);
257+
self.expiration_cleanup_task = Some(cleanup_task);
258+
self.is_opened.store(true, Ordering::SeqCst);
259+
260+
Ok(receiver)
261+
}
262+
263+
fn open_instances(
264+
&self,
265+
options: Arc<StorageOptions>,
266+
db_path: &Path,
267+
root_manifest: &RootStorageManifestV2,
268+
allow_manifest_creation: bool,
269+
handler_arc: &Arc<BgTaskHandler>,
270+
) -> Result<Vec<Arc<Redis>>> {
210271
let mut opened_instances = Vec::with_capacity(self.db_instance_num);
211272
for i in 0..self.db_instance_num {
212273
let sub_path = db_path.join(i.to_string());
@@ -222,33 +283,22 @@ impl Storage {
222283
let mut inst = Redis::new(
223284
options.clone(),
224285
i as i32,
225-
Arc::clone(&handler_arc),
286+
Arc::clone(handler_arc),
226287
Arc::clone(&self.lock_mgr),
227288
);
228289
if let Err(e) = inst.open_bound(
229290
sub_path_str,
230291
i as u32,
231-
&root_manifest,
232-
root_load.created_this_call,
292+
root_manifest,
293+
allow_manifest_creation,
233294
) {
234295
log::error!("open RocksDB{i} failed: {e:?}");
235296
return Err(e);
236297
}
237298
log::info!("open RocksDB{i} success!");
238299
opened_instances.push(Arc::new(inst));
239300
}
240-
241-
let expiration_manager = Arc::new(ExpirationManager::new(Arc::clone(&handler_arc)));
242-
let cleanup_task = expiration_manager.start_cleanup_task();
243-
244-
self.scan_cursor_states.clear();
245-
self.insts = opened_instances;
246-
self.bg_task_handler = Some(handler_arc);
247-
self.expiration_manager = Some(expiration_manager);
248-
self.expiration_cleanup_task = Some(cleanup_task);
249-
self.is_opened.store(true, Ordering::SeqCst);
250-
251-
Ok(receiver)
301+
Ok(opened_instances)
252302
}
253303

254304
pub async fn shutdown(&mut self) {

0 commit comments

Comments
 (0)