Skip to content

Commit dd7cf48

Browse files
committed
Track TierStore backup synchronization generations
A newly configured backup and a previously configured backup that missed primary-only operations are both potentially incomplete. Without durable synchronization metadata, TierStore cannot distinguish either case from a backup containing the current primary state. In this commit, we: - Persist an opaque synchronization generation in the authoritative primary store and compare it with the backup's last completed generation. - Classify missing or mismatched backup completion records as requiring synchronization, while preserving matching generations across restarts. - Rotate an existing primary generation when restarting without the backup so later primary-only operations invalidate its previous completion record. - Avoid creating synchronization metadata for stores that have never configured a backup, preventing an unnecessary write on every startup. - Initialize backup synchronization metadata after NodeBuilder finishes configuring TierStore and test new, stale, synchronized, and removed-backup cases. This commit only establishes synchronization detection. Copying primary data into a new or stale backup follows separately. Assisted-by: Amp (AI coding agent)
1 parent 13633ed commit dd7cf48

2 files changed

Lines changed: 296 additions & 1 deletion

File tree

src/builder.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,10 @@ impl NodeBuilder {
10291029
tier_store.set_backup_store(backup_store);
10301030
}
10311031
}
1032+
runtime.block_on(tier_store.initialize_backup_synchronization()).map_err(|e| {
1033+
log_error!(logger, "Failed to initialize tier-store backup synchronization: {}", e);
1034+
BuildError::KVStoreSetupFailed
1035+
})?;
10321036
Arc::new(DynStoreWrapper(tier_store))
10331037
};
10341038
#[cfg(not(feature = "storage-tier"))]

src/io/tier_store.rs

Lines changed: 292 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ use crate::logger::{LdkLogger, Logger};
2828
use crate::types::{DynStore, DynStoreWrapper};
2929

3030
const INDEX_DATABASE_ID_LEN: usize = 16;
31-
const PAGE_TOKEN_FORMAT_VERSION: u8 = 1;
3231
const INDEX_ENTRIES_PRIMARY_NAMESPACE: &str = "_tier_store_entries";
3332
const INDEX_JOURNAL_PRIMARY_NAMESPACE: &str = "_tier_store_journal";
3433
const INDEX_METADATA_PRIMARY_NAMESPACE: &str = "_tier_store_metadata";
@@ -37,6 +36,20 @@ const INDEX_NAMESPACE_READY_KEY_PREFIX: &str = "ready_";
3736
const INDEX_CACHE_READY_KEY_PREFIX: &str = "cache_ready_";
3837
const INDEX_ENTRY_VALUE: &[u8] = &[1];
3938

39+
const PAGE_TOKEN_FORMAT_VERSION: u8 = 1;
40+
41+
const BACKUP_SYNC_GENERATION_LEN: usize = 16;
42+
const BACKUP_SYNC_PRIMARY_NAMESPACE: &str = "_tier_store_backup_sync";
43+
const PRIMARY_SYNC_GENERATION_KEY: &str = "primary_generation";
44+
const BACKUP_SYNC_COMPLETION_KEY: &str = "completed_generation";
45+
46+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47+
pub(crate) enum BackupSyncStatus {
48+
NotConfigured,
49+
Synchronized,
50+
Required,
51+
}
52+
4053
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4154
enum ValueTier {
4255
Primary,
@@ -601,6 +614,26 @@ impl TierStore {
601614

602615
inner.index = Some(index);
603616
}
617+
618+
/// Initializes the durable metadata used to determine whether the backup matches the primary.
619+
///
620+
/// This must be called once after the optional backup store has been configured and before the
621+
/// `TierStore` is used. If no backup is configured but a primary generation already exists, it
622+
/// writes a new generation so any backup completed against the earlier generation will be
623+
/// recognized as stale if it returns. If no generation exists, no backup has yet been tracked and
624+
/// no metadata is created. If a backup is configured, it reads or creates the primary generation
625+
/// and compares it with the backup's completion record without modifying the completion record.
626+
///
627+
/// Returns [`BackupSyncStatus::NotConfigured`] when no backup is present, whether or not an
628+
/// existing primary generation was rotated, [`BackupSyncStatus::Synchronized`] when both records match, or
629+
/// [`BackupSyncStatus::Required`] when the backup has no completion record or records another
630+
/// generation. A `Required` result only classifies the backup; it does not perform synchronization.
631+
///
632+
/// Returns an error if generation metadata cannot be read, generated, or persisted, or if a
633+
/// stored generation has an invalid length.
634+
pub(crate) async fn initialize_backup_synchronization(&self) -> io::Result<BackupSyncStatus> {
635+
self.inner.initialize_backup_synchronization().await
636+
}
604637
}
605638

606639
pub(crate) async fn setup_index_store(data_dir: PathBuf) -> io::Result<TierStoreIndex> {
@@ -731,6 +764,149 @@ impl TierStoreInner {
731764
}
732765
}
733766

767+
/// Rotates or compares the stores' backup-synchronization generations.
768+
///
769+
/// Without a configured backup, this rotates an existing primary generation so primary-only
770+
/// operation invalidates any earlier backup completion. It leaves primary metadata absent when no
771+
/// backup has ever established a generation. With a configured backup, this preserves the primary
772+
/// generation (creating it if absent) and compares it with the completion generation stored in the
773+
/// backup. A missing or different backup completion is classified as requiring synchronization;
774+
/// this method does not perform that synchronization.
775+
async fn initialize_backup_synchronization(&self) -> io::Result<BackupSyncStatus> {
776+
if self.backup_store.is_none() {
777+
match Self::read_backup_sync_generation(
778+
self.primary_store.as_ref(),
779+
PRIMARY_SYNC_GENERATION_KEY,
780+
)
781+
.await
782+
{
783+
Ok(_) => {
784+
let generation = Self::generate_backup_sync_generation()?;
785+
self.write_primary_backup_sync_generation(generation).await?;
786+
},
787+
Err(e) if e.kind() == io::ErrorKind::NotFound => {},
788+
Err(e) => return Err(e),
789+
}
790+
return Ok(BackupSyncStatus::NotConfigured);
791+
}
792+
793+
let primary_generation = self.read_or_create_primary_backup_sync_generation().await?;
794+
match self.read_backup_sync_completion().await {
795+
Ok(backup_generation) if backup_generation == primary_generation => {
796+
Ok(BackupSyncStatus::Synchronized)
797+
},
798+
Ok(_) => Ok(BackupSyncStatus::Required),
799+
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(BackupSyncStatus::Required),
800+
Err(e) => Err(e),
801+
}
802+
}
803+
804+
/// Generates an opaque random identity for one synchronization generation.
805+
fn generate_backup_sync_generation() -> io::Result<[u8; BACKUP_SYNC_GENERATION_LEN]> {
806+
let mut generation = [0; BACKUP_SYNC_GENERATION_LEN];
807+
getrandom::fill(&mut generation).map_err(|e| {
808+
io::Error::new(
809+
io::ErrorKind::Other,
810+
format!("Failed to generate tier-store backup synchronization generation: {e}"),
811+
)
812+
})?;
813+
Ok(generation)
814+
}
815+
816+
/// Reads the primary's current synchronization generation, creating and persisting one if absent.
817+
///
818+
/// An existing generation is preserved so a matching backup completion remains valid across
819+
/// restarts where the backup stays configured.
820+
async fn read_or_create_primary_backup_sync_generation(
821+
&self,
822+
) -> io::Result<[u8; BACKUP_SYNC_GENERATION_LEN]> {
823+
match Self::read_backup_sync_generation(
824+
self.primary_store.as_ref(),
825+
PRIMARY_SYNC_GENERATION_KEY,
826+
)
827+
.await
828+
{
829+
Ok(generation) => Ok(generation),
830+
Err(e) if e.kind() == io::ErrorKind::NotFound => {
831+
let generation = Self::generate_backup_sync_generation()?;
832+
self.write_primary_backup_sync_generation(generation).await?;
833+
Ok(generation)
834+
},
835+
Err(e) => Err(e),
836+
}
837+
}
838+
839+
/// Persists the current synchronization generation directly in the authoritative primary store.
840+
///
841+
/// The write intentionally bypasses backup replication: changing this record invalidates an old
842+
/// backup, whose completion record must remain unchanged until synchronization actually finishes.
843+
async fn write_primary_backup_sync_generation(
844+
&self, generation: [u8; BACKUP_SYNC_GENERATION_LEN],
845+
) -> io::Result<()> {
846+
KVStore::write(
847+
self.primary_store.as_ref(),
848+
BACKUP_SYNC_PRIMARY_NAMESPACE,
849+
"",
850+
PRIMARY_SYNC_GENERATION_KEY,
851+
generation.to_vec(),
852+
)
853+
.await
854+
}
855+
856+
/// Reads the generation for which the configured backup last completed synchronization.
857+
///
858+
/// The completion record is proof that all durable primary data was copied and stale backup data
859+
/// was removed for that generation. Its absence therefore means synchronization is required; it
860+
/// must not be inferred from individual values already present in the backup.
861+
///
862+
/// Returns [`io::ErrorKind::NotFound`] when no backup is configured or the configured backup has
863+
/// no completion record. Other storage errors and malformed completion records are propagated so
864+
/// callers cannot mistake an unreadable record for proof that the backup is current.
865+
async fn read_backup_sync_completion(&self) -> io::Result<[u8; BACKUP_SYNC_GENERATION_LEN]> {
866+
let backup_store = self.backup_store.as_ref().ok_or_else(|| {
867+
io::Error::new(io::ErrorKind::NotFound, "Backup store is not configured")
868+
})?;
869+
Self::read_backup_sync_generation(backup_store.as_ref(), BACKUP_SYNC_COMPLETION_KEY).await
870+
}
871+
872+
/// Reads and validates a synchronization generation from the given store metadata key.
873+
///
874+
/// Primary generations and backup completion generations use the same opaque, fixed-width value
875+
/// representation but live under different keys. This helper centralizes the shared storage
876+
/// location and length validation without assigning ordering semantics to the random bytes.
877+
///
878+
/// Returns [`io::ErrorKind::NotFound`] when the key is absent and
879+
/// [`io::ErrorKind::InvalidData`] when its value is not exactly
880+
/// [`BACKUP_SYNC_GENERATION_LEN`] bytes. All other underlying storage errors are propagated.
881+
async fn read_backup_sync_generation(
882+
store: &DynStore, key: &str,
883+
) -> io::Result<[u8; BACKUP_SYNC_GENERATION_LEN]> {
884+
let generation = KVStore::read(store, BACKUP_SYNC_PRIMARY_NAMESPACE, "", key).await?;
885+
generation.try_into().map_err(|_| {
886+
io::Error::new(
887+
io::ErrorKind::InvalidData,
888+
"Invalid tier-store backup synchronization generation",
889+
)
890+
})
891+
}
892+
893+
#[cfg(test)]
894+
async fn write_backup_sync_completion(
895+
&self, generation: [u8; BACKUP_SYNC_GENERATION_LEN],
896+
) -> io::Result<()> {
897+
let backup_store = self.backup_store.as_ref().ok_or_else(|| {
898+
io::Error::new(io::ErrorKind::NotFound, "Backup store is not configured")
899+
})?;
900+
KVStore::write(
901+
backup_store.as_ref(),
902+
BACKUP_SYNC_PRIMARY_NAMESPACE,
903+
"",
904+
BACKUP_SYNC_COMPLETION_KEY,
905+
generation.to_vec(),
906+
)
907+
.await
908+
}
909+
734910
fn get_new_version_and_lock_ref(&self, locking_key: String) -> (Arc<TokioMutex<u64>>, u64) {
735911
let version = self.next_write_version.fetch_add(1, Ordering::Relaxed);
736912
if version == u64::MAX {
@@ -1616,6 +1792,121 @@ mod tests {
16161792
tier.set_index_store(TierStoreIndex::from_store(store));
16171793
}
16181794

1795+
#[tokio::test]
1796+
async fn backup_sync_metadata_remains_absent_when_a_backup_has_never_been_configured() {
1797+
let base_dir = random_storage_path();
1798+
let log_path = base_dir.join("tier_store_test.log").to_string_lossy().into_owned();
1799+
let logger = Arc::new(Logger::new_fs_writer(log_path, Level::Trace).unwrap());
1800+
let _cleanup = CleanupDir(base_dir);
1801+
let primary_store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1802+
1803+
let tier = setup_tier_store(Arc::clone(&primary_store), Arc::clone(&logger));
1804+
assert_eq!(
1805+
tier.initialize_backup_synchronization().await.unwrap(),
1806+
BackupSyncStatus::NotConfigured
1807+
);
1808+
drop(tier);
1809+
let restarted_tier = setup_tier_store(Arc::clone(&primary_store), logger);
1810+
assert_eq!(
1811+
restarted_tier.initialize_backup_synchronization().await.unwrap(),
1812+
BackupSyncStatus::NotConfigured
1813+
);
1814+
let error = TierStoreInner::read_backup_sync_generation(
1815+
primary_store.as_ref(),
1816+
PRIMARY_SYNC_GENERATION_KEY,
1817+
)
1818+
.await
1819+
.unwrap_err();
1820+
assert_eq!(error.kind(), io::ErrorKind::NotFound);
1821+
}
1822+
1823+
#[tokio::test]
1824+
async fn backup_sync_generation_rotates_after_backup_is_removed() {
1825+
let base_dir = random_storage_path();
1826+
let log_path = base_dir.join("tier_store_test.log").to_string_lossy().into_owned();
1827+
let logger = Arc::new(Logger::new_fs_writer(log_path, Level::Trace).unwrap());
1828+
let _cleanup = CleanupDir(base_dir);
1829+
let primary_store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1830+
let backup_store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1831+
1832+
let mut tier = setup_tier_store(Arc::clone(&primary_store), Arc::clone(&logger));
1833+
tier.set_backup_store(backup_store);
1834+
assert_eq!(
1835+
tier.initialize_backup_synchronization().await.unwrap(),
1836+
BackupSyncStatus::Required
1837+
);
1838+
let configured_generation = TierStoreInner::read_backup_sync_generation(
1839+
primary_store.as_ref(),
1840+
PRIMARY_SYNC_GENERATION_KEY,
1841+
)
1842+
.await
1843+
.unwrap();
1844+
drop(tier);
1845+
1846+
let restarted_tier = setup_tier_store(Arc::clone(&primary_store), logger);
1847+
assert_eq!(
1848+
restarted_tier.initialize_backup_synchronization().await.unwrap(),
1849+
BackupSyncStatus::NotConfigured
1850+
);
1851+
let rotated_generation = TierStoreInner::read_backup_sync_generation(
1852+
primary_store.as_ref(),
1853+
PRIMARY_SYNC_GENERATION_KEY,
1854+
)
1855+
.await
1856+
.unwrap();
1857+
1858+
assert_ne!(rotated_generation, configured_generation);
1859+
}
1860+
1861+
#[tokio::test]
1862+
async fn backup_sync_status_tracks_persisted_completion() {
1863+
let base_dir = random_storage_path();
1864+
let log_path = base_dir.join("tier_store_test.log").to_string_lossy().into_owned();
1865+
let logger = Arc::new(Logger::new_fs_writer(log_path, Level::Trace).unwrap());
1866+
let _cleanup = CleanupDir(base_dir);
1867+
let primary_store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1868+
let backup_store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1869+
1870+
let mut tier = setup_tier_store(Arc::clone(&primary_store), Arc::clone(&logger));
1871+
tier.set_backup_store(Arc::clone(&backup_store));
1872+
assert_eq!(
1873+
tier.initialize_backup_synchronization().await.unwrap(),
1874+
BackupSyncStatus::Required
1875+
);
1876+
let generation = TierStoreInner::read_backup_sync_generation(
1877+
primary_store.as_ref(),
1878+
PRIMARY_SYNC_GENERATION_KEY,
1879+
)
1880+
.await
1881+
.unwrap();
1882+
1883+
let mut stale_generation = generation;
1884+
stale_generation[0] ^= 1;
1885+
tier.inner.write_backup_sync_completion(stale_generation).await.unwrap();
1886+
assert_eq!(
1887+
tier.initialize_backup_synchronization().await.unwrap(),
1888+
BackupSyncStatus::Required
1889+
);
1890+
1891+
tier.inner.write_backup_sync_completion(generation).await.unwrap();
1892+
drop(tier);
1893+
let mut restarted_tier = setup_tier_store(Arc::clone(&primary_store), logger);
1894+
restarted_tier.set_backup_store(backup_store);
1895+
assert_eq!(
1896+
restarted_tier.initialize_backup_synchronization().await.unwrap(),
1897+
BackupSyncStatus::Synchronized
1898+
);
1899+
assert_eq!(
1900+
TierStoreInner::read_backup_sync_generation(
1901+
primary_store.as_ref(),
1902+
PRIMARY_SYNC_GENERATION_KEY,
1903+
)
1904+
.await
1905+
.unwrap(),
1906+
generation
1907+
);
1908+
}
1909+
16191910
#[test]
16201911
fn journal_entry_roundtrips() {
16211912
for operation in [

0 commit comments

Comments
 (0)