Skip to content

Give MetadataStorage an update_* so a field change stops crossing the seam #412

Description

@blooop

Decided on #400, which established there is no live race — read the resolution comment there before starting, because it is what keeps this slice from being mis-sold. This is a maintainability and modelling change. Nothing here closes a bug, and the PR must not claim to.

Why

MetadataStorage offers get_* and add_* and no update (domain/metadata.rs:498, :461), so a caller with one field to change has no move but read-modify-write across the seam: the record is cloned before the lock exists, and add_* inserts it wholesale over the map exclusive freshly reloaded (:562). Two production sites do that today.

That it costs nothing today is an accident of which fields have readers, and the argument runs through six files. #400 §4 has it in full; the short version is that the only concurrently-writable field of BaseRepository is worktrees, which nothing reads, and the only concurrently-writable fields of WorktreeInfo are either unread or derived byte-identically by the other writer. Add a reader for worktrees, or a field to BaseRepository written off the repo-lock path, and it goes live with nothing going red.

The module already knows the right shape. commit_migration (metadata.rs:674) reloads under the lock and hands the reloaded IndexMap to an edit closure; migrate_cache mutates records through values_mut() (flows/migration.rs:192). This generalises that rather than adding a second mechanism.

Slice

  1. update_repository(owner, repo, |r| …) and update_worktree(owner, repo, branch, |w| …) on MetadataStorage, running the closure on the record loaded under the lock, saving only if the key was there. Built on exclusive, shaped after commit_migration.
  2. Convert the two genuine call sites:
    • flows/repo_manager.rs:1429-1436last_fetched.
    • flows/lifecycle.rs:3068-3070devpod_workspace_id.
  3. Leave the other two alone, and say why in the PR body:
    • flows/migration.rs:306 already runs inside commit_migration's closure on reloaded records. It is not a cross-seam RMW.
    • flows/workspace_clone.rs:960-961 reads nothing — WorktreeInfo::new (domain/model.rs:283) builds the record from launch inputs. Wholesale registration is a different intent from updating a field, so add_worktree stays and this site keeps calling it.
  4. With no flows-layer site constructing a record it did not read, narrow the fields on BaseRepository/WorktreeInfo (domain/model.rs:199-223) to module-private wherever the borrow checker now allows. This is the constructive-modelling half: "a record whose unseen fields the caller invented" stops being representable.

Net lines should be negative in flows/.

Tests, red first

Not the two-process test #400 originally proposed — that passes today, for the reason in §4, and would mislead. Both of these live at the metadata seam and need no subprocess: flock is per open file description (domain/locks.rs:14-17), so a second MetadataStorage on the same path inside one test is a genuine second writer.

  • A concurrent field update survives. Store A loads. Store B writes a different field of the same record and saves. Store A update_*s its field. Assert both fields on disk. Red today with add_*: the pre-lock clone overwrites B.
  • An update does not resurrect a deleted record. Store A loads. Store B removes the worktree. Store A update_worktrees that key. Assert the key is still absent, and that the call reports it did nothing. Red today at metadata.rs:506, whose insert is unconditional.

Out of scope

Making the repo lock cover apply_reconciliation and remove_clone. Those are real asymmetries (#400 §1) but a separate question, and the closure fix does not depend on the answer.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions