Skip to content

fix(pds): space reads before the first write are empty, not missing - #232

Open
ngerakines wants to merge 1 commit into
blacksky-algorithms:mainfrom
ngerakines:fix/space-reads-before-first-write
Open

fix(pds): space reads before the first write are empty, not missing#232
ngerakines wants to merge 1 commit into
blacksky-algorithms:mainfrom
ngerakines:fix/space-reads-before-first-write

Conversation

@ngerakines

Copy link
Copy Markdown

The problem

SpaceStore::live_repo_state answers two questions at once — is this repo tombstoned, and does a space_repo row exist — and six space read handlers call it as a precondition. The row is created by the member's first write in apply_writes_tx, so a member who has been added to a space and has not written yet gets 400 SpaceNotFound from every read of their repo.

Every member passes through that window exactly once, and an app that syncs a member's repo on page load meets them in it. Observed against a live space whose authority runs a different implementation: addMember succeeded, and every subsequent com.atproto.space.listRecords for that member returned SpaceNotFound until they wrote — which they could not usefully do while the app could not read them back.

simplespace/add_member.rs already names the ordering:

A membership must be discoverable through the member's own listSpaces before their first write creates a repo row.

The change

A new SpaceStore::readable_repo_state answers only what a read needs: Ok(None) before the first write, and the same SpaceDeleted error for a tombstoned repo. Five handlers swap to it and fall through to their naturally empty results; list_repo_ops returns an empty page so an incremental syncer can start from since = None.

before after
listRecords 400 SpaceNotFound 200 { records: [] }
getRecord 400 SpaceNotFound RecordNotFound
listBlobs 400 SpaceNotFound empty page
getBlob 400 SpaceNotFound BlobNotFound
registerNotify 400 SpaceNotFound registers
listRepoOps 400 SpaceNotFound empty page
tombstoned repo SpaceDeleted SpaceDeleted (unchanged)

Deliberately unchanged: getRepo and getLatestCommit both sign a commit from state.rev/state.hash(), and an unwritten repo has no commit to sign. Erroring there is defensible — it just should not be the same error that says the space is missing. Happy to follow up separately if you'd like a distinct code.

One existing assertion changed: repo_lifecycle_flags_and_listing pinned the old list_repo_ops behaviour. That is the one behavioural change worth a second look.

The write path is untouched — applyWrites and putRecord never required the row and already create it on first commit.

Verification

  • cargo test -p rsky-pds --lib — 286 passed
  • cargo fmt --check, cargo clippy -p rsky-pds --lib clean
  • New reads_before_the_first_write_are_empty_not_missing fails without the fix (verified by reverting readable_repo_state to delegate to live_repo_state)
  • New readable_repo_state_still_refuses_a_deleted_repo covers the tombstone half

Suggested conformance check

An invited member with no records reads as empty. Every implementation passes through that state, and it is easy to never test — anything that writes before it reads skips the window entirely.

`SpaceStore::live_repo_state` answers two questions at once — is this repo
tombstoned, and does a `space_repo` row exist — and six read handlers call
it as a precondition. The row is created by the member's first write, in
`apply_writes_tx`, so every read against a member who has been added to a
space and has not written yet fails with `400 SpaceNotFound` instead of
returning an empty result.

That window is not a corner case. Every member passes through it exactly
once, and an application that syncs a member's repo on page load meets them
in it. `simplespace::add_member` already names the ordering in a comment —
membership must be discoverable "before their first write creates a repo
row" — so the read behaviour contradicts an invariant this code states
about itself.

`readable_repo_state` answers only the question a read needs: `Ok(None)`
before the first write, and the same `SpaceDeleted` error for a tombstoned
repo. The five handlers that can fall through to a naturally empty result
now do so, and `list_repo_ops` returns an empty page rather than an error
so an incremental syncer can start from `since = None`.

`getRepo` and `getLatestCommit` are deliberately unchanged: both sign a
commit from `state.rev` and `state.hash()`, and an unwritten repo has no
commit to sign. Erroring there is defensible; it just should not be the
same error that says the space is missing.

`repo_lifecycle_flags_and_listing` asserted the old `list_repo_ops`
behaviour, so it is updated rather than left to fail — the change to that
assertion is deliberate and is the one behavioural change a reviewer should
look at twice.

Found while interoperating with a space whose authority runs a different
implementation: a member was added successfully and every subsequent read
of their repo returned SpaceNotFound until they wrote, which they could not
usefully do while the app could not read them back.
@rudyfraser

Copy link
Copy Markdown
Member

The listRecords change is correct, but listRepoOps still returns SpaceNotFound before the first write.

SpaceStore::list_repo_ops now returns ([], false) for a missing space_repo row. The handler then sees has_more == false and calls serve_commit, which calls live_repo_state and fails on the same missing row.

For an admitted member with no commits, return 200 with an empty ops array and no commit. Keep the signed commit for existing, caught-up repos. Please add an HTTP-level regression test covering addMember -> no write -> listRecords and listRepoOps.

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.

2 participants