Skip to content

feat(kvp): add store trait and Hyper-V KVP storage crate#288

Open
peytonr18 wants to merge 27 commits into
Azure:mainfrom
peytonr18:probertson/kvp-store-trait
Open

feat(kvp): add store trait and Hyper-V KVP storage crate#288
peytonr18 wants to merge 27 commits into
Azure:mainfrom
peytonr18:probertson/kvp-store-trait

Conversation

@peytonr18
Copy link
Copy Markdown
Contributor

@peytonr18 peytonr18 commented Mar 9, 2026

What this PR introduces

  • Adds workspace crate libazureinit-kvp.
  • Adds libazureinit-kvp/src/lib.rs, libazureinit-kvp/src/store.rs, and libazureinit-kvp/src/error.rs.
  • Adds crate dependencies: libc, serde, serde_json, sysinfo.
  • This is step 1 of the abstraction — the crate exists and is exercised by its own tests, but is not yet wired into libazureinit. A follow-up PR will replace libazureinit/src/kvp.rs with 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.

KvpPoolStore API

Construction:

  • 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 from pool).

Reads / queries (do not enforce mode limits on the key argument; a Safe-mode store can read keys written in Unsafe mode):

  • 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 PoolMode key 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; returns true if any were present.
  • clear() -> Result<(), KvpError> — truncates the file.
  • clear_if_stale() -> Result<(), KvpError> — convenience: is_stale() + clear().

Metadata accessors:

  • pool() -> KvpPool
  • mode() -> PoolMode
  • path() -> &Path
  • max_key_size() -> usize
  • max_value_size() -> usize

Pool identity

KvpPool enumerates the five standard Hyper-V KVP pool indices:

Variant File Role
External .kvp_pool_0 Host-to-guest, pushed by host admin
Guest .kvp_pool_1 Guest-to-host; where cloud-init / azure-init write
Auto .kvp_pool_2 Guest intrinsics generated by the daemon
AutoExternal .kvp_pool_3 Host-originated data describing the host
AutoInternal .kvp_pool_4 Undocumented; no pool file exists

KvpPool::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.

  • key field: 512 bytes (zero-padded)
  • value field: 2048 bytes (zero-padded)
  • record size: 2560 bytes
  • maximum unique keys: 1024 (enforced by insert, not append)

PoolMode controls write-side size limits:

Mode Max key Max value Notes
Safe 254 bytes 1022 bytes Recommended for Linux kernel compatibility
Unsafe 512 bytes 2048 bytes Full wire-format maximums

Read-side operations do not enforce these limits, so a Safe-mode store can read keys written in Unsafe mode.

Locking change (flock -> fcntl OFD)

  • Replaces fs2 advisory locks with libc::fcntl open file description (OFD) locks.
  • Lock behavior:
    • shared read lock: fcntl(F_OFD_SETLKW, F_RDLCK)
    • exclusive write lock: fcntl(F_OFD_SETLKW, F_WRLCK)
    • unlock: fcntl(F_OFD_SETLK, F_UNLCK)
  • Why: Linux flock and fcntl locks 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 POSIX fcntl locks used by hv_kvp_daemon and cloud-init.
  • Result: the KVP store coordinates correctly with other Linux components that use fcntl-style locks.
  • fcntl_unlock returns io::Result<()> so callers can choose to surface unlock failures or ignore them (e.g. in Drop impls, 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.
  • Iteration is handled by an internal KvpPoolIter that 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 (via write_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 from sysinfo::System::uptime()).
  • clear_if_stale() is a convenience that clears the file only when stale.
  • Stale detection is explicit and caller-controlled — no automatic truncation occurs during construction.

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 append and insert using limits derived from the store's PoolMode. read and delete do not validate the key argument — absent keys simply return None / false.

Error model

KvpError variants:

  • EmptyKey
  • KeyContainsNull
  • KeyTooLarge { max, actual }
  • ValueTooLarge { max, actual }
  • MaxUniqueKeysExceeded { max }
  • Io(io::Error)

Test coverage

88 tests cover:

  • Key/value validation boundaries in both Safe and Unsafe modes.
  • Pool identity: file names, default dir, default path, per-variant paths.
  • insert semantics: 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).
  • append semantics: no cap, duplicate-key last-wins on read, size validation.
  • read leniency: accepts over-limit keys, returns None for missing keys without validation errors.
  • delete: removes single and duplicate records, returns false for missing file and missing key, no key validation.
  • clear, clear_if_stale, len, is_empty, is_stale, is_stale_at_boot.
  • dump_json round-trips with and without data.
  • Iterator correctness: yield order, empty file, malformed file error, mid-iteration drop releases lock, size hint tracks iteration, read errors on truncated files.
  • In-place overwrite: adjacent records untouched, multiple overwrites in one pass, error if called before first next().
  • Remove: swap-and-continue, remove-last (no swap), remove-only-record, error if called before first next().
  • File-size invariants: unchanged on overwrite, grows on new key, preserves other records.
  • File integrity after mixed insert/append/delete operations.
  • Concurrent reader/writer, writer/writer, append-only, and mixed insert+append scenarios (8 threads).
  • Permission-denied error paths for read, delete, clear, entries, dump, len, is_stale, is_stale_at_boot.
  • fcntl failure paths (lock on mismatched fd modes, FIFO truncation / seek errors on Linux).
  • KvpError display, source, and From<io::Error> conversion.

Coverage Report

image

@peytonr18 peytonr18 force-pushed the probertson/kvp-store-trait branch from b134a98 to f435f70 Compare March 9, 2026 19:19
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread doc/libazurekvp.md Outdated
Comment thread doc/libazurekvp.md Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
@cadejacobson cadejacobson self-requested a review March 9, 2026 20:42
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-kvp crate with KvpStore and KvpLimits (Hyper-V/Azure presets).
  • Implements Hyper-V binary pool-file backend with flock-based concurrency and stale-file truncation support.
  • Rewrites doc/libazurekvp.md as 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.

Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread doc/libazurekvp.md Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread doc/libazurekvp.md Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread libazureinit-kvp/src/hyperv.rs Outdated
Comment thread libazureinit-kvp/src/azure.rs Outdated
Comment thread libazureinit-kvp/src/lib.rs Outdated
peytonr18 and others added 3 commits March 19, 2026 11:56
…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().
Comment thread libazureinit-kvp/src/store.rs Outdated
/// 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.
Copy link
Copy Markdown
Contributor

@cjp256 cjp256 Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems like this should raise the error, but then have the caller ignore it if it's not needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

- 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-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.51%. Comparing base (5ff1d14) to head (7518bfa).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@peytonr18 peytonr18 force-pushed the probertson/kvp-store-trait branch 2 times, most recently from 4e1f57a to 680057c Compare April 23, 2026 21:19
Comment thread libazureinit-kvp/src/store.rs Outdated
Comment thread libazureinit-kvp/src/error.rs
Comment thread libazureinit-kvp/src/store.rs Outdated
Comment thread libazureinit-kvp/src/store.rs Outdated
Err(e) => return Err(e.into()),
};

let mut iter = KvpPoolIter::new(file, true)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use iter_mut() here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread libazureinit-kvp/src/store.rs Outdated
Comment thread libazureinit-kvp/src/store.rs
Comment thread libazureinit-kvp/src/store.rs Outdated

fn new(mut file: File, lock_exclusive: bool) -> Result<Self, KvpError> {
let lock_result = if lock_exclusive {
fcntl_lock_exclusive(&file)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we want to take both fcntl and flock locks:

this lock works for hv_kvp_daemon, but cloud-init uses flock(LOCK_EX)

https://github.com/canonical/cloud-init/blob/c7ae6a4cc0dad5a411945e0644dab6926d41a997/cloudinit/reporting/handlers.py#L286

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why last index? doesn't this reverse ordering if copying the last record and moving it to current?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Contributor

@cjp256 cjp256 May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    /// 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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread libazureinit-kvp/src/store.rs Outdated
Comment thread libazureinit-kvp/src/store.rs
@peytonr18 peytonr18 force-pushed the probertson/kvp-store-trait branch from 680057c to d925931 Compare April 28, 2026 06:17
Comment thread libazureinit-kvp/src/store.rs Outdated
/// 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() {
Copy link
Copy Markdown
Contributor

@cjp256 cjp256 May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

@peytonr18 peytonr18 May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call out - this should be fixed in the most recent commit!

Comment thread libazureinit-kvp/src/store.rs Outdated
F: FnOnce(&mut KvpPoolIter) -> Result<T, KvpError>,
{
let work_result = f(&mut iter);
let unlock_result = iter.unlock().map_err(KvpError::from);
Copy link
Copy Markdown
Contributor

@cjp256 cjp256 May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted this in the latest commit!

peytonr18 and others added 6 commits May 11, 2026 12:19
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.
@peytonr18 peytonr18 force-pushed the probertson/kvp-store-trait branch from 6540a20 to 7518bfa Compare May 21, 2026 20:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants