Commit

Repos: nightly backups are queued, claimed and recorded, with their bundles in R2

repo_backups (migration 0013) keeps each repository's last backup and the job in hand. At 02:53 UTC the repositories whose refs moved are queued, oldest backup first, BACKUPS_PER_NIGHT at most; the runner claims a few at a time. A sandbox asks for its job through the API, sends its bundle in parts to the BACKUPS bucket (BACKUP_STORE r2, or s3 self-hosted), and the manifest gains the entry: a full bundle, then incrementals whose prerequisites are the last tips, full again every BACKUP_FULL_EVERY. The clone is metered as internal.git.backup_fetch, on g1t's bill only.

syntaqxcommitted Parente93c31eBrowse files
11 files+1313−60/11 viewed
+1−0
10411041 dependencies = [
10421042 "base64 0.22.1",
10431043 "futures-util",
1044+ "g1t-blobstore",
10441045 "g1t-contracts",
10451046 "g1t-kit",
10461047 "g1t-scan",
+138−0
1+//! Repository backups: a nightly `git bundle` of every repository whose
2+//! refs changed, kept outside the git store (docs/ARTIFACTS.md, R11).
3+//!
4+//! The repos service decides what is due and keeps the bundles and their
5+//! manifests (services/repos/src/backups.rs). It cannot run git, so the
6+//! bundle is cut where git runs: a sandbox the runner starts, which only
7+//! ever calls out, as a merge check does.
8+//!
9+//! 1. Each night the repos service queues the repositories whose refs
10+//! moved since their last backup.
11+//! 2. The runner's sweep claims a few at a time (`claim_backups`) and starts
12+//! a sandbox for each, with the job's id and token and nothing else.
13+//! 3. The sandbox asks for its job (`POST api.g1t.sh/backups/{job}/spec`):
14+//! a read-only git credential for the repository, minutes long, and the
15+//! commits the last bundle ended at. It clones, cuts the bundle, sends
16+//! it in parts (`PUT .../parts/{n}`), and says what it holds
17+//! (`POST .../complete`), or why it could not (`POST .../fail`).
18+//!
19+//! The job's token, in the `x-g1t-backup-token` header, is the only
20+//! credential the sandbox holds for g1t; it lasts as long as the job.
21+//!
22+//! What the runner and the repos service exchange is camelCase, as between
23+//! every service. What the sandbox sends and is sent is snake_case: it is
24+//! the API's.
25+
26+use std::collections::BTreeMap;
27+
28+use serde::{Deserialize, Serialize};
29+
30+use crate::repos::RepoPath;
31+
32+/// A bundle is sent in parts of this size; the last may be smaller.
33+pub const PART_BYTES: u64 = 32 * 1024 * 1024;
34+/// The header the sandbox sends its job's token in.
35+pub const TOKEN_HEADER: &str = "x-g1t-backup-token";
36+
37+/// `claim_backups`: up to `limit` queued backups, so long as no more than
38+/// `max_running` are then running. Returns `Vec<BackupClaim>`.
39+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40+#[serde(rename_all = "camelCase")]
41+pub struct ClaimBackupsArgs {
42+ pub limit: u32,
43+ pub max_running: u32,
44+}
45+
46+/// One backup to start.
47+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48+#[serde(rename_all = "camelCase")]
49+pub struct BackupClaim {
50+ pub job_id: String,
51+ /// Lets the sandbox, and nothing else, do this job.
52+ pub token: String,
53+ pub repo_id: String,
54+ /// Where the repository is now: for the sandbox's name and the logs.
55+ pub path: RepoPath,
56+}
57+
58+/// What kind of bundle a job cuts.
59+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60+#[serde(rename_all = "snake_case")]
61+pub enum BackupKind {
62+ /// Everything the repository has.
63+ Full,
64+ /// What is new since the last bundle: its prerequisites are the commits
65+ /// the last bundle's refs pointed to.
66+ Incremental,
67+}
68+
69+impl BackupKind {
70+ /// How a bundle's file name says what it is.
71+ pub fn suffix(self) -> &'static str {
72+ match self {
73+ BackupKind::Full => "full",
74+ BackupKind::Incremental => "incr",
75+ }
76+ }
77+}
78+
79+/// `backup_spec`, `backup_part` and the job's other calls: which job, and
80+/// its token.
81+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82+pub struct BackupJobArgs {
83+ pub job_id: String,
84+ pub token: String,
85+}
86+
87+/// The job, as the sandbox is given it. `Outcome<BackupSpec>`.
88+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
89+pub struct BackupSpec {
90+ pub kind: BackupKind,
91+ /// The repository in the git store, and a read-only credential for it
92+ /// that lasts minutes (sent as `Authorization: Bearer`).
93+ pub remote: String,
94+ pub git_token: String,
95+ /// For an incremental bundle: the commits it may leave out, and every
96+ /// commit they reach. Empty for a full one.
97+ pub prerequisites: Vec<String>,
98+ /// The refs the last bundle held. When the clone has exactly these,
99+ /// nothing has changed and no bundle is cut.
100+ pub previous_refs: BTreeMap<String, String>,
101+ pub part_bytes: u64,
102+}
103+
104+/// A part the repos service has kept: what completing the upload needs.
105+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106+pub struct BackupPart {
107+ pub number: u16,
108+ pub etag: String,
109+}
110+
111+/// `backup_complete`: the bundle is cut and sent. `Outcome<bool>`.
112+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113+pub struct BackupComplete {
114+ pub job_id: String,
115+ pub token: String,
116+ /// Every ref the bundle holds (`git for-each-ref` of the clone, and
117+ /// `HEAD`), by name.
118+ pub refs: BTreeMap<String, String>,
119+ /// The bundle's size; 0 when there was nothing new to bundle.
120+ pub size: u64,
121+ #[serde(default)]
122+ pub sha256: Option<String>,
123+ #[serde(default)]
124+ pub parts: Vec<BackupPart>,
125+ /// What the clone read from the git store, for its meters.
126+ #[serde(default)]
127+ pub fetched_bytes: u64,
128+}
129+
130+/// `backup_fail`: the job could not be done. `Outcome<bool>`.
131+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
132+pub struct BackupFail {
133+ pub job_id: String,
134+ pub token: String,
135+ pub error: String,
136+ #[serde(default)]
137+ pub fetched_bytes: u64,
138+}
+1−0
99 pub mod actions;
1010 pub mod agents;
1111 pub mod audit;
12+pub mod backups;
1213 pub mod billing;
1314 pub mod capture;
1415 pub mod credentials;
+4−1
7777 "d1": { "database": "g1t-repos", "migrations": "migrations" },
7878 "stage": "core",
7979 "secrets": ["REPOS_KEY"],
80− "setup": ["The Artifacts namespace `g1t` (the ARTIFACTS binding)"],
80+ "setup": [
81+ "The Artifacts namespace `g1t` (the ARTIFACTS binding)",
82+ "The R2 bucket for nightly backups: npx wrangler r2 bucket create g1t-backups"
83+ ],
8184 "self_host": "run"
8285 },
8386 "work": {
+1−1
9898
9999 test("shared crates and packages are read from workspace metadata", () => {
100100 assert.deepEqual(unit("events").dependsOn, ["crates/contracts", "crates/kit"]);
101− assert.deepEqual(unit("repos").dependsOn, ["crates/contracts", "crates/kit", "crates/scan", "crates/secrets"]);
101+ assert.deepEqual(unit("repos").dependsOn, ["crates/blobstore", "crates/contracts", "crates/kit", "crates/scan", "crates/secrets"]);
102102 assert.ok(unit("actions").dependsOn.includes("crates/actions"));
103103 assert.ok(!unit("events").dependsOn.includes("crates/scan"));
104104 assert.deepEqual(unit("web").dependsOn, ["packages/contracts", "packages/theme"]);
+1−0
1313 g1t-kit.workspace = true
1414 g1t-scan.workspace = true
1515 g1t-secrets.workspace = true
16+g1t-blobstore.workspace = true
1617 serde.workspace = true
1718 serde_json.workspace = true
1819 worker.workspace = true
+65−0
1+-- Nightly backups of every repository, outside the git store
2+-- (src/backups.rs; docs/ARTIFACTS.md, R11). One row per repository that
3+-- has been queued at least once: its last backup, and the job in hand.
4+--
5+-- status: idle | queued | running. The nightly cron queues the
6+-- repositories whose refs moved since their last backup; the runner's
7+-- sweep claims queued ones (`claim_backups`) and starts a sandbox for
8+-- each; the sandbox's `backup_complete` or `backup_fail` makes it idle
9+-- again, or queued for another try.
10+-- queued_ms, claimed_ms: milliseconds since the epoch. A job running past
11+-- its lease (3 hours) goes back in the queue.
12+-- attempts: tries tonight; past 3 it waits for the next night.
13+-- last_error: why the last try failed.
14+--
15+-- The job in hand, while running:
16+-- job_id, token_hash: the job, and the SHA-256 of the token its sandbox
17+-- holds (the only credential it has for g1t).
18+-- target_version: the repository's refs_version when the clone began,
19+-- which the backup is recorded at once done.
20+-- backed_from_ms: when that was.
21+-- store_key: the repository's name in the git store, for its meters.
22+-- upload_key, upload_id: the bundle's key in storage, and its multipart
23+-- upload. upload_kind: full | incr. upload_entry: the entry's id in the
24+-- manifest (`20261006T025300Z`).
25+-- prerequisites: JSON array, the commits the bundle leaves out.
26+--
27+-- The last backup:
28+-- refs_version: the refs_version it was cut at. The repository is due
29+-- again once its own goes past this, or once a credential that can push
30+-- is handed out after backed_up_ms (repos.refs_open_until).
31+-- backed_up_ms: when its clone began.
32+-- tips: JSON object, every ref it held by name: the next bundle's
33+-- prerequisites. The manifest in storage says the same, and is what a
34+-- restore reads.
35+-- last_entry: its id in the manifest. When the manifest's last entry is
36+-- another, the next backup is full.
37+CREATE TABLE repo_backups (
38+ repo_id TEXT PRIMARY KEY,
39+ status TEXT NOT NULL DEFAULT 'idle',
40+ queued_ms INTEGER,
41+ claimed_ms INTEGER,
42+ attempts INTEGER NOT NULL DEFAULT 0,
43+ last_error TEXT,
44+ job_id TEXT,
45+ token_hash TEXT,
46+ target_version INTEGER,
47+ backed_from_ms INTEGER,
48+ store_key TEXT,
49+ upload_key TEXT,
50+ upload_id TEXT,
51+ upload_kind TEXT,
52+ upload_entry TEXT,
53+ prerequisites TEXT,
54+ refs_version INTEGER,
55+ backed_up_ms INTEGER,
56+ tips TEXT,
57+ last_entry TEXT
58+);
59+CREATE INDEX repo_backups_queue ON repo_backups (status, queued_ms);
60+CREATE UNIQUE INDEX repo_backups_job ON repo_backups (job_id) WHERE job_id IS NOT NULL;
61+
62+-- A backup's clone is metered as `internal.git.backup_fetch`: an operation
63+-- on g1t's own bill, never on a workspace's.
64+INSERT INTO operation_mapping (meter, cost_operations, billable_operations, note, updated_at) VALUES
65+ ('internal.git.backup_fetch', 1, 0, 'Nightly backup clone (R11): g1t''s cost, not the workspace''s', '2026-10-06T00:00:00Z');
+992−0
1+//! Nightly backups of every repository, outside the git store
2+//! (docs/ARTIFACTS.md, R11; the flow is in `g1t_contracts::backups`).
3+//!
4+//! Each repository whose refs moved since its last backup gets a
5+//! `git bundle`: a full one first, then incremental ones whose
6+//! prerequisites are the commits the one before ended at, and a full one
7+//! again after [`Settings::full_every`] incremental ones, so a restore
8+//! never reads a long chain. Bundles and a manifest that lists the chain
9+//! are kept in object storage through the `BlobStore` port: the BACKUPS R2
10+//! bucket hosted, any S3-compatible store (MinIO in the compose file)
11+//! self-hosted, as BACKUP_STORE says.
12+//!
13+//! ```text
14+//! backups/<repo id>/manifest.json
15+//! backups/<repo id>/<20261006T025300Z>-full.bundle
16+//! backups/<repo id>/<20261007T025300Z>-incr.bundle
17+//! ```
18+//!
19+//! Restoring is fetching each bundle of `chain` in order into an empty
20+//! repository, then setting every ref to what the last entry says
21+//! (scripts/ops/backup-restore-drill.mjs does it and compares).
22+//!
23+//! `repo_backups` keeps, per repository, the last backup (the refs version
24+//! it was cut at, its tips, when) and the job in hand, if any:
25+//! `idle` → `queued` (the nightly cron) → `running` (claimed by the
26+//! runner's sweep) → `idle` again, done or failed. A job that has been
27+//! running longer than [`LEASE_MS`] is queued again; one that failed
28+//! [`MAX_ATTEMPTS`] times waits for the next night.
29+
30+use std::collections::{BTreeMap, BTreeSet};
31+
32+use g1t_blobstore::{BlobStore, Config, Part, Store};
33+use g1t_contracts::backups::{
34+ BackupClaim, BackupComplete, BackupFail, BackupJobArgs, BackupKind, BackupPart, BackupSpec, ClaimBackupsArgs, PART_BYTES,
35+};
36+use g1t_contracts::repos::RepoPath;
37+use g1t_contracts::time::rfc3339;
38+use g1t_contracts::{FailureCode, Outcome, new_id};
39+use serde::{Deserialize, Serialize};
40+use worker::wasm_bindgen::JsValue;
41+use worker::{D1Database, Env, Result};
42+
43+use crate::PULLS_NAMESPACE;
44+use crate::meters;
45+use crate::registry::{Registry, store_key};
46+use crate::store::{GitStore, Scope};
47+
48+/// Where backups are kept: the BACKUPS bucket, or, when BACKUP_STORE is
49+/// `s3`, the bucket BACKUP_S3_BUCKET names on the installation's S3 store.
50+pub const STORAGE: Config = Config {
51+ kind: "BACKUP_STORE",
52+ binding: "BACKUPS",
53+ r2_signer: None,
54+ s3_bucket: "BACKUP_S3_BUCKET",
55+ s3_public_endpoint: None,
56+};
57+
58+/// The meter a backup's clone is counted under: an operation for g1t's own
59+/// bill, never for the workspace's (migrations/0013).
60+pub const FETCH_METER: &str = "internal.git.backup_fetch";
61+
62+/// How long a claimed job may run before it is given to another sandbox.
63+pub const LEASE_MS: u64 = 3 * 60 * 60 * 1000;
64+/// How many times a night a backup is tried.
65+pub const MAX_ATTEMPTS: u32 = 3;
66+/// How many backups of deleted repositories one night removes.
67+const PRUNES_PER_NIGHT: u32 = 50;
68+
69+/// How backups are paced, from the service's variables.
70+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71+pub struct Settings {
72+ /// BACKUPS_PER_NIGHT: how many repositories one night queues.
73+ pub per_night: u32,
74+ /// BACKUP_FULL_EVERY: incremental bundles before the next full one.
75+ pub full_every: u32,
76+}
77+
78+impl Default for Settings {
79+ fn default() -> Self {
80+ Settings { per_night: 200, full_every: 30 }
81+ }
82+}
83+
84+impl Settings {
85+ pub fn from_env(env: &Env) -> Settings {
86+ let number = |name: &str| env.var(name).ok().and_then(|value| value.to_string().parse::<u32>().ok());
87+ let defaults = Settings::default();
88+ Settings {
89+ per_night: number("BACKUPS_PER_NIGHT").unwrap_or(defaults.per_night),
90+ full_every: number("BACKUP_FULL_EVERY").unwrap_or(defaults.full_every).max(1),
91+ }
92+ }
93+}
94+
95+/// The storage backups go to, or None when this installation has none
96+/// (no BACKUPS binding and BACKUP_STORE is not `s3`): backups are then off.
97+pub fn storage(env: &Env) -> Option<Store> {
98+ match Store::from_env(env, &STORAGE) {
99+ Ok(store) => Some(store),
100+ Err(error) => {
101+ worker::console_log!("repos: backups are off: {error}");
102+ None
103+ }
104+ }
105+}
106+
107+// ---------------------------------------------------------------------
108+// Which repositories are due
109+// ---------------------------------------------------------------------
110+
111+/// A repository and its last backup, as the nightly query reads them.
112+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
113+pub struct Candidate {
114+ pub repo_id: String,
115+ pub namespace: String,
116+ pub created_at: String,
117+ pub refs_version: u64,
118+ /// Until when a credential that can push was out of g1t's hands
119+ /// (`git_access`); a push with it does not move `refs_version`.
120+ pub refs_open_until: u64,
121+ pub deleted: bool,
122+ pub retired: bool,
123+ /// None: never backed up, and no row.
124+ pub status: Option<String>,
125+ pub backed_version: Option<u64>,
126+ pub backed_up_ms: u64,
127+}
128+
129+/// Whether a repository needs a backup tonight: it is live, it is not a
130+/// pull request's working copy (whose work lands in its repository, and
131+/// whose head is kept there once it goes), nothing is already queued or
132+/// running for it, and its refs moved since the last backup: its
133+/// `refs_version` went past the one backed up, or a credential that could
134+/// push was handed out after the last backup started.
135+pub fn is_due(c: &Candidate) -> bool {
136+ if c.deleted || c.retired || c.namespace == PULLS_NAMESPACE {
137+ return false;
138+ }
139+ match c.status.as_deref() {
140+ None => true,
141+ Some("idle") => match c.backed_version {
142+ None => true,
143+ Some(version) => version < c.refs_version || c.refs_open_until > c.backed_up_ms,
144+ },
145+ _ => false,
146+ }
147+}
148+
149+/// The repositories to queue: those due, the longest since their last
150+/// backup first (never backed up first of all, oldest repository first),
151+/// `limit` at most.
152+pub fn pick_due(candidates: &[Candidate], limit: usize) -> Vec<String> {
153+ let mut due: Vec<&Candidate> = candidates.iter().filter(|c| is_due(c)).collect();
154+ due.sort_by(|a, b| {
155+ a.backed_up_ms
156+ .cmp(&b.backed_up_ms)
157+ .then_with(|| a.created_at.cmp(&b.created_at))
158+ .then_with(|| a.repo_id.cmp(&b.repo_id))
159+ });
160+ due.into_iter().take(limit).map(|c| c.repo_id.clone()).collect()
161+}
162+
163+/// The same choice in SQL, so a night reads only what it queues. `?1`:
164+/// the working copies' namespace, `?2`: how many.
165+const DUE_SQL: &str = "
166+SELECT r.id AS repo_id, r.namespace, r.created_at,
167+ coalesce(r.refs_version, 0) AS refs_version,
168+ coalesce(r.refs_open_until, 0) AS refs_open_until,
169+ (r.deleted_at IS NOT NULL) AS deleted,
170+ (r.retired_at IS NOT NULL) AS retired,
171+ b.status, b.refs_version AS backed_version,
172+ coalesce(b.backed_up_ms, 0) AS backed_up_ms
173+FROM repos r LEFT JOIN repo_backups b ON b.repo_id = r.id
174+WHERE r.deleted_at IS NULL AND r.retired_at IS NULL AND r.namespace != ?1
175+ AND (b.repo_id IS NULL
176+ OR (b.status = 'idle'
177+ AND (b.refs_version IS NULL
178+ OR b.refs_version < coalesce(r.refs_version, 0)
179+ OR coalesce(r.refs_open_until, 0) > coalesce(b.backed_up_ms, 0))))
180+ORDER BY coalesce(b.backed_up_ms, 0), r.created_at, r.id
181+LIMIT ?2";
182+
183+// ---------------------------------------------------------------------
184+// The chain and its manifest
185+// ---------------------------------------------------------------------
186+
187+/// One backup in a chain: a bundle, or, when nothing new was there to
188+/// bundle (a branch deleted, a ref pointed at a commit already kept), only
189+/// the refs it ended with.
190+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191+pub struct Entry {
192+ /// When it was cut, `20261006T025300Z`: also its name in the chain.
193+ pub id: String,
194+ pub kind: BackupKind,
195+ /// The bundle's key in storage; None when only the refs moved.
196+ pub key: Option<String>,
197+ pub created_at: String,
198+ /// The repository's `refs_version` when the clone began.
199+ pub refs_version: u64,
200+ /// Every ref, by name, once this backup is applied.
201+ pub refs: BTreeMap<String, String>,
202+ /// The commits the bundle leaves out: the previous entry's tips.
203+ pub prerequisites: Vec<String>,
204+ pub size: u64,
205+ pub sha256: Option<String>,
206+}
207+
208+/// What restoring a repository reads first: its chain, oldest first, and
209+/// the chain before it, kept until the next full backup replaces it.
210+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
211+pub struct Manifest {
212+ pub version: u32,
213+ pub repo_id: String,
214+ /// The repository's name in the git store, and its path, when last cut.
215+ pub store_key: String,
216+ pub path: Option<RepoPath>,
217+ pub updated_at: String,
218+ pub chain: Vec<Entry>,
219+ #[serde(default)]
220+ pub previous: Vec<Entry>,
221+}
222+
223+pub const MANIFEST_VERSION: u32 = 1;
224+
225+impl Manifest {
226+ pub fn new(repo_id: &str, store_key: &str) -> Manifest {
227+ Manifest {
228+ version: MANIFEST_VERSION,
229+ repo_id: repo_id.to_owned(),
230+ store_key: store_key.to_owned(),
231+ path: None,
232+ updated_at: String::new(),
233+ chain: Vec::new(),
234+ previous: Vec::new(),
235+ }
236+ }
237+
238+ pub fn last(&self) -> Option<&Entry> {
239+ self.chain.last()
240+ }
241+
242+ /// Adds `entry` to the chain. A full one starts a new chain, and the
243+ /// current one becomes `previous`. Returns the bundles no longer kept.
244+ pub fn add(&mut self, entry: Entry) -> Vec<String> {
245+ if entry.kind == BackupKind::Full {
246+ let dropped = std::mem::take(&mut self.previous);
247+ self.previous = std::mem::replace(&mut self.chain, vec![entry]);
248+ return dropped.into_iter().filter_map(|entry| entry.key).collect();
249+ }
250+ self.chain.push(entry);
251+ Vec::new()
252+ }
253+
254+ /// Every bundle it lists.
255+ pub fn keys(&self) -> Vec<String> {
256+ self.chain.iter().chain(&self.previous).filter_map(|entry| entry.key.clone()).collect()
257+ }
258+
259+ pub fn to_bytes(&self) -> Vec<u8> {
260+ serde_json::to_vec_pretty(self).unwrap_or_default()
261+ }
262+
263+ pub fn from_bytes(bytes: &[u8]) -> Option<Manifest> {
264+ serde_json::from_slice::<Manifest>(bytes).ok().filter(|manifest| manifest.version == MANIFEST_VERSION)
265+ }
266+}
267+
268+/// What the next backup of a repository cuts.
269+#[derive(Clone, Debug, PartialEq, Eq)]
270+pub struct Plan {
271+ pub kind: BackupKind,
272+ pub prerequisites: Vec<String>,
273+ pub previous_refs: BTreeMap<String, String>,
274+}
275+
276+/// The next backup, from the manifest (what a restore reads) and the
277+/// entry the repository's row says was last (`last_entry`). Full when
278+/// there is no chain, the two disagree, the last entry had no refs, or the
279+/// chain already holds `full_every` incremental backups. Otherwise
280+/// incremental, leaving out every commit the last entry's refs reach.
281+pub fn plan(manifest: Option<&Manifest>, last_entry: Option<&str>, full_every: u32) -> Plan {
282+ let full = Plan { kind: BackupKind::Full, prerequisites: Vec::new(), previous_refs: BTreeMap::new() };
283+ let Some(manifest) = manifest else { return full };
284+ let Some(last) = manifest.last() else { return full };
285+ let previous_refs = last.refs.clone();
286+ if last_entry != Some(last.id.as_str()) || last.refs.is_empty() {
287+ return full;
288+ }
289+ let incrementals = manifest.chain.len().saturating_sub(1);
290+ if incrementals >= full_every as usize {
291+ return Plan { previous_refs, ..full };
292+ }
293+ let prerequisites: BTreeSet<&String> = last.refs.values().collect();
294+ Plan {
295+ kind: BackupKind::Incremental,
296+ prerequisites: prerequisites.into_iter().cloned().collect(),
297+ previous_refs,
298+ }
299+}
300+
301+/// `20261006T025300Z`, from milliseconds since the epoch.
302+pub fn stamp(now_ms: u64) -> String {
303+ let text = rfc3339(now_ms);
304+ let whole = text.split('.').next().unwrap_or(&text).trim_end_matches('Z');
305+ format!("{}Z", whole.replace(['-', ':'], ""))
306+}
307+
308+pub fn manifest_key(repo_id: &str) -> String {
309+ format!("backups/{repo_id}/manifest.json")
310+}
311+
312+pub fn bundle_key(repo_id: &str, id: &str, kind: BackupKind) -> String {
313+ format!("backups/{repo_id}/{id}-{}.bundle", kind.suffix())
314+}
315+
316+fn is_hash(text: &str) -> bool {
317+ text.len() == 40 && text.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
318+}
319+
320+/// Whether what a sandbox says a bundle holds can be: ref names git would
321+/// make (`HEAD` or under `refs/`) pointing at full commit hashes.
322+pub fn valid_refs(refs: &BTreeMap<String, String>) -> bool {
323+ refs.iter().all(|(name, hash)| {
324+ let named = name == "HEAD"
325+ || (name.starts_with("refs/")
326+ && name.len() <= 1024
327+ && !name.contains("..")
328+ && !name.ends_with('/')
329+ && !name.bytes().any(|b| b <= b' ' || b"~^:?*[\\".contains(&b)));
330+ named && is_hash(hash)
331+ })
332+}
333+
334+/// Whether the parts a sandbox says it sent are the ones a bundle of
335+/// `size` bytes makes, numbered from 1 with none missing.
336+pub fn parts_fit(parts: &[BackupPart], size: u64, part_bytes: u64) -> bool {
337+ let wanted = size.div_ceil(part_bytes.max(1));
338+ parts.len() as u64 == wanted && parts.iter().enumerate().all(|(index, part)| part.number as usize == index + 1)
339+}
340+
341+// ---------------------------------------------------------------------
342+// The record in D1, and the job in hand
343+// ---------------------------------------------------------------------
344+
345+/// A repository's row in `repo_backups`, as a job reads it.
346+#[derive(Clone, Debug, Default, Deserialize)]
347+struct JobRow {
348+ repo_id: String,
349+ token_hash: Option<String>,
350+ attempts: Option<f64>,
351+ target_version: Option<f64>,
352+ store_key: Option<String>,
353+ upload_key: Option<String>,
354+ upload_id: Option<String>,
355+ upload_kind: Option<String>,
356+ upload_entry: Option<String>,
357+ prerequisites: Option<String>,
358+ last_entry: Option<String>,
359+}
360+
361+fn refused<T>(message: &str) -> Outcome<T> {
362+ Outcome::fail(FailureCode::NotFound, message)
363+}
364+
365+fn n(value: u64) -> JsValue {
366+ JsValue::from_f64(value as f64)
367+}
368+
369+fn text(value: Option<&str>) -> JsValue {
370+ value.map_or(JsValue::NULL, JsValue::from)
371+}
372+
373+/// What a nightly run did.
374+#[derive(Debug, Default)]
375+pub struct Night {
376+ pub queued: u32,
377+ pub pruned: u32,
378+}
379+
380+/// Queues tonight's backups: the repositories due, `settings.per_night`
381+/// at most. Also removes the backups of repositories that were purged.
382+pub async fn nightly(db: &D1Database, blobs: &Store, settings: Settings, now: u64) -> Result<Night> {
383+ let candidates = db
384+ .prepare(DUE_SQL)
385+ .bind(&[PULLS_NAMESPACE.into(), n(settings.per_night as u64)])?
386+ .all()
387+ .await?
388+ .results::<CandidateRow>()?
389+ .into_iter()
390+ .map(Candidate::from)
391+ .collect::<Vec<_>>();
392+ let due = pick_due(&candidates, settings.per_night as usize);
393+ let mut statements = Vec::new();
394+ for repo_id in &due {
395+ statements.push(
396+ db.prepare(
397+ "INSERT INTO repo_backups (repo_id, status, queued_ms, attempts) VALUES (?1, 'queued', ?2, 0)
398+ ON CONFLICT (repo_id) DO UPDATE SET status = 'queued', queued_ms = ?2, attempts = 0, last_error = NULL
399+ WHERE repo_backups.status = 'idle'",
400+ )
401+ .bind(&[repo_id.as_str().into(), n(now)])?,
402+ );
403+ }
404+ if !statements.is_empty() {
405+ db.batch(statements).await?;
406+ }
407+ let pruned = prune(db, blobs).await?;
408+ Ok(Night { queued: due.len() as u32, pruned })
409+}
410+
411+/// The row the due query reads; D1 gives numbers as floats.
412+#[derive(Deserialize)]
413+struct CandidateRow {
414+ repo_id: String,
415+ namespace: String,
416+ created_at: Option<serde_json::Value>,
417+ refs_version: Option<f64>,
418+ refs_open_until: Option<f64>,
419+ deleted: Option<f64>,
420+ retired: Option<f64>,
421+ status: Option<String>,
422+ backed_version: Option<f64>,
423+ backed_up_ms: Option<f64>,
424+}
425+
426+impl From<CandidateRow> for Candidate {
427+ fn from(row: CandidateRow) -> Candidate {
428+ Candidate {
429+ repo_id: row.repo_id,
430+ namespace: row.namespace,
431+ created_at: match row.created_at {
432+ Some(serde_json::Value::String(text)) => text,
433+ Some(other) => other.to_string(),
434+ None => String::new(),
435+ },
436+ refs_version: row.refs_version.unwrap_or(0.0) as u64,
437+ refs_open_until: row.refs_open_until.unwrap_or(0.0) as u64,
438+ deleted: row.deleted.unwrap_or(0.0) != 0.0,
439+ retired: row.retired.unwrap_or(0.0) != 0.0,
440+ status: row.status,
441+ backed_version: row.backed_version.map(|v| v as u64),
442+ backed_up_ms: row.backed_up_ms.unwrap_or(0.0) as u64,
443+ }
444+ }
445+}
446+
447+/// Removes the backups of repositories that no longer exist (purged, so
448+/// their data is gone for good): every bundle the manifest lists, the
449+/// manifest, and the row.
450+async fn prune(db: &D1Database, blobs: &Store) -> Result<u32> {
451+ #[derive(Deserialize)]
452+ struct Gone {
453+ repo_id: String,
454+ }
455+ let gone = db
456+ .prepare(
457+ "SELECT b.repo_id FROM repo_backups b LEFT JOIN repos r ON r.id = b.repo_id
458+ WHERE r.id IS NULL LIMIT ?1",
459+ )
460+ .bind(&[n(PRUNES_PER_NIGHT as u64)])?
461+ .all()
462+ .await?
463+ .results::<Gone>()?;
464+ for row in &gone {
465+ let key = manifest_key(&row.repo_id);
466+ if let Some(manifest) = blobs.read(&key).await?.as_deref().and_then(Manifest::from_bytes) {
467+ for bundle in manifest.keys() {
468+ blobs.delete(&bundle).await?;
469+ }
470+ }
471+ blobs.delete(&key).await?;
472+ db.prepare("DELETE FROM repo_backups WHERE repo_id = ?1")
473+ .bind(&[row.repo_id.as_str().into()])?
474+ .run()
475+ .await?;
476+ }
477+ Ok(gone.len() as u32)
478+}
479+
480+/// For the runner's sweep: up to `limit` queued backups, each marked
481+/// running with a token of its own, so long as no more than `max_running`
482+/// are then running. Jobs past their lease go back in the queue first.
483+pub async fn claim(db: &D1Database, blobs: Option<&Store>, a: &ClaimBackupsArgs, now: u64) -> Result<Vec<BackupClaim>> {
484+ // Past their lease: the sandbox died without saying so.
485+ let stale = db
486+ .prepare("SELECT * FROM repo_backups WHERE status = 'running' AND claimed_ms < ?1")
487+ .bind(&[n(now.saturating_sub(LEASE_MS))])?
488+ .all()
489+ .await?
490+ .results::<JobRow>()?;
491+ for row in &stale {
492+ give_up_upload(blobs, row).await;
493+ settle_failure(db, row, "The backup ran past its time and was started again.").await?;
494+ }
495+ if blobs.is_none() {
496+ return Ok(Vec::new());
497+ }
498+
499+ #[derive(Deserialize)]
500+ struct Count {
501+ running: f64,
502+ }
503+ let running = db
504+ .prepare("SELECT count(*) AS running FROM repo_backups WHERE status = 'running'")
505+ .first::<Count>(None)
506+ .await?
507+ .map_or(0, |row| row.running as u32);
508+ let room = a.max_running.saturating_sub(running).min(a.limit);
509+ if room == 0 {
510+ return Ok(Vec::new());
511+ }
512+
513+ #[derive(Deserialize)]
514+ struct Queued {
515+ repo_id: String,
516+ namespace: Option<String>,
517+ name: Option<String>,
518+ deleted: Option<f64>,
519+ }
520+ let queued = db
521+ .prepare(
522+ "SELECT b.repo_id, r.namespace, r.name, (r.id IS NULL OR r.deleted_at IS NOT NULL) AS deleted
523+ FROM repo_backups b LEFT JOIN repos r ON r.id = b.repo_id
524+ WHERE b.status = 'queued' ORDER BY b.queued_ms, b.repo_id LIMIT ?1",
525+ )
526+ .bind(&[n(room as u64)])?
527+ .all()
528+ .await?
529+ .results::<Queued>()?;
530+ let mut claims = Vec::new();
531+ for row in queued {
532+ let (Some(namespace), Some(name)) = (row.namespace, row.name) else { continue };
533+ if row.deleted.unwrap_or(0.0) != 0.0 {
534+ // Deleted since it was queued: nothing to back up until it is
535+ // restored, if it is.
536+ db.prepare("UPDATE repo_backups SET status = 'idle' WHERE repo_id = ?1 AND status = 'queued'")
537+ .bind(&[row.repo_id.as_str().into()])?
538+ .run()
539+ .await?;
540+ continue;
541+ }
542+ let job_id = new_id("bkp", now);
543+ let token = g1t_secrets::random_hex(32);
544+ let claimed = db
545+ .prepare(
546+ "UPDATE repo_backups SET status = 'running', job_id = ?2, token_hash = ?3, claimed_ms = ?4,
547+ attempts = attempts + 1, target_version = NULL, store_key = NULL, upload_key = NULL,
548+ upload_id = NULL, upload_kind = NULL, upload_entry = NULL, prerequisites = NULL
549+ WHERE repo_id = ?1 AND status = 'queued' RETURNING repo_id",
550+ )
551+ .bind(&[
552+ row.repo_id.as_str().into(),
553+ job_id.as_str().into(),
554+ g1t_secrets::sha256_hex(&token).into(),
555+ n(now),
556+ ])?
557+ .first::<serde_json::Value>(None)
558+ .await?;
559+ if claimed.is_some() {
560+ claims.push(BackupClaim { job_id, token, repo_id: row.repo_id, path: RepoPath { namespace, name } });
561+ }
562+ }
563+ Ok(claims)
564+}
565+
566+/// The running job `a` names, if its token is the one it was given.
567+async fn job(db: &D1Database, a: &BackupJobArgs) -> Result<Option<JobRow>> {
568+ let row = db
569+ .prepare("SELECT * FROM repo_backups WHERE job_id = ?1 AND status = 'running'")
570+ .bind(&[a.job_id.as_str().into()])?
571+ .first::<JobRow>(None)
572+ .await?;
573+ Ok(row.filter(|row| {
574+ row.token_hash
575+ .as_deref()
576+ .is_some_and(|hash| g1t_secrets::same(hash, &g1t_secrets::sha256_hex(&a.token)))
577+ }))
578+}
579+
580+const NO_JOB: &str = "No such backup job, or it is not running.";
581+
582+/// The job, for its sandbox: what to cut, and a read-only credential for
583+/// the repository that lasts minutes. Asked again, the same bundle with a
584+/// new credential.
585+pub async fn spec<S: GitStore>(
586+ registry: &Registry,
587+ blobs: &Store,
588+ store: &S,
589+ a: &BackupJobArgs,
590+ full_every: u32,
591+ now: u64,
592+) -> Result<Outcome<BackupSpec>> {
593+ let db = &registry.db;
594+ let Some(row) = job(db, a).await? else { return Ok(refused(NO_JOB)) };
595+ #[derive(Deserialize)]
596+ struct Live {
597+ refs_version: Option<f64>,
598+ }
599+ let Some(repo) = registry.by_id(&row.repo_id).await? else {
600+ settle_failure(db, &row, "The repository was deleted.").await?;
601+ return Ok(refused("The repository was deleted."));
602+ };
603+ let key = store_key(&repo);
604+ let access = store.handout(&key, Scope::Read).await?;
605+ // Asked before: the same bundle, so parts already sent still fit.
606+ if let (Some(kind), Some(_)) = (row.upload_kind.as_deref(), row.upload_id.as_deref()) {
607+ let manifest = blobs.read(&manifest_key(&row.repo_id)).await?.as_deref().and_then(Manifest::from_bytes);
608+ let prerequisites: Vec<String> = row.prerequisites.as_deref().and_then(|p| serde_json::from_str(p).ok()).unwrap_or_default();
609+ let previous_refs = manifest.as_ref().and_then(|m| m.last()).map(|e| e.refs.clone()).unwrap_or_default();
610+ return Ok(Outcome::Ok(BackupSpec {
611+ kind: if kind == "full" { BackupKind::Full } else { BackupKind::Incremental },
612+ remote: access.remote,
613+ git_token: access.token,
614+ prerequisites,
615+ previous_refs,
616+ part_bytes: PART_BYTES,
617+ }));
618+ }
619+ let version = db
620+ .prepare("SELECT refs_version FROM repos WHERE id = ?1")
621+ .bind(&[row.repo_id.as_str().into()])?
622+ .first::<Live>(None)
623+ .await?
624+ .and_then(|live| live.refs_version)
625+ .unwrap_or(0.0) as u64;
626+ let manifest = blobs.read(&manifest_key(&row.repo_id)).await?.as_deref().and_then(Manifest::from_bytes);
627+ let next = plan(manifest.as_ref(), row.last_entry.as_deref(), full_every);
628+ let entry = stamp(now);
629+ let upload_key = bundle_key(&row.repo_id, &entry, next.kind);
630+ let upload_id = blobs.create_multipart(&upload_key).await?;
631+ db.prepare(
632+ "UPDATE repo_backups SET target_version = ?2, store_key = ?3, upload_key = ?4, upload_id = ?5,
633+ upload_kind = ?6, upload_entry = ?7, prerequisites = ?8, backed_from_ms = ?9
634+ WHERE job_id = ?1",
635+ )
636+ .bind(&[
637+ a.job_id.as_str().into(),
638+ n(version),
639+ key.as_str().into(),
640+ upload_key.as_str().into(),
641+ upload_id.as_str().into(),
642+ next.kind.suffix().into(),
643+ entry.as_str().into(),
644+ serde_json::to_string(&next.prerequisites)?.into(),
645+ n(now),
646+ ])?
647+ .run()
648+ .await?;
649+ Ok(Outcome::Ok(BackupSpec {
650+ kind: next.kind,
651+ remote: access.remote,
652+ git_token: access.token,
653+ prerequisites: next.prerequisites,
654+ previous_refs: next.previous_refs,
655+ part_bytes: PART_BYTES,
656+ }))
657+}
658+
659+/// One part of the job's bundle, kept.
660+pub async fn part(db: &D1Database, blobs: &Store, a: &BackupJobArgs, number: u16, bytes: Vec<u8>) -> Result<Outcome<BackupPart>> {
661+ let Some(row) = job(db, a).await? else { return Ok(refused(NO_JOB)) };
662+ let (Some(key), Some(upload)) = (row.upload_key.as_deref(), row.upload_id.as_deref()) else {
663+ return Ok(Outcome::fail(FailureCode::Conflict, "Ask for the job's spec first."));
664+ };
665+ if number == 0 || bytes.len() as u64 > PART_BYTES {
666+ return Ok(Outcome::fail(FailureCode::Invalid, "Parts are numbered from 1, and hold 32 MiB at most."));
667+ }
668+ let Part { number, etag } = blobs.upload_part(key, upload, number, bytes).await?;
669+ Ok(Outcome::Ok(BackupPart { number, etag }))
670+}
671+
672+/// The bundle is cut and sent: the upload is completed, the chain gains
673+/// an entry (unless nothing changed), bundles of the chain before last
674+/// are removed, and the repository is backed up as of the refs version it
675+/// had when the clone began.
676+pub async fn complete(registry: &Registry, blobs: &Store, a: &BackupComplete, now: u64) -> Result<Outcome<bool>> {
677+ let db = &registry.db;
678+ let args = BackupJobArgs { job_id: a.job_id.clone(), token: a.token.clone() };
679+ let Some(row) = job(db, &args).await? else { return Ok(refused(NO_JOB)) };
680+ let (Some(upload_key), Some(upload_id), Some(entry_id), Some(store_key)) =
681+ (row.upload_key.clone(), row.upload_id.clone(), row.upload_entry.clone(), row.store_key.clone())
682+ else {
683+ return Ok(Outcome::fail(FailureCode::Conflict, "Ask for the job's spec first."));
684+ };
685+ if !valid_refs(&a.refs) {
686+ return Ok(Outcome::fail(FailureCode::Invalid, "A ref name or commit is not one git makes."));
687+ }
688+ if !parts_fit(&a.parts, a.size, PART_BYTES) {
689+ return Ok(Outcome::fail(FailureCode::Invalid, "The parts do not add up to the bundle's size."));
690+ }
691+ meter_fetch(&store_key, a.fetched_bytes);
692+ let kind = if row.upload_kind.as_deref() == Some("full") { BackupKind::Full } else { BackupKind::Incremental };
693+ let mut manifest = blobs
694+ .read(&manifest_key(&row.repo_id))
695+ .await?
696+ .as_deref()
697+ .and_then(Manifest::from_bytes)
698+ .unwrap_or_else(|| Manifest::new(&row.repo_id, &store_key));
699+ let unchanged = a.size == 0 && kind == BackupKind::Incremental && manifest.last().is_some_and(|last| last.refs == a.refs);
700+ let version = row.target_version.unwrap_or(0.0) as u64;
701+ let mut last_entry = row.last_entry.clone();
702+ if a.size == 0 {
703+ blobs.abort_multipart(&upload_key, &upload_id).await?;
704+ } else {
705+ let parts: Vec<Part> = a.parts.iter().map(|p| Part { number: p.number, etag: p.etag.clone() }).collect();
706+ blobs.complete_multipart(&upload_key, &upload_id, &parts).await?;
707+ }
708+ if !unchanged {
709+ let prerequisites: Vec<String> = row.prerequisites.as_deref().and_then(|p| serde_json::from_str(p).ok()).unwrap_or_default();
710+ let dropped = manifest.add(Entry {
711+ id: entry_id.clone(),
712+ kind,
713+ key: (a.size > 0).then(|| upload_key.clone()),
714+ created_at: rfc3339(now),
715+ refs_version: version,
716+ refs: a.refs.clone(),
717+ prerequisites,
718+ size: a.size,
719+ sha256: a.sha256.clone(),
720+ });
721+ manifest.store_key = store_key.clone();
722+ manifest.updated_at = rfc3339(now);
723+ manifest.path = registry
724+ .by_id(&row.repo_id)
725+ .await?
726+ .map(|repo| RepoPath { namespace: repo.namespace, name: repo.name });
727+ blobs.put(&manifest_key(&row.repo_id), manifest.to_bytes()).await?;
728+ for key in dropped {
729+ if let Err(error) = blobs.delete(&key).await {
730+ worker::console_error!("repos: backup {key} not removed: {error}");
731+ }
732+ }
733+ last_entry = Some(entry_id);
734+ }
735+ db.prepare(
736+ "UPDATE repo_backups SET status = 'idle', job_id = NULL, token_hash = NULL, claimed_ms = NULL,
737+ attempts = 0, last_error = NULL, refs_version = ?2, backed_up_ms = backed_from_ms,
738+ tips = ?3, last_entry = ?4, upload_key = NULL, upload_id = NULL, upload_kind = NULL,
739+ upload_entry = NULL, prerequisites = NULL, target_version = NULL
740+ WHERE job_id = ?1",
741+ )
742+ .bind(&[a.job_id.as_str().into(), n(version), serde_json::to_string(&a.refs)?.into(), text(last_entry.as_deref())])?
743+ .run()
744+ .await?;
745+ Ok(Outcome::Ok(true))
746+}
747+
748+/// The sandbox could not do the job: tried again later tonight, up to
749+/// [`MAX_ATTEMPTS`] times, and tomorrow night after that.
750+pub async fn fail(db: &D1Database, blobs: &Store, a: &BackupFail) -> Result<Outcome<bool>> {
751+ let args = BackupJobArgs { job_id: a.job_id.clone(), token: a.token.clone() };
752+ let Some(row) = job(db, &args).await? else { return Ok(refused(NO_JOB)) };
753+ if let Some(key) = row.store_key.as_deref() {
754+ meter_fetch(key, a.fetched_bytes);
755+ }
756+ give_up_upload(Some(blobs), &row).await;
757+ let said: String = a.error.chars().take(500).collect();
758+ settle_failure(db, &row, &said).await?;
759+ Ok(Outcome::Ok(true))
760+}
761+
762+/// A clone counts once, with what it read, whenever it got as far as
763+/// reading anything.
764+fn meter_fetch(store_key: &str, fetched_bytes: u64) {
765+ if fetched_bytes > 0 {
766+ meters::record("internal.git.info_refs", store_key, 0, 0);
767+ meters::record(FETCH_METER, store_key, 0, fetched_bytes);
768+ }
769+}
770+
771+async fn give_up_upload(blobs: Option<&Store>, row: &JobRow) {
772+ if let (Some(blobs), Some(key), Some(upload)) = (blobs, row.upload_key.as_deref(), row.upload_id.as_deref())
773+ && let Err(error) = blobs.abort_multipart(key, upload).await
774+ {
775+ worker::console_error!("repos: backup upload {key} not given up: {error}");
776+ }
777+}
778+
779+/// Back in the queue for another try, or idle until tomorrow night once
780+/// it has been tried enough.
781+async fn settle_failure(db: &D1Database, row: &JobRow, error: &str) -> Result<()> {
782+ let attempts = row.attempts.unwrap_or(0.0) as u32;
783+ let next = if attempts >= MAX_ATTEMPTS { "idle" } else { "queued" };
784+ worker::console_error!("repos: backup of {} failed ({attempts} of {MAX_ATTEMPTS}): {error}", row.repo_id);
785+ db.prepare(
786+ "UPDATE repo_backups SET status = ?2, last_error = ?3, job_id = NULL, token_hash = NULL,
787+ claimed_ms = NULL, upload_key = NULL, upload_id = NULL, upload_kind = NULL,
788+ upload_entry = NULL, prerequisites = NULL, target_version = NULL
789+ WHERE repo_id = ?1",
790+ )
791+ .bind(&[row.repo_id.as_str().into(), next.into(), error.into()])?
792+ .run()
793+ .await?;
794+ Ok(())
795+}
796+
797+#[cfg(test)]
798+mod tests {
799+ use super::*;
800+
801+ const A: &str = "c71546fcd893ef8b0f57388b65e620d759705dda";
802+ const B: &str = "4807077b296e6edbf410d55e72749d3e1170c291";
803+ const C: &str = "0000000000000000000000000000000000000abc";
804+
805+ fn candidate(id: &str) -> Candidate {
806+ Candidate {
807+ repo_id: id.into(),
808+ namespace: "acme".into(),
809+ created_at: "2026-01-01T00:00:00.000Z".into(),
810+ refs_version: 3,
811+ ..Candidate::default()
812+ }
813+ }
814+
815+ fn backed(id: &str, version: u64, at: u64) -> Candidate {
816+ Candidate { status: Some("idle".into()), backed_version: Some(version), backed_up_ms: at, ..candidate(id) }
817+ }
818+
819+ #[test]
820+ fn a_repository_never_backed_up_is_due() {
821+ assert!(is_due(&candidate("r1")));
822+ // Even one whose refs never moved: rows from before refs_version.
823+ assert!(is_due(&Candidate { refs_version: 0, ..candidate("r1") }));
824+ }
825+
826+ #[test]
827+ fn a_repository_is_due_only_once_its_refs_moved() {
828+ assert!(!is_due(&backed("r1", 3, 1_000)));
829+ assert!(is_due(&backed("r1", 2, 1_000)));
830+ // A credential that could push went out after the last backup began.
831+ assert!(is_due(&Candidate { refs_open_until: 2_000, ..backed("r1", 3, 1_000) }));
832+ assert!(!is_due(&Candidate { refs_open_until: 900, ..backed("r1", 3, 1_000) }));
833+ // Tried and failed: no version yet.
834+ assert!(is_due(&Candidate { backed_version: None, ..backed("r1", 0, 0) }));
835+ }
836+
837+ #[test]
838+ fn deleted_retired_working_copies_and_jobs_in_hand_are_not_due() {
839+ assert!(!is_due(&Candidate { deleted: true, ..candidate("r1") }));
840+ assert!(!is_due(&Candidate { retired: true, ..candidate("r1") }));
841+ assert!(!is_due(&Candidate { namespace: PULLS_NAMESPACE.into(), ..candidate("r1") }));
842+ assert!(!is_due(&Candidate { status: Some("queued".into()), ..backed("r1", 1, 0) }));
843+ assert!(!is_due(&Candidate { status: Some("running".into()), ..backed("r1", 1, 0) }));
844+ }
845+
846+ #[test]
847+ fn the_longest_waiting_go_first_and_no_more_than_the_limit() {
848+ let candidates = vec![
849+ backed("recent", 1, 5_000),
850+ backed("older", 1, 1_000),
851+ backed("current", 3, 0),
852+ Candidate { created_at: "2026-02-01T00:00:00.000Z".into(), ..candidate("new-b") },
853+ Candidate { created_at: "2025-02-01T00:00:00.000Z".into(), ..candidate("new-a") },
854+ ];
855+ assert_eq!(pick_due(&candidates, 10), ["new-a", "new-b", "older", "recent"]);
856+ assert_eq!(pick_due(&candidates, 2), ["new-a", "new-b"]);
857+ assert!(pick_due(&candidates, 0).is_empty());
858+ }
859+
860+ fn refs(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
861+ pairs.iter().map(|(name, hash)| ((*name).to_owned(), (*hash).to_owned())).collect()
862+ }
863+
864+ fn entry(id: &str, kind: BackupKind, tips: &[(&str, &str)]) -> Entry {
865+ Entry {
866+ id: id.into(),
867+ kind,
868+ key: Some(format!("backups/r1/{id}-{}.bundle", kind.suffix())),
869+ created_at: String::new(),
870+ refs_version: 1,
871+ refs: refs(tips),
872+ prerequisites: Vec::new(),
873+ size: 10,
874+ sha256: None,
875+ }
876+ }
877+
878+ fn manifest(incrementals: usize) -> Manifest {
879+ let mut manifest = Manifest::new("r1", "acme--rocket");
880+ manifest.add(entry("e0", BackupKind::Full, &[("HEAD", A), ("refs/heads/main", A)]));
881+ for index in 0..incrementals {
882+ manifest.add(entry(&format!("e{}", index + 1), BackupKind::Incremental, &[("HEAD", A), ("refs/heads/main", A), ("refs/tags/v1", B)]));
883+ }
884+ manifest
885+ }
886+
887+ #[test]
888+ fn the_first_backup_is_full() {
889+ let next = plan(None, None, 30);
890+ assert_eq!(next.kind, BackupKind::Full);
891+ assert!(next.prerequisites.is_empty() && next.previous_refs.is_empty());
892+ assert_eq!(plan(Some(&Manifest::new("r1", "k")), None, 30).kind, BackupKind::Full);
893+ }
894+
895+ #[test]
896+ fn the_next_is_incremental_from_the_last_tips_each_once() {
897+ let manifest = manifest(1);
898+ let next = plan(Some(&manifest), Some("e1"), 30);
899+ assert_eq!(next.kind, BackupKind::Incremental);
900+ assert_eq!(next.prerequisites, [B, A]);
901+ assert_eq!(next.previous_refs, manifest.last().unwrap().refs);
902+ }
903+
904+ #[test]
905+ fn a_full_bundle_is_cut_again_after_enough_incrementals() {
906+ assert_eq!(plan(Some(&manifest(29)), Some("e29"), 30).kind, BackupKind::Incremental);
907+ let next = plan(Some(&manifest(30)), Some("e30"), 30);
908+ assert_eq!(next.kind, BackupKind::Full);
909+ assert!(next.prerequisites.is_empty());
910+ // What it last held is still said, so an unchanged clone is seen.
911+ assert!(!next.previous_refs.is_empty());
912+ assert_eq!(plan(Some(&manifest(1)), Some("e1"), 1).kind, BackupKind::Full);
913+ }
914+
915+ #[test]
916+ fn a_chain_the_row_does_not_know_or_an_empty_one_starts_again() {
917+ assert_eq!(plan(Some(&manifest(2)), Some("e1"), 30).kind, BackupKind::Full);
918+ assert_eq!(plan(Some(&manifest(2)), None, 30).kind, BackupKind::Full);
919+ let mut empty = Manifest::new("r1", "k");
920+ empty.add(entry("e0", BackupKind::Full, &[]));
921+ assert_eq!(plan(Some(&empty), Some("e0"), 30).kind, BackupKind::Full);
922+ }
923+
924+ #[test]
925+ fn a_full_backup_starts_a_chain_and_the_one_before_last_goes() {
926+ let mut manifest = manifest(2);
927+ assert!(manifest.previous.is_empty());
928+ let dropped = manifest.add(entry("f1", BackupKind::Full, &[("refs/heads/main", C)]));
929+ assert!(dropped.is_empty(), "the chain before is kept until the next full one");
930+ assert_eq!(manifest.chain.len(), 1);
931+ assert_eq!(manifest.previous.len(), 3);
932+ manifest.add(entry("f1a", BackupKind::Incremental, &[("refs/heads/main", C)]));
933+ let dropped = manifest.add(entry("f2", BackupKind::Full, &[("refs/heads/main", C)]));
934+ assert_eq!(dropped, ["backups/r1/e0-full.bundle", "backups/r1/e1-incr.bundle", "backups/r1/e2-incr.bundle"]);
935+ assert_eq!(manifest.previous.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["f1", "f1a"]);
936+ assert_eq!(manifest.keys().len(), 3);
937+ }
938+
939+ #[test]
940+ fn a_manifest_round_trips_and_an_unknown_version_is_not_read() {
941+ let mut manifest = manifest(2);
942+ manifest.path = Some(RepoPath { namespace: "acme".into(), name: "rocket".into() });
943+ manifest.add(Entry { key: None, size: 0, ..entry("e3", BackupKind::Incremental, &[("refs/heads/main", A)]) });
944+ let bytes = manifest.to_bytes();
945+ let text = String::from_utf8(bytes.clone()).unwrap();
946+ // What the restore drill reads: snake_case, the kind in words.
947+ assert!(text.contains("\"repo_id\": \"r1\"") && text.contains("\"kind\": \"incremental\"") && text.contains("\"key\": null"));
948+ assert_eq!(Manifest::from_bytes(&bytes), Some(manifest.clone()));
949+ let mut later = manifest;
950+ later.version = 2;
951+ assert_eq!(Manifest::from_bytes(&later.to_bytes()), None);
952+ assert_eq!(Manifest::from_bytes(b"not json"), None);
953+ }
954+
955+ #[test]
956+ fn a_backup_clone_is_g1ts_operation_not_the_workspaces() {
957+ let mapping = meters::Mapping::defaults();
958+ assert_eq!(mapping.cost(FETCH_METER), 1.0);
959+ assert_eq!(mapping.billable(FETCH_METER), 0.0);
960+ assert_eq!(mapping.billable("internal.git.fetch"), 1.0);
961+ }
962+
963+ #[test]
964+ fn keys_and_stamps() {
965+ assert_eq!(stamp(1_369_353_600_123), "20130524T000000Z");
966+ assert_eq!(manifest_key("r1"), "backups/r1/manifest.json");
967+ assert_eq!(bundle_key("r1", "20130524T000000Z", BackupKind::Incremental), "backups/r1/20130524T000000Z-incr.bundle");
968+ assert_eq!(bundle_key("r1", "20130524T000000Z", BackupKind::Full), "backups/r1/20130524T000000Z-full.bundle");
969+ }
970+
971+ #[test]
972+ fn only_refs_git_makes_are_taken() {
973+ assert!(valid_refs(&refs(&[("HEAD", A), ("refs/heads/main", A), ("refs/pull/pr_1/head", B)])));
974+ assert!(valid_refs(&BTreeMap::new()));
975+ assert!(!valid_refs(&refs(&[("main", A)])));
976+ assert!(!valid_refs(&refs(&[("refs/heads/../x", A)])));
977+ assert!(!valid_refs(&refs(&[("refs/heads/a b", A)])));
978+ assert!(!valid_refs(&refs(&[("refs/heads/main", "abc")])));
979+ assert!(!valid_refs(&refs(&[("refs/heads/main", &A.to_uppercase())])));
980+ }
981+
982+ #[test]
983+ fn parts_must_cover_the_bundle_in_order() {
984+ let part = |number| BackupPart { number, etag: "e".into() };
985+ assert!(parts_fit(&[], 0, 10));
986+ assert!(parts_fit(&[part(1)], 10, 10));
987+ assert!(parts_fit(&[part(1), part(2)], 11, 10));
988+ assert!(!parts_fit(&[part(1)], 11, 10));
989+ assert!(!parts_fit(&[part(2), part(1)], 11, 10));
990+ assert!(!parts_fit(&[part(1)], 0, 10));
991+ }
992+}
+83−2
55 //! `g1t_contracts::repos` for the methods and their arguments. Any other
66 //! request is treated as git's smart HTTP protocol.
77
8+mod backups;
89 mod blame;
910 mod catch_up;
1011 mod coalesce;
19091910 }
19101911 }
19111912
1913+/// `/backups/<job id>/parts/<number>`: the job and the part's number.
1914+fn backup_part_path(path: &str) -> Option<(String, u16)> {
1915+ let rest = path.strip_prefix("/backups/")?;
1916+ let (job, number) = rest.split_once("/parts/")?;
1917+ let number = number.parse::<u16>().ok()?;
1918+ (!job.is_empty() && !job.contains('/')).then(|| (job.to_owned(), number))
1919+}
1920+
1921+fn backups_off<T>() -> Outcome<T> {
1922+ Outcome::fail(FailureCode::Conflict, "Backups are off on this installation: it has no storage for them.")
1923+}
1924+
1925+/// One part of a backup's bundle, with the job's token in its header.
1926+async fn backup_part(request: &mut Request, env: &Env, repos: &Repos<ArtifactsStore>, job_id: String, number: u16) -> Result<Response> {
1927+ let Some(blobs) = backups::storage(env) else {
1928+ return reply(&backups_off::<()>());
1929+ };
1930+ let token = request.headers().get(g1t_contracts::backups::TOKEN_HEADER)?.unwrap_or_default();
1931+ let bytes = request.bytes().await?;
1932+ let job = g1t_contracts::backups::BackupJobArgs { job_id, token };
1933+ reply(&backups::part(&repos.registry.db, &blobs, &job, number, bytes).await?)
1934+}
1935+
1936+#[cfg(test)]
1937+mod backup_path_tests {
1938+ use super::backup_part_path;
1939+
1940+ #[test]
1941+ fn a_part_is_named_by_its_job_and_number() {
1942+ assert_eq!(backup_part_path("/backups/bkp_1/parts/3"), Some(("bkp_1".to_owned(), 3)));
1943+ assert_eq!(backup_part_path("/backups/bkp_1/parts/x"), None);
1944+ assert_eq!(backup_part_path("/backups//parts/1"), None);
1945+ assert_eq!(backup_part_path("/acme/rocket.git/info/refs"), None);
1946+ }
1947+}
1948+
19121949 /// Read methods whose answer is an `Outcome`: when the git store is busy,
19131950 /// the site is told so in words instead of failing the page.
19141951 const OUTCOME_READS: [&str; 6] = ["tree", "blob", "log", "branches", "blame", "compare"];
19161953 #[event(fetch)]
19171954 async fn fetch(mut request: Request, env: Env, ctx: Context) -> Result<Response> {
19181955 let mut repos = service(&env)?;
1956+ // A part of a backup's bundle, as the API passes it on from the
1957+ // sandbox: bytes, not JSON (backups.rs).
1958+ if request.method() == Method::Put
1959+ && let Some((job_id, number)) = backup_part_path(&request.path())
1960+ {
1961+ let answered = backup_part(&mut request, &env, &repos, job_id, number).await;
1962+ flush_later(&env, &ctx);
1963+ return answered;
1964+ }
19191965 let Some(method) = rpc_method(&request) else {
19201966 let answered = repos.git_http(request, &env, &ctx).await;
19211967 flush_later(&env, &ctx);
20472093 meters::set_mapping(&repos.registry.db, &row, &rfc3339(now_ms())).await?;
20482094 reply(&meters::read_mapping(&repos.registry.db).await?)
20492095 }
2096+ // Backups (backups.rs): the runner's sweep claims queued ones, and
2097+ // each sandbox, through the API, asks for its job and says how it went.
2098+ "claim_backups" => {
2099+ let a: g1t_contracts::backups::ClaimBackupsArgs = args(body)?;
2100+ let blobs = backups::storage(&env);
2101+ reply(&backups::claim(&repos.registry.db, blobs.as_ref(), &a, now_ms()).await?)
2102+ }
2103+ "backup_spec" => match backups::storage(&env) {
2104+ Some(blobs) => {
2105+ let a: g1t_contracts::backups::BackupJobArgs = args(body)?;
2106+ let every = backups::Settings::from_env(&env).full_every;
2107+ reply(&backups::spec(&repos.registry, &blobs, &repos.store, &a, every, now_ms()).await?)
2108+ }
2109+ None => reply(&backups_off::<bool>()),
2110+ },
2111+ "backup_complete" => match backups::storage(&env) {
2112+ Some(blobs) => reply(&backups::complete(&repos.registry, &blobs, &args(body)?, now_ms()).await?),
2113+ None => reply(&backups_off::<bool>()),
2114+ },
2115+ "backup_fail" => match backups::storage(&env) {
2116+ Some(blobs) => reply(&backups::fail(&repos.registry.db, &blobs, &args(body)?).await?),
2117+ None => reply(&backups_off::<bool>()),
2118+ },
20502119 // How the git store has been answering, for the status page.
20512120 "store_health" => {
20522121 let a: meters::HealthArgs = args(body)?;
20742143 served.finish(answered)
20752144 }
20762145
2146+/// The nightly cron in wrangler.jsonc: tonight's backups are queued.
2147+const BACKUP_CRON: &str = "53 2 * * *";
2148+
20772149 /// The hourly sweep: deleted repositories whose time to be restored has
2078−/// passed are purged. See lifecycle.rs.
2150+/// passed are purged. See lifecycle.rs. And, at [`BACKUP_CRON`], the
2151+/// repositories whose refs moved are queued for a backup (backups.rs).
20792152 #[event(scheduled)]
2080−async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
2153+async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
20812154 let repos = match service(&env) {
20822155 Ok(repos) => repos,
20832156 Err(error) => {
20852158 return;
20862159 }
20872160 };
2161+ if event.cron() == BACKUP_CRON {
2162+ let Some(blobs) = backups::storage(&env) else { return };
2163+ match backups::nightly(&repos.registry.db, &blobs, backups::Settings::from_env(&env), now_ms()).await {
2164+ Ok(night) => worker::console_log!("repos: queued {} backups, removed {} of purged repositories", night.queued, night.pruned),
2165+ Err(error) => worker::console_error!("repos: backups could not be queued: {error}"),
2166+ }
2167+ return;
2168+ }
20882169 match repos.purge_due(PurgeDueArgs::default()).await {
20892170 Ok(0) => {}
20902171 Ok(count) => worker::console_log!("repos: purged {count} deleted repositories"),
+9−0
168168 let rows = DEFAULT_OPERATIONS
169169 .iter()
170170 .map(|meter| MappingRow { meter: (*meter).to_owned(), cost_operations: 1.0, billable_operations: 1.0, ..MappingRow::default() })
171+ .chain(COST_ONLY_OPERATIONS.iter().map(|meter| MappingRow {
172+ meter: (*meter).to_owned(),
173+ cost_operations: 1.0,
174+ ..MappingRow::default()
175+ }))
171176 .collect::<Vec<_>>();
172177 Mapping::from_rows(&rows)
173178 }
194199 "binding.delete",
195200 ];
196201
202+/// Meters that are an operation on g1t's own bill and on no workspace's:
203+/// a nightly backup's clone (backups.rs, migrations/0013).
204+pub const COST_ONLY_OPERATIONS: [&str; 1] = [crate::backups::FETCH_METER];
205+
197206 /// How long a read of `operation_mapping` is used for.
198207 const MAPPING_TTL_MS: u64 = 5 * 60 * 1000;
199208
+18−2
6060 // ARTIFACTS_REMOTE_BASE (optional): where remotes start,
6161 // https://<account>.artifacts.cloudflare.net/git, so a new isolate
6262 // needs no info() call before its first credential (src/store.rs).
63+ //
64+ // Backups (src/backups.rs):
65+ // BACKUP_STORE: r2 (the BACKUPS bucket) or s3 (BACKUP_S3_BUCKET on the
66+ // installation's S3_ENDPOINT, as self-hosting uses).
67+ // BACKUPS_PER_NIGHT: how many repositories one night queues, those
68+ // longest since their last backup first.
69+ // BACKUP_FULL_EVERY: incremental bundles before a full one again.
6370 "vars": {
71+ "BACKUP_STORE": "r2",
72+ "BACKUPS_PER_NIGHT": "200",
73+ "BACKUP_FULL_EVERY": "30",
6474 "GIT_OPERATIONS_FREE_CAP": "50000",
6575 "GIT_OPERATIONS_FREE_HOURLY": "60",
6676 "FREE_PRIVATE_STORAGE_BYTES": "1000000000",
7383 // Every hour, deleted repositories whose 30 days to be restored have
7484 // passed are purged, their git data with them (src/lifecycle.rs), and
7585 // pull request working copies past FORK_RETENTION_DAYS are removed
76− // (src/forks.rs).
77− "triggers": { "crons": ["23 * * * *"] },
86+ // (src/forks.rs). At 02:53 UTC, the repositories whose refs moved since
87+ // their last backup are queued for one (src/backups.rs, BACKUP_CRON);
88+ // the runner's sweep starts them a few at a time.
89+ "triggers": { "crons": ["23 * * * *", "53 2 * * *"] },
90+ // Nightly backups: a git bundle per repository and a manifest of each
91+ // chain (src/backups.rs). Made by
92+ // `npx wrangler r2 bucket create g1t-backups`.
93+ "r2_buckets": [{ "binding": "BACKUPS", "bucket_name": "g1t-backups" }],
7894 // A workspace renamed moves its repositories to the new slug; one
7995 // deleted purges the repositories it left in Recently deleted.
8096 "queues": {