Skip to content

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

Merged
cjp256 merged 28 commits into
Azure:mainfrom
peytonr18:probertson/kvp-store-trait
May 26, 2026
Merged

feat(kvp): add store trait and Hyper-V KVP storage crate#288
cjp256 merged 28 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, tracing. Dev-dependencies: rstest, tempfile.
  • 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 in the public API.

  • 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 size limits on the key argument; a Safe-mode store can read keys written in Unsafe mode):

  • read(key) -> Result<Option<String>, KvpError> — last-write-wins on duplicate keys.
  • entries() -> Result<HashMap<String, String>, KvpError> (deduplicated, last-write-wins).
  • dump() -> Result<Vec<(String, String)>, KvpError> (on-disk order, preserves duplicates).
  • 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 the 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.
  • populate(records) -> Result<(), KvpError> — atomic replace-all. Validates the full batch (including the unique-key cap) before locking, then truncates and rewrites under one exclusive lock. Preserves duplicates in iteration order. Inverse of dump.
  • 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> — reads boot time, takes the exclusive lock, then truncates only if the file's mtime is <= boot time. The check happens under the write lock so a concurrent writer can't extend mtime between the staleness check and the truncate.

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 UTF-8)
  • value field: 2048 bytes (zero-padded UTF-8)
  • record size: 2560 bytes
  • maximum unique keys: 1024 (enforced by insert and populate, not append)

All sizes are UTF-8 byte counts measured against the Linux on-disk layout. The Windows-side Hyper-V spec expresses limits in UTF-16 code units (256 / 1024), which equal the 512 / 2048-byte fields used by hv_kvp_daemon on Linux.

PoolMode controls write-side size limits:

Mode Max key Max value Notes
Safe 254 bytes 1022 bytes Recommended for Linux kernel compatibility (2 bytes under the kernel HV_KVP_EXCHANGE_MAX_* maximums to leave room for a NUL terminator on either side of the boundary)
Unsafe 512 bytes 2048 bytes Full wire-format maximums

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

Locking: both flock and fcntl OFD

The store takes both flock and fcntl open file description (OFD) locks for every operation:

  • Acquire flock first (LOCK_EX for writes, LOCK_SH for reads). If the subsequent fcntl lock fails, the flock is released before returning the error (all-or-nothing acquisition).
  • Then fcntl(F_OFD_SETLKW, ...) with F_WRLCK for writes or F_RDLCK for reads, range l_start = 0, l_len = 0 (whole file).
  • Linux flock(2) and fcntl(2) byte-range locks live in disjoint kernel domains and do not coordinate with each other. hv_kvp_daemon uses fcntl; cloud-init uses flock(LOCK_EX). Holding both is the only way to coordinate with either.
  • OFD fcntl locks (F_OFD_SETLKW) share a domain with classic POSIX fcntl byte-range locks but are per-fd and thread-safe — compatible with hv_kvp_daemon while remaining safe across threads sharing a process.
  • Successful locks are released when the file descriptor closes (via Drop on the handle), so there is no explicit unlock in normal paths.

File I/O behavior

Standard file I/O via OpenOptions, read_exact, write_all, set_len, seek, flush.

  • open_read_write_create() sets .truncate(false) to avoid implicit truncation on open and .mode(0o600) so newly created pool files are private to the owning user.
  • Iteration is handled by an internal KvpPoolIter that holds the file and 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 locks, iterate records (collecting unique keys into a set). If the key exists, overwrite the first match in place and remove any further duplicates via swap-with-last + truncate. If the key is new, enforce the 1024 unique-key cap and append at EOF. Flush; locks released on drop.
  • append — open read+write+create, take exclusive locks, seek to EOF, write a new record. No duplicate check, no unique-key cap. Flush.
  • populate — validate the full batch up front (key/value sizes and unique-key cap), then open read+write+create, take exclusive locks, set_len(0), and write every record. An I/O error mid-write may leave the file partially written.
  • delete — open read+write, take exclusive locks, iterate records. For each match, swap with the final record and truncate by one. Flush only if at least one record was removed.
  • clear — open read+write, take exclusive locks, set_len(0).
  • read / entries / dump — open read-only, take shared locks, decode records.

All missing-file paths return well-defined sentinels (None, false, empty collections, 0, false) instead of erroring.

Malformed-file handling:

  • A file whose size is not a multiple of the 2560-byte record size is rejected with an Io error from record_count_from_len on any operation that opens it for iteration or calls len(). Recover by calling clear().
  • Records whose key or value fields are not valid UTF-8 surface as Io(InvalidData) from decode_record. Trailing zero bytes in either field are trimmed during decode.

Stale-data handling

  • is_stale() compares the pool file's mtime to the system boot time, derived by parsing the btime field of /proc/stat.
  • 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) — rejects empty keys, null bytes, and lengths greater than max.
  • validate_value(value, max) — rejects null bytes and lengths greater than max.

insert, append, and populate validate using limits derived from the store's PoolMode. read and delete validate the key's content (non-empty, no nulls) but pass usize::MAX for the size cap so they don't reject otherwise-valid keys that were written in a different PoolMode.

Error model

KvpError variants:

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

Implements Display, Error (with source() chaining to the inner io::Error), and From<io::Error>.

Testability: internal SysOps / Handle traits

To exercise error paths that are difficult to trigger with real syscalls (lock failures, mid-write I/O errors, transient filesystem failures, deterministic boot times), the store is implemented against two internal traits:

  • SysOps — filesystem opens, path_metadata, boot_time.
  • Handle — per-file read, write, seek, set_len, metadata, flock_*, fcntl_*.

OsSysOps / OsHandle are the production implementations and are constructed by default by new / new_in. Tests can substitute a fault-injecting backend via the crate-private KvpPoolStore::with_ops constructor. The public API exposes only the concrete KvpPoolStore (which is Clone + Debug; the inner SysOps is held behind an Arc).

Test coverage

Comprehensive unit tests cover validation, lock interactions, fault injection via the SysOps / Handle traits, concurrent reader/writer scenarios, stale-data detection, and all documented error paths. 100% line coverage on Codecov.

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.
@peytonr18 peytonr18 force-pushed the probertson/kvp-store-trait branch 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
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
@cjp256 cjp256 merged commit 5b4a999 into Azure:main May 26, 2026
10 checks passed
@cjp256
Copy link
Copy Markdown
Contributor

cjp256 commented May 26, 2026

Great job @peytonr18, thanks!

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