Skip to content

Commit 9c90de1

Browse files
futurepaulclaude
andcommitted
refresh reads again only the ref its writer moved
A sync moved main, but its refresh read both refs again: main's head and listing, then live's head, one code.storage call more on every sync that landed. `POST /api/f/{name}/refresh` now takes `{ref: "main" | "live"}`, the ref the writer moved, and reads only that one; a sync, a --watch pass and a session import name main, a rollback live. Without one (an older CLI's `{}`, or no body: the route read none before) it reads both, so an older writer is answered as before and a new CLI is safe on an older cell, which reads no body. A sync still returns once main's move is taken. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 28daf85 commit 9c90de1

9 files changed

Lines changed: 70 additions & 12 deletions

File tree

‎cell/src/fragment.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1088,7 +1088,11 @@ impl FragmentCell {
10881088
}
10891089
(Method::Delete, ["api", "secrets", key]) => self.delete_secret(&caller, key),
10901090
(Method::Get, ["api", "storage-token"]) => self.storage_token(&caller).await,
1091-
(Method::Post, ["api", "refresh"]) => self.refresh(&caller).await,
1091+
(Method::Post, ["api", "refresh"]) => {
1092+
// the ref its writer moved, or both (fragment_proto::Refresh)
1093+
let refs = fragment_proto::Refresh::refs_of(&req.bytes().await?).map_err(CellError::invalid)?;
1094+
self.refresh(&caller, &refs).await
1095+
}
10921096
(Method::Get, ["api", "files"]) => self.files(&caller).await,
10931097
(Method::Post, ["api", "files"]) => {
10941098
let body = body_json(&mut req).await?;

‎cell/src/plane.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ pub const QUIET_PASS_MS: i64 = 24 * 3600 * 1000;
4343
/// fails to follow: a token lives 15 minutes, so this is generous for
4444
/// whoever pushes with it, and outlasts a code.storage outage of hours.
4545
const MAY_LAG_MS: i64 = 24 * 3600 * 1000;
46-
pub const REFS: [&str; 2] = ["main", "live"];
46+
pub const REFS: [&str; 2] = fragment_proto::PINNED_REFS;
4747

4848
pub struct PinMove {
4949
pub changed: bool,
@@ -791,9 +791,11 @@ impl FragmentCell {
791791
self.schedule().await
792792
}
793793

794-
pub(crate) async fn refresh(&self, caller: &Caller) -> CellResult<Response> {
794+
/// `POST /api/refresh`: `refs` read again (the one its writer moved,
795+
/// or both), their moves taken before it answers.
796+
pub(crate) async fn refresh(&self, caller: &Caller, refs: &[&str]) -> CellResult<Response> {
795797
self.require(caller, false, Role::Editor)?;
796-
let moved = self.interpret(&REFS).await?;
798+
let moved = self.interpret(refs).await?;
797799
let refs: serde_json::Map<String, Value> = moved
798800
.into_iter()
799801
.map(|(which, m)| {

‎cli/src/main.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1890,7 +1890,7 @@ fn run(cli: Cli) -> Result<()> {
18901890
let author = Author::writer(&writer_id(&c));
18911891
let new_tip = storage.restore_live(&target, &live_tip, &format!("rollback {name} to {target}"), &author)
18921892
.map_err(cs_anyhow)?;
1893-
sync::refresh_pins(&c, &name);
1893+
sync::refresh_pins(&c, &name, LIVE);
18941894
json_exit(j, &json!({ "rolledBackTo": target, "liveTip": new_tip }));
18951895
println!("rolled back to {}: live is now {}", &target[..8.min(target.len())], &new_tip[..8.min(new_tip.len())]);
18961896
}

‎cli/src/session/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ fn write(c: &Client, name: &str, spool: &Spool) -> Result<Written> {
486486
let indexes = Indexes { c, name, storage: &storage, author: &author };
487487
let (entries, indexed) = indexes.write(head, merge.entries, merge.indexes, &written)?;
488488
if total > 0 || indexed {
489-
crate::sync::refresh_pins(c, name);
489+
crate::sync::refresh_pins(c, name, crate::codestorage::MAIN);
490490
}
491491
Ok(Written { added, updated, unchanged, entries })
492492
}

‎cli/src/sync.rs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ pub fn sync_once(client: &Client, name: &str, dir: &Path, opts: &SyncOptions) ->
535535
let storage = CodeStorage::connect(client, name)?;
536536
let report = pass_over(client, &storage, name, dir, opts, read_local(dir, name)?)?;
537537
if report.landed {
538-
refresh_pins(client, name);
538+
refresh_pins(client, name, MAIN);
539539
}
540540
Ok(report)
541541
}
@@ -743,12 +743,14 @@ pub fn pass_over(client: &Client, storage: &CodeStorage, name: &str, dir: &Path,
743743
Ok(report)
744744
}
745745

746-
/// Tells the cell its fragment's git moved. Our commits and ref moves are
746+
/// Tells the cell its fragment's `moved` ref moved. Our commits and ref moves are
747747
/// EXTERNAL pushes from the cell's perspective: without this nudge its
748748
/// pins wait out the 5-minute poll backstop before app reads and serving
749749
/// see them. Best-effort: the poll covers a missed nudge.
750-
pub fn refresh_pins(client: &Client, name: &str) {
751-
if let Err(e) = client.post_json(&format!("/api/f/{name}/refresh"), &serde_json::json!({})) {
750+
pub fn refresh_pins(client: &Client, name: &str, moved: &str) {
751+
// the cell reads again only the ref we moved (a cell before that reads both)
752+
let asked = fragment_proto::Refresh { moved: Some(moved.to_string()) };
753+
if let Err(e) = client.post_json(&format!("/api/f/{name}/refresh"), &asked) {
752754
eprintln!("warning: cell pin refresh failed ({e:#}); the poll backstop will catch up");
753755
}
754756
}

‎cli/src/watch.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ impl<'a> Watcher<'a> {
147147
let passed = self.storage.get(client).map_err(SyncError::from).and_then(|storage| sync::pass_over(client, storage, name, dir, opts, local));
148148
let report = self.refused_on_auth(passed)?;
149149
if report.landed {
150-
sync::refresh_pins(client, name);
150+
sync::refresh_pins(client, name, MAIN);
151151
}
152152
self.head = report.head.clone();
153153
Ok(Served::Passed(Box::new(report)))

‎crates/e2e/src/lanes/plane.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ fn files_lane(s: &mut Suite, api: &Api) -> Result<()> {
7676
let r = api.signed(&owner, "POST", &format!("/api/f/{name}/refresh"), Some(&json!({})))?;
7777
s.ok("refresh moves main at once", r.status == 200 && r.body["refs"]["main"]["moved"] == true && r.body["refs"]["live"]["absent"] == true, &r);
7878
s.ok("refresh made the file visible", read(api, &owner, &name, "quiet.md").as_deref() == Some("unannounced"), "");
79+
// a writer names the ref it moved, and only that one is read again
80+
s.fake.external_commit(&repo, "main", &[("named.md", Some(b"main alone"))], "unannounced");
81+
let r = api.signed(&owner, "POST", &format!("/api/f/{name}/refresh"), Some(&json!({ "ref": "main" })))?;
82+
s.ok(
83+
"a refresh naming main moves main and reads live not at all",
84+
r.status == 200 && r.body["refs"]["main"]["moved"] == true && r.body["refs"].get("live").is_none() && read(api, &owner, &name, "named.md").as_deref() == Some("main alone"),
85+
&r,
86+
);
87+
let r = api.signed(&owner, "POST", &format!("/api/f/{name}/refresh"), Some(&json!({ "ref": "dev" })))?;
88+
s.ok("a ref the platform does not pin is 400", r.status == 400, &r);
7989
s.fake.external_commit(&repo, "main", &[("polled.md", Some(b"found by the poll"))], "unannounced");
8090
let polled = s.eventually(Duration::from_secs(u64::from(crate::POLL_S) * 5), || read(api, &owner, &name, "polled.md").is_some());
8191
s.ok("the poll backstop finds a commit no one refreshed, a storage token since", polled, "");

‎crates/proto/src/lib.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,6 +1708,31 @@ pub struct SetVisibility {
17081708
pub visibility: Visibility,
17091709
}
17101710

1711+
/// The refs a fragment's git has that the platform follows (its pins).
1712+
pub const PINNED_REFS: [&str; 2] = ["main", "live"];
1713+
1714+
/// `POST /api/f/<name>/refresh`'s body: the ref its writer moved, which is
1715+
/// all the platform reads again (a sync moved main, a rollback live).
1716+
/// Without one (an older CLI's `{}`, or no body), both.
1717+
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1718+
pub struct Refresh {
1719+
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
1720+
pub moved: Option<String>,
1721+
}
1722+
1723+
impl Refresh {
1724+
/// The refs a refresh with this body reads again. A body that names
1725+
/// no ref, or is not one (the route once read none), reads both; a ref
1726+
/// the platform does not pin is refused.
1727+
pub fn refs_of(body: &[u8]) -> Result<Vec<&'static str>, String> {
1728+
let asked: Refresh = serde_json::from_slice(body).unwrap_or_default();
1729+
match asked.moved.as_deref() {
1730+
None => Ok(PINNED_REFS.to_vec()),
1731+
Some(r) => PINNED_REFS.iter().find(|p| **p == r).map(|p| vec![*p]).ok_or_else(|| format!("ref is main or live, not {r:?}")),
1732+
}
1733+
}
1734+
}
1735+
17111736
/// `GET /api/f/<name>/storage-token` (editor and up): a code.storage JWT
17121737
/// scoped to this fragment's repo, `git:read` + `git:write`.
17131738
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -2256,6 +2281,21 @@ pub mod wipe;
22562281
mod tests {
22572282
use super::*;
22582283

2284+
/// Goal: a refresh reads again the ref its writer names, and an older
2285+
/// writer's body (`{}`, none, or not JSON: the route read no body
2286+
/// before) both. Method: each body, and a ref the platform does not pin.
2287+
#[test]
2288+
fn a_refresh_reads_the_ref_it_names_or_both() {
2289+
assert_eq!(Refresh::refs_of(br#"{"ref":"main"}"#), Ok(vec!["main"]));
2290+
assert_eq!(Refresh::refs_of(br#"{"ref":"live"}"#), Ok(vec!["live"]));
2291+
for older in [&b"{}"[..], b"", b"not json", br#"{"other":1}"#] {
2292+
assert_eq!(Refresh::refs_of(older), Ok(vec!["main", "live"]), "{}", String::from_utf8_lossy(older));
2293+
}
2294+
assert!(Refresh::refs_of(br#"{"ref":"dev"}"#).is_err());
2295+
let sent = serde_json::to_value(Refresh { moved: Some("main".into()) }).unwrap();
2296+
assert_eq!(sent, serde_json::json!({ "ref": "main" }), "as a CLI sends it");
2297+
}
2298+
22592299
/// Goal: the typed identity and sign-in reads the same JSON the
22602300
/// registry and `GET /api/identities/me` carried before (T4: the wire
22612301
/// is unchanged). Method: the literal old forms, decoded and encoded.

‎docs/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,7 @@ inbox's, which takes its token. `{name}` is a fragment's name in full.
676676
| `GET /api/f/{name}/door` | its owner | → `{bound: {door, agent} \| null}`: whose door this connector is (An agent's doors) |
677677
| `POST /api/f/{name}/door` | its owner, in person (never an agent) | `{act: "link", agent}` → `{door, agent, relayed}`, or `{act: "unlink"}` → `{ok}` (An agent's doors). No connector, one the deployment does not allow, or an agent not the owner's, 403; another `act` 400 |
678678
| `GET /api/f/{name}/storage-token` | editor | → `{token, repo, api, expiresAt}`: ES256, this repo, `git:read`+`git:write`, `STORAGE_TOKEN_TTL_S` |
679-
| `POST /api/f/{name}/refresh` | editor | → `{ok, refs: {main: {pin, moved} \| {absent}, live: …}}` once a moved live is installed. A writer to the repo asks this after its push; the platform follows its own moves, and a busy fragment's poll is the backstop (`FRAGMENT_POLL_INTERVAL_S`) |
679+
| `POST /api/f/{name}/refresh` | editor | `{ref?: "main" \| "live"}`, the ref the writer moved (none, or no body: both; another ref 400) → `{ok, refs: {main: {pin, moved} \| {absent}, live: …}}`, the refs it read, once a moved live is installed. A writer to the repo asks this after its push; the platform follows its own moves, and a busy fragment's poll is the backstop (`FRAGMENT_POLL_INTERVAL_S`) |
680680
| `POST /api/f/{name}/files` | editor | `{files: [{path, text \| base64} \| {path, delete: true}], message?, key?}` → `{commit}`: one commit to main (at most `FILE_WRITES_MAX` files, 400; `API_FILES_WRITE_MAX_BYTES` decoded in a body of `BODY_MAX_BYTES`, 413; relative paths, no `.` or `..`). The same `key` from the same person answers the first commit again; a write of what main holds commits nothing. Main moves at once; live does not |
681681
| `POST /api/f/{name}/deploy` | editor | `{note?}` → `{live, canonical}`: live to main's tip, guarded against a live that moved meanwhile; the app installs at once |
682682
| `GET /api/f/{name}/files` | viewer | → `{ref, files: [{path, size, mode, lastCommitSha, machinery, blob?, sha256?, release?}]}` at main; a pointer's `sha256` is its blob. A blessed template's data from the release is listed beneath (`release: true`) |

0 commit comments

Comments
 (0)