feat(kvp): add store trait and Hyper-V KVP storage crate#288
feat(kvp): add store trait and Hyper-V KVP storage crate#288peytonr18 wants to merge 27 commits into
Conversation
b134a98 to
f435f70
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new standalone workspace crate (libazureinit-kvp) that defines a storage abstraction (KvpStore) and a production Hyper-V pool-file implementation (HyperVKvpStore), along with updated technical reference documentation.
Changes:
- Introduces
libazureinit-kvpcrate withKvpStoreandKvpLimits(Hyper-V/Azure presets). - Implements Hyper-V binary pool-file backend with flock-based concurrency and stale-file truncation support.
- Rewrites
doc/libazurekvp.mdas a technical reference for the new crate/API.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
Cargo.toml |
Adds libazureinit-kvp to the workspace members. |
libazureinit-kvp/Cargo.toml |
Defines the new crate and its dependencies (fs2, sysinfo). |
libazureinit-kvp/src/lib.rs |
Defines KvpStore, KvpLimits, and exports HyperVKvpStore plus size constants. |
libazureinit-kvp/src/hyperv.rs |
Implements Hyper-V pool-file encoding/decoding and the KvpStore backend, plus unit tests. |
doc/libazurekvp.md |
Updates documentation to describe the new crate’s record format, semantics, truncation behavior, and limits. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ation; add PoolMode to handle size limits
…ics and JSON dump Overhaul the KvpStore trait into a pure customer-facing interface with no default implementations or backend_* methods. All logic (validation, file I/O, locking) now lives in the KvpPoolStore implementation. Key changes: - Replace append-based write with upsert (insert-or-update) so each key has exactly one record in the file. Eliminates entries_raw and last-write-wins deduplication. - Move validate_key/validate_value from trait defaults to private KvpPoolStore methods. - Decouple read validation from PoolMode: reads accept keys up to the full wire-format max (512 bytes) regardless of mode; only writes are restricted by PoolMode. - Add len(), is_empty(), and dump() (JSON via serde_json) to the trait. - Add 4 multi-threaded concurrency tests covering concurrent upserts to different keys, same key, mixed readers/writers, and key cap boundary.
- Replace validate_key_for_read and per-method validation with consistent standalone validate_key/validate_value free functions. - Remove key validation from read and delete (read-path leniency allows Safe-mode stores to read keys written in Unsafe mode). - Move is_empty default impl to the KvpStore trait. - Narrow decode_record/encode_record visibility to private. - Use open_for_read_write helper in clear().
| /// Release a lock on the entire file. | ||
| /// | ||
| /// Unlock errors are non-actionable (the fd is about to close, which | ||
| /// releases the lock unconditionally), so the return value is discarded. |
There was a problem hiding this comment.
it seems like this should raise the error, but then have the caller ignore it if it's not needed.
- Merge KvpStore trait methods directly into impl KvpPoolStore. The trait added indirection without a consumer; testability is already covered by KvpPoolStore + TempDir. Can be re-introduced if a second backend is ever needed. - fcntl_unlock now returns io::Result<()> so callers can propagate or ignore unlock errors explicitly. clear() surfaces unlock errors via write_result.and(unlock_result), preserving the primary error when both fail. Drop impl ignores the result (can't return from Drop); KvpPoolIter::new error paths rely on fd-drop for lock release, consistent with how its other ? paths already work. - Update lib.rs exports and doc links to reflect the removed trait.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #288 +/- ##
==========================================
+ Coverage 91.52% 94.51% +2.98%
==========================================
Files 17 19 +2
Lines 3905 6031 +2126
==========================================
+ Hits 3574 5700 +2126
Misses 331 331 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
4e1f57a to
680057c
Compare
| Err(e) => return Err(e.into()), | ||
| }; | ||
|
|
||
| let mut iter = KvpPoolIter::new(file, true)?; |
There was a problem hiding this comment.
should we use iter_mut() here?
There was a problem hiding this comment.
I think this is correct as is because delete() opens the file via open_for_read_write() where there isn't a create call and then the function exits with Ok(false) if the file is missing. using iter_mut() would open the file via open_for_read_write_create() and subsequently create an empty pool file as a side effect from trying to delete from it
|
|
||
| fn new(mut file: File, lock_exclusive: bool) -> Result<Self, KvpError> { | ||
| let lock_result = if lock_exclusive { | ||
| fcntl_lock_exclusive(&file) |
There was a problem hiding this comment.
we want to take both fcntl and flock locks:
this lock works for hv_kvp_daemon, but cloud-init uses flock(LOCK_EX)
There was a problem hiding this comment.
maybe add a lock_for_reading() and lock_for_writing()
and the semantic here, if needed, perhaps should be read_only: bool, because we want to guarantee the locks for any write operations
|
|
||
| if delete_index != last_index { | ||
| self.file.seek(io::SeekFrom::Start( | ||
| last_index as u64 * RECORD_SIZE as u64, |
There was a problem hiding this comment.
why last index? doesn't this reverse ordering if copying the last record and moving it to current?
There was a problem hiding this comment.
I added some comments to make the iteration logic clearer here, but let me know if you still have questions/concerns and I can rework the approach!
There was a problem hiding this comment.
/// Exposes a bug where `remove_current()` reorders duplicate
/// records via its swap-with-last strategy, silently changing
/// which duplicate `entries()` reports as last-write-wins.
///
/// Initial on-disk order has `("dup", "C")` as the final record
/// for key `"dup"`, so `entries()["dup"] == "C"`. After deleting
/// the unrelated `"middle"` key, the swap moves `("dup", "C")`
/// into the freed slot ahead of `("dup", "B")`, leaving `"B"` as
/// the on-disk-last record for `"dup"`.
#[test]
fn test_delete_reorders_duplicates_breaking_last_write_wins() {
let dir = TempDir::new().unwrap();
let store = safe_store(dir.path());
store
.populate(pairs([
("dup", "A"),
("middle", "m"),
("dup", "B"),
("other", "x"),
("dup", "C"),
]))
.unwrap();
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"precondition: last-write-wins picks the final on-disk record",
);
assert!(store.delete("middle").unwrap());
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"deleting an unrelated key must not change which duplicate \
entries() reports as the winning value",
);
}
/// Companion to
/// [`test_delete_reorders_duplicates_breaking_last_write_wins`]
/// for the other production caller of `remove_current()`: the
/// duplicate-collapsing branch of [`KvpPoolStore::insert`].
///
/// Initial on-disk order has `("dup", "C")` as the final record
/// for key `"dup"`, so `entries()["dup"] == "C"`. Inserting
/// `"ins"` overwrites the first `"ins"` record in place and then
/// calls `remove_current()` on the second `"ins"`, which swaps
/// the tail `("dup", "C")` forward of `("dup", "B")`, leaving
/// `"B"` as the on-disk-last record for `"dup"`.
#[test]
fn test_insert_reorders_duplicates_breaking_last_write_wins() {
let dir = TempDir::new().unwrap();
let store = safe_store(dir.path());
store
.populate(pairs([
("ins", "i1"),
("ins", "i2"),
("dup", "B"),
("dup", "C"),
]))
.unwrap();
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"precondition: last-write-wins picks the final on-disk record",
);
store.insert("ins", "new").unwrap();
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"insert collapsing its own duplicates must not change which \
duplicate of an unrelated key entries() reports as winning",
);
}failures:
---- store::tests::test_delete_reorders_duplicates_breaking_last_write_wins stdout ----
thread 'store::tests::test_delete_reorders_duplicates_breaking_last_write_wins' (1817184) panicked at libazureinit-kvp/src/store.rs:1341:9:
assertion `left == right` failed: deleting an unrelated key must not change which duplicate entries() reports as the winning value
left: Some("B")
right: Some("C")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
---- store::tests::test_insert_reorders_duplicates_breaking_last_write_wins stdout ----
thread 'store::tests::test_insert_reorders_duplicates_breaking_last_write_wins' (1817203) panicked at libazureinit-kvp/src/store.rs:1382:9:
assertion `left == right` failed: insert collapsing its own duplicates must not change which duplicate of an unrelated key entries() reports as winning
left: Some("B")
right: Some("C")
failures:
store::tests::test_delete_reorders_duplicates_breaking_last_write_wins
store::tests::test_insert_reorders_duplicates_breaking_last_write_wins
test result: FAILED. 100 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.72s
error: test failed, to rerun pass `-p libazureinit-kvp --lib`There was a problem hiding this comment.
or for a dump-based view
/// Exposes a bug where `remove_current()` reorders duplicate
/// records via its swap-with-last strategy, silently changing
/// which duplicate `entries()` reports as last-write-wins.
///
/// Initial on-disk order has `("dup", "C")` as the final record
/// for key `"dup"`, so `entries()["dup"] == "C"`. After deleting
/// the unrelated `"middle"` key, the swap moves `("dup", "C")`
/// into the freed slot ahead of `("dup", "B")`, leaving `"B"` as
/// the on-disk-last record for `"dup"`.
#[test]
fn test_delete_reorders_duplicates_breaking_last_write_wins() {
let dir = TempDir::new().unwrap();
let store = safe_store(dir.path());
store
.populate(pairs([
("dup", "A"),
("middle", "m"),
("dup", "B"),
("other", "x"),
("dup", "C"),
]))
.unwrap();
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"precondition: last-write-wins picks the final on-disk record",
);
assert!(store.delete("middle").unwrap());
assert_eq!(
store.dump().unwrap(),
pairs([
("dup", "A"),
("dup", "B"),
("other", "x"),
("dup", "C"),
]),
"deleting an unrelated key must preserve the relative order \
of every other record (so last-write-wins still picks C \
for `dup`)",
);
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"deleting an unrelated key must not change which duplicate \
entries() reports as the winning value",
);
}
/// Companion to
/// [`test_delete_reorders_duplicates_breaking_last_write_wins`]
/// for the other production caller of `remove_current()`: the
/// duplicate-collapsing branch of [`KvpPoolStore::insert`].
///
/// Initial on-disk order has `("dup", "C")` as the final record
/// for key `"dup"`, so `entries()["dup"] == "C"`. Inserting
/// `"ins"` overwrites the first `"ins"` record in place and then
/// calls `remove_current()` on the second `"ins"`, which swaps
/// the tail `("dup", "C")` forward of `("dup", "B")`, leaving
/// `"B"` as the on-disk-last record for `"dup"`.
#[test]
fn test_insert_reorders_duplicates_breaking_last_write_wins() {
let dir = TempDir::new().unwrap();
let store = safe_store(dir.path());
store
.populate(pairs([
("ins", "i1"),
("ins", "i2"),
("dup", "B"),
("dup", "C"),
]))
.unwrap();
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"precondition: last-write-wins picks the final on-disk record",
);
store.insert("ins", "new").unwrap();
assert_eq!(
store.dump().unwrap(),
pairs([("ins", "new"), ("dup", "B"), ("dup", "C")]),
"collapsing duplicate `ins` records must preserve the \
relative order of unrelated `dup` records (so last-write-wins \
still picks C for `dup`)",
);
assert_eq!(
store.entries().unwrap().get("dup"),
Some(&"C".to_string()),
"insert collapsing its own duplicates must not change which \
duplicate of an unrelated key entries() reports as winning",
);
}
failures:
---- store::tests::test_delete_reorders_duplicates_breaking_last_write_wins stdout ----
thread 'store::tests::test_delete_reorders_duplicates_breaking_last_write_wins' (1818095) panicked at libazureinit-kvp/src/store.rs:1341:9:
assertion `left == right` failed: deleting an unrelated key must preserve the relative order of every other record (so last-write-wins still picks C for `dup`)
left: [("dup", "A"), ("dup", "C"), ("dup", "B"), ("other", "x")]
right: [("dup", "A"), ("dup", "B"), ("other", "x"), ("dup", "C")]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
---- store::tests::test_insert_reorders_duplicates_breaking_last_write_wins stdout ----
thread 'store::tests::test_insert_reorders_duplicates_breaking_last_write_wins' (1818139) panicked at libazureinit-kvp/src/store.rs:1394:9:
assertion `left == right` failed: collapsing duplicate `ins` records must preserve the relative order of unrelated `dup` records (so last-write-wins still picks C for `dup`)
left: [("ins", "new"), ("dup", "C"), ("dup", "B")]
right: [("ins", "new"), ("dup", "B"), ("dup", "C")]There was a problem hiding this comment.
I think we just have to bite the bullet and redo these to shift everything up (and delete dupe keys in the process). I think if we have to iterate through all entries anyways, the cost isn't really a problem. Callers should avoid introducing duplicate keys anyways so we likely won't hit the worst-case.
680057c to
d925931
Compare
| /// Remove all records matching the key. Returns `true` if at least | ||
| /// one record was present. | ||
| pub fn delete(&self, key: &str) -> Result<bool, KvpError> { | ||
| let file = match self.open_for_read_write() { |
There was a problem hiding this comment.
why is this special cased and not handled by iter_mut()? we should be able to see the NotFound error [and ignore it] through that interface too?
There was a problem hiding this comment.
Good call out - this should be fixed in the most recent commit!
| F: FnOnce(&mut KvpPoolIter) -> Result<T, KvpError>, | ||
| { | ||
| let work_result = f(&mut iter); | ||
| let unlock_result = iter.unlock().map_err(KvpError::from); |
There was a problem hiding this comment.
OK, I think I've come around on the drop pattern here.
this way if scope of iter is too long, it can be simply managed with something like:
let result = {
let mut iter = self.iter_mut()?;
iter.append(key, value)?;
};to ensure it's unlocked when the caller needs it to be.
I think this is more generally safe than trying to ensure all callers run_with_iter(). And file closure depends on the same mechanism, so this makes it consistent.
There was a problem hiding this comment.
Adopted this in the latest commit!
Update KVP pool mutation paths to rely on iterator/file drop for lock release, matching the RAII pattern used elsewhere, and route through so missing files are handled through the iterator interface. Keep create-on-write behavior only for append/insert/populate. Tighten KVP file handling by rejecting malformed pool file lengths in , creating new pool files with private permissions, making stale-file clearing atomic under one exclusive lock, and deriving boot time from . Also reject embedded null bytes in written values and preserve iterator cursor position when appending after in-place removal. Add focused regression coverage for file permissions, malformed lengths, value validation, stale boot-time parsing, and iterator append/remove behavior. Remove the unused dependency from and add for warning on structurally invalid read/delete lookup keys.
Merge 30+ near-duplicate tests in libazureinit-kvp/store.rs into parametrized rstest cases driven by small enums (WriteOp, ReadOp, StoreOp, FifoOp, KvpErrKind, Field): - test_max_size_getters folds test_max_key_size and test_max_value_size across both pool modes. - test_write_rejects_invalid_input replaces five tests covering empty-key and null-byte rejection on insert/append/populate. - test_missing_file_is_ok replaces eight per-op tests asserting that read/delete/clear/clear_if_stale/entries/dump/len/is_stale/ is_stale_at_boot tolerate a missing pool file. - test_bad_key_is_silent_noop replaces four tests covering empty and null-byte keys on read/delete. - test_parse_proc_stat_btime_cases replaces three success/error variants. - test_encode_decode_round_trip + test_decode_record_errors replace six encode/decode tests. - test_insert_file_size_delta replaces two file-size delta tests. - test_fifo_propagates_io_error replaces three FIFO error tests using a new make_fifo_at helper. Extend test_permission_denied to also exercise insert and append, hitting the open-error '?' residuals in those write paths that were previously uncovered. Replace KvpErrKind::of's catch-all 'panic' arm (an unreachable region on stable Rust) with a Self::matches helper, eliminating dead code in the test fixture. Net effect: 119 -> 77 test functions while preserving full case coverage (121 logical cases pass). Coverage: store.rs regions 98.18% -> 98.28%; lines unchanged at 99.87%.
Switch read() and delete() from silent Ok(None)/Ok(false) no-ops to returning KvpError::EmptyKey / KvpError::KeyContainsNull via validate_key(key, usize::MAX). Size is intentionally uncapped on read and delete so a Safe-mode store can still access keys written in Unsafe mode. Replace test_bad_key_is_silent_noop with test_bad_key_is_rejected and update the oversized-key assertion in test_read_accepts_wire_max_key_in_safe_mode (oversized reads now miss rather than being rejected, since size is uncapped).
Introduce a SysOps + Handle trait abstraction so every fallible IO
syscall in store.rs (open, lock, seek, read/write, set_len, metadata,
boot_time) becomes mockable. Production uses OsSysOps + OsHandle that
delegate to std::fs / libc unchanged; tests use MockSysOps + MemHandle
with per-op fault queues to drive every '?' Err residual on stable.
KvpPoolStore now holds Arc<dyn SysOps>, KvpPoolIter holds
Box<dyn Handle>, and the free 'lock_for_{reading,writing}' helpers
take &mut dyn Handle. OsSysOps' boot_time reads from a configurable
proc_stat_path so a test can simulate a missing /proc/stat.
Add ~40 fault-injection tests covering every previously-uncovered
production region (lock failures, metadata, set_len, write, read,
seek, flush, boot_time, path_metadata) plus their cleanup paths.
Refactor 'assert!(matches!(err, ...))' patterns to predicate helpers
(is_io, is_max_keys, is_value_too_large, is_key_too_large) exercised
by a dedicated test_error_predicates test, eliminating uncoverable
false arms inside the matches! macro.
Result: 175 lib tests pass; cargo llvm-cov reports 100.00% regions,
100.00% functions, 100.00% lines on stable.
6540a20 to
7518bfa
Compare
What this PR introduces
libazureinit-kvp.libazureinit-kvp/src/lib.rs,libazureinit-kvp/src/store.rs, andlibazureinit-kvp/src/error.rs.libc,serde,serde_json,sysinfo.libazureinit. A follow-up PR will replacelibazureinit/src/kvp.rswith this implementation.KVP design
Single concrete type — no trait indirection.
KvpPoolStore: file-backed KVP pool store.KvpPool: enum identifying one of the five Hyper-V pool indices.PoolMode: policy for write-path size limits.KvpError: error type.KvpPoolStoreAPIConstruction:
KvpPoolStore::new(pool, mode)— uses the default directory/var/lib/hyperv.KvpPoolStore::new_in(pool, dir, mode)— uses a caller-supplied directory (file name derived frompool).Reads / queries (do not enforce mode limits on the key argument; a
Safe-mode store can read keys written inUnsafemode):read(key) -> Result<Option<String>, KvpError>entries() -> Result<HashMap<String, String>, KvpError>(deduplicated, last-write-wins)dump() -> Result<Vec<(String, String)>, KvpError>(on-disk order, preserves duplicates)dump_json(path) -> Result<(), KvpError>(writes deduplicated entries as JSON)len() -> Result<usize, KvpError>(on-disk record count, not unique keys)is_empty() -> Result<bool, KvpError>is_stale() -> Result<bool, KvpError>Writes (enforce
PoolModekey and value size limits):insert(key, value) -> Result<(), KvpError>— upsert; overwrites first match in place, removes any further duplicates, and enforces a 1024 unique-key cap for new keys.append(key, value) -> Result<(), KvpError>— blind append; no duplicate check, no unique-key cap. Intended for log/telemetry workloads.delete(key) -> Result<bool, KvpError>— removes all records matching the key; returnstrueif any were present.clear() -> Result<(), KvpError>— truncates the file.clear_if_stale() -> Result<(), KvpError>— convenience:is_stale()+clear().Metadata accessors:
pool() -> KvpPoolmode() -> PoolModepath() -> &Pathmax_key_size() -> usizemax_value_size() -> usizePool identity
KvpPoolenumerates the five standard Hyper-V KVP pool indices:External.kvp_pool_0Guest.kvp_pool_1Auto.kvp_pool_2AutoExternal.kvp_pool_3AutoInternal.kvp_pool_4KvpPool::default_dir()returns/var/lib/hyperv.KvpPool::default_path()returns the full default path for a pool.On-disk format and limits
Hyper-V wire format: fixed-size records, identical across environments.
insert, notappend)PoolModecontrols write-side size limits:SafeUnsafeRead-side operations do not enforce these limits, so a
Safe-mode store can read keys written inUnsafemode.Locking change (
flock->fcntlOFD)fs2advisory locks withlibc::fcntlopen file description (OFD) locks.fcntl(F_OFD_SETLKW, F_RDLCK)fcntl(F_OFD_SETLKW, F_WRLCK)fcntl(F_OFD_SETLK, F_UNLCK)flockandfcntllocks are separate lock domains and do not coordinate. OFD locks are also per-fd (safe across threads sharing a process) while still being compatible with the POSIXfcntllocks used byhv_kvp_daemonand cloud-init.fcntl-style locks.fcntl_unlockreturnsio::Result<()>so callers can choose to surface unlock failures or ignore them (e.g. inDropimpls, where returning errors isn't possible).File I/O behavior
Standard file I/O via
OpenOptions,read_exact,write_all,set_len,seek,flush.open_for_read_write_create()explicitly sets.truncate(false)to avoid implicit truncation on open.KvpPoolIterthat holds the file, keeps the lock for its lifetime, and supports in-place value overwrite and swap-remove. The lock is released when the iterator is dropped.Flows:
insert— open read+write+create, take exclusive lock, iterate records (collecting unique keys into a set). If the key exists, overwrite the first match in place and remove any further duplicates. If the key is new, enforce the 1024 unique-key cap and append at EOF. Flush, unlock on drop.append— open read+write+create, take exclusive lock, seek to EOF, write a new record. No duplicate check, no unique-key cap. Flush, unlock on drop.delete— open read+write, take exclusive lock, iterate records. For each match, swap with the final record and truncate by one. Flush if any were removed, unlock on drop.clear— open read+write, take exclusive lock,set_len(0). Unlock error surfaces only if the truncate succeeded (viawrite_result.and(unlock_result)).read/entries/dump— open read-only, take shared lock, decode records. Unlock on drop.Stale-data handling
is_stale()compares the pool file's mtime to the system boot time (derived fromsysinfo::System::uptime()).clear_if_stale()is a convenience that clears the file only when stale.Validation
Key and value validation are implemented as free functions:
validate_key(key, max)— checks non-empty, no null bytes, and length ≤ max.validate_value(value, max)— checks length ≤ max.Both are called by
appendandinsertusing limits derived from the store'sPoolMode.readanddeletedo not validate the key argument — absent keys simply returnNone/false.Error model
KvpErrorvariants:EmptyKeyKeyContainsNullKeyTooLarge { max, actual }ValueTooLarge { max, actual }MaxUniqueKeysExceeded { max }Io(io::Error)Test coverage
88 tests cover:
SafeandUnsafemodes.insertsemantics: upsert, duplicate collapse, unique-key cap (allows 1024 then rejects 1025th, allows overwrite at cap, allows new key after delete, cap uses unique keys not record count).appendsemantics: no cap, duplicate-key last-wins on read, size validation.readleniency: accepts over-limit keys, returnsNonefor missing keys without validation errors.delete: removes single and duplicate records, returnsfalsefor missing file and missing key, no key validation.clear,clear_if_stale,len,is_empty,is_stale,is_stale_at_boot.dump_jsonround-trips with and without data.next().next().fcntlfailure paths (lock on mismatched fd modes, FIFO truncation / seek errors on Linux).KvpErrordisplay, source, andFrom<io::Error>conversion.Coverage Report