g1t/services/repos/src/git_ops.rs
| 1 | //! Git operations, counted per workspace. |
| 2 | //! |
| 3 | //! Cloudflare Artifacts charges g1t per operation from 2026-10-14 ($0.15 |
| 4 | //! per 1,000) without having said exactly which calls are operations. So |
| 5 | //! every interaction with the store is metered by kind (meters.rs), and |
| 6 | //! which meters a workspace is counted for, and how much each is worth, is |
| 7 | //! data (`operation_mapping`). By default: each clone or fetch (an |
| 8 | //! upload-pack request that fetches objects, not `ls-refs` and never an |
| 9 | //! answer g1t served from its own cache), each push (receive-pack), and |
| 10 | //! making, forking and deleting a repository. The counts go to |
| 11 | //! `git_operations` by the hour, after answers have gone back. Billing |
| 12 | //! reads the month's count each day and charges workspaces on the plan for |
| 13 | //! what is past the amount that is free for everyone (50,000 a month), at |
| 14 | //! cost plus 20%. A workspace on the plan is never slowed or refused for |
| 15 | //! git operations or for storage: it pays for them as usage, up to its |
| 16 | //! spend limit. |
| 17 | //! |
| 18 | //! A free workspace is never charged for git operations. Past |
| 19 | //! `GIT_OPERATIONS_FREE_CAP` in a month (50,000, billing's |
| 20 | //! `GIT_OPERATIONS_INCLUDED`), it is slowed down instead: at most |
| 21 | //! `GIT_OPERATIONS_FREE_HOURLY` (60) an hour, answered 429 with when to try |
| 22 | //! again. Whether it is past its cap is decided from counts this isolate |
| 23 | //! read a moment ago and has added to since, never by asking the database |
| 24 | //! on the way (63 to 98 ms a request, measured). Pushes from agents' |
| 25 | //! sandboxes go to the store directly and are counted as the store's |
| 26 | //! meters see them, not here. |
| 27 | |
| 28 | use std::cell::RefCell; |
| 29 | use std::collections::HashMap; |
| 30 | |
| 31 | use g1t_contracts::repos::{GitService, WorkspaceGitOperations}; |
| 32 | use serde::Deserialize; |
| 33 | use worker::wasm_bindgen::JsValue; |
| 34 | use worker::{D1Database, Env, Fetcher, Response, Result}; |
| 35 | |
| 36 | /// What a request to g1t's git endpoints asks the store. |
| 37 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 38 | pub enum GitCall { |
| 39 | /// `GET info/refs`: the refs, for a fetch or a push. |
| 40 | RefAdvertisement, |
| 41 | /// A protocol v2 `ls-refs`. |
| 42 | LsRefs, |
| 43 | /// An upload-pack request that fetches objects: a clone or fetch. |
| 44 | Fetch, |
| 45 | /// A push. |
| 46 | ReceivePack, |
| 47 | } |
| 48 | |
| 49 | impl GitCall { |
| 50 | /// Its meter (meters.rs). |
| 51 | pub fn meter(self) -> &'static str { |
| 52 | match self { |
| 53 | GitCall::RefAdvertisement => "git.info_refs", |
| 54 | GitCall::LsRefs => "git.ls_refs", |
| 55 | GitCall::Fetch => "git.fetch", |
| 56 | GitCall::ReceivePack => "git.receive_pack", |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// The meter for an answer g1t served from its own cache, which never |
| 61 | /// reaches the store and is never an operation. |
| 62 | pub fn cached_meter(self) -> &'static str { |
| 63 | match self { |
| 64 | GitCall::RefAdvertisement => "cache.info_refs", |
| 65 | GitCall::LsRefs => "cache.ls_refs", |
| 66 | GitCall::Fetch => "cache.fetch", |
| 67 | GitCall::ReceivePack => "cache.receive_pack", |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /// What a git request is. `body` is an upload-pack POST's, read already. |
| 73 | pub fn classify(service: GitService, endpoint: &str, get: bool, body: Option<&[u8]>) -> GitCall { |
| 74 | if get || endpoint == "info/refs" { |
| 75 | return GitCall::RefAdvertisement; |
| 76 | } |
| 77 | if service == GitService::ReceivePack { |
| 78 | return GitCall::ReceivePack; |
| 79 | } |
| 80 | let ls_refs = body.is_some_and(|body| { |
| 81 | let (lines, _) = crate::land::read_pkt_lines(body); |
| 82 | lines.first().is_some_and(|line| line.strip_suffix(b"\n").unwrap_or(line) == b"command=ls-refs") |
| 83 | }); |
| 84 | if ls_refs { GitCall::LsRefs } else { GitCall::Fetch } |
| 85 | } |
| 86 | |
| 87 | /// The hour an operation is counted in: `YYYY-MM-DDTHH` of an RFC 3339 time. |
| 88 | pub fn hour_key(timestamp: &str) -> String { |
| 89 | timestamp[..13].to_owned() |
| 90 | } |
| 91 | |
| 92 | /// Whether a free workspace's operation should wait: past the month's cap, |
| 93 | /// and past the hour's share. |
| 94 | pub fn slow_down(month_ops: u64, hour_ops: u64, free_cap: u64, hourly: u64) -> bool { |
| 95 | month_ops > free_cap && hour_ops > hourly |
| 96 | } |
| 97 | |
| 98 | /// The limits, from the repos service's variables. |
| 99 | pub struct Limits { |
| 100 | pub free_cap: u64, |
| 101 | pub hourly: u64, |
| 102 | } |
| 103 | |
| 104 | impl Limits { |
| 105 | pub fn from_env(env: &Env) -> Self { |
| 106 | let number = |name: &str, default: u64| env.var(name).ok().and_then(|v| v.to_string().parse().ok()).unwrap_or(default); |
| 107 | Limits { free_cap: number("GIT_OPERATIONS_FREE_CAP", 50_000), hourly: number("GIT_OPERATIONS_FREE_HOURLY", 60) } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | #[derive(Deserialize)] |
| 112 | struct Counts { |
| 113 | month: Option<f64>, |
| 114 | hour: Option<f64>, |
| 115 | } |
| 116 | |
| 117 | /// Where a workspace stands this month and hour, as this isolate knows it. |
| 118 | #[derive(Clone, Debug, Default, PartialEq)] |
| 119 | pub struct Standing { |
| 120 | /// `YYYY-MM-DDTHH` the counts are for. |
| 121 | hour_key: String, |
| 122 | month: f64, |
| 123 | hour: f64, |
| 124 | /// When the database was last read for it. |
| 125 | read_at: u64, |
| 126 | } |
| 127 | |
| 128 | impl Standing { |
| 129 | /// The month's and the hour's counts at `hour_key`, if read recently |
| 130 | /// enough to go by: an hour that has turned starts at nothing, a month |
| 131 | /// that has turned likewise. |
| 132 | pub fn at(&self, hour_key: &str, now: u64) -> Option<(u64, u64)> { |
| 133 | if now.saturating_sub(self.read_at) > STANDING_TTL_MS { |
| 134 | return None; |
| 135 | } |
| 136 | let month = if self.hour_key.get(..7) == hour_key.get(..7) { self.month } else { 0.0 }; |
| 137 | let hour = if self.hour_key == hour_key { self.hour } else { 0.0 }; |
| 138 | Some((month as u64, hour as u64)) |
| 139 | } |
| 140 | |
| 141 | /// Adds operations counted here, not yet written. |
| 142 | pub fn add(&mut self, hour_key: &str, operations: f64) { |
| 143 | if self.hour_key != hour_key { |
| 144 | if self.hour_key.get(..7) != hour_key.get(..7) { |
| 145 | self.month = 0.0; |
| 146 | } |
| 147 | self.hour = 0.0; |
| 148 | self.hour_key = hour_key.to_owned(); |
| 149 | } |
| 150 | self.month += operations; |
| 151 | self.hour += operations; |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /// How long counts read from the database are gone by. Every write of the |
| 156 | /// meters reads them again (meters.rs), so a busy workspace's are seconds old. |
| 157 | const STANDING_TTL_MS: u64 = 10 * 60 * 1000; |
| 158 | /// How long billing's answer about a workspace's plan is kept. |
| 159 | const PLAN_TTL_MS: u64 = 5 * 60 * 1000; |
| 160 | |
| 161 | thread_local! { |
| 162 | static STANDING: RefCell<HashMap<String, Standing>> = RefCell::new(HashMap::new()); |
| 163 | static FREE: RefCell<HashMap<String, (bool, u64)>> = RefCell::new(HashMap::new()); |
| 164 | } |
| 165 | |
| 166 | /// Adds operations this isolate counted for `namespace` (meters.rs). |
| 167 | pub fn note_local(namespace: &str, hour_key: &str, operations: f64) { |
| 168 | STANDING.with(|standing| { |
| 169 | if let Some(kept) = standing.borrow_mut().get_mut(namespace) { |
| 170 | kept.add(hour_key, operations); |
| 171 | } |
| 172 | }); |
| 173 | } |
| 174 | |
| 175 | /// The month's and the hour's counts for `namespace`, as last read and |
| 176 | /// added to here; `None` when not read lately, which never slows anyone. |
| 177 | pub fn standing(namespace: &str, hour_key: &str, now: u64) -> Option<(u64, u64)> { |
| 178 | STANDING.with(|standing| standing.borrow().get(namespace).and_then(|kept| kept.at(hour_key, now))) |
| 179 | } |
| 180 | |
| 181 | /// Reads `namespace`'s counts again, after the meters were written. |
| 182 | pub async fn refresh(db: &D1Database, namespace: &str) -> Result<()> { |
| 183 | let now = g1t_kit::now_ms(); |
| 184 | let hour = hour_key(&g1t_contracts::time::rfc3339(now)); |
| 185 | let counts = db |
| 186 | .prepare( |
| 187 | "SELECT SUM(operations) AS month, SUM(CASE WHEN hour = ?2 THEN operations END) AS hour |
| 188 | FROM git_operations WHERE namespace = ?1 AND substr(hour, 1, 7) = ?3", |
| 189 | ) |
| 190 | .bind(&[namespace.into(), hour.as_str().into(), hour[..7].into()])? |
| 191 | .first::<Counts>(None) |
| 192 | .await?; |
| 193 | let (month, hour_count) = counts.map_or((0.0, 0.0), |c| (c.month.unwrap_or(0.0), c.hour.unwrap_or(0.0))); |
| 194 | STANDING.with(|standing| { |
| 195 | standing.borrow_mut().insert(namespace.to_owned(), Standing { hour_key: hour, month, hour: hour_count, read_at: now }); |
| 196 | }); |
| 197 | Ok(()) |
| 198 | } |
| 199 | |
| 200 | /// Each workspace's operations in `month`, from `since` (an hour) on. |
| 201 | pub async fn totals(db: &D1Database, month: &str, since: Option<&str>, namespace: Option<&str>) -> Result<Vec<WorkspaceGitOperations>> { |
| 202 | #[derive(Deserialize)] |
| 203 | struct Row { |
| 204 | namespace: String, |
| 205 | operations: Option<f64>, |
| 206 | } |
| 207 | Ok(db |
| 208 | .prepare( |
| 209 | "SELECT namespace, SUM(operations) AS operations FROM git_operations |
| 210 | WHERE substr(hour, 1, 7) = ?1 AND hour >= COALESCE(?2, '') AND (?3 IS NULL OR namespace = ?3) |
| 211 | GROUP BY namespace", |
| 212 | ) |
| 213 | .bind(&[month.into(), since.map_or(JsValue::NULL, JsValue::from), namespace.map_or(JsValue::NULL, JsValue::from)])? |
| 214 | .all() |
| 215 | .await? |
| 216 | .results::<Row>()? |
| 217 | .into_iter() |
| 218 | .map(|row| WorkspaceGitOperations { namespace: row.namespace, operations: row.operations.unwrap_or(0.0) as u64 }) |
| 219 | .collect()) |
| 220 | } |
| 221 | |
| 222 | /// Whether billing says the workspace is free. Unknown (billing not bound |
| 223 | /// or not answering) counts as not free: nothing is slowed down on a guess. |
| 224 | pub async fn is_free(billing: Option<&Fetcher>, namespace: &str) -> bool { |
| 225 | let Some(billing) = billing else { return false }; |
| 226 | let args = g1t_contracts::billing::EntitlementsArgs { workspace: namespace.to_owned() }; |
| 227 | match g1t_kit::call::<_, serde_json::Value>(billing, "entitlements", &args).await { |
| 228 | Ok(found) => found["plan"].as_str() == Some("free"), |
| 229 | Err(error) => { |
| 230 | worker::console_error!("could not ask billing about {namespace}: {error}"); |
| 231 | false |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /// [`is_free`], kept for a few minutes: asked only of a workspace past its |
| 237 | /// cap, on each of its requests. |
| 238 | pub async fn is_free_kept(billing: Option<&Fetcher>, namespace: &str) -> bool { |
| 239 | let now = g1t_kit::now_ms(); |
| 240 | let kept = FREE.with(|free| { |
| 241 | free.borrow().get(namespace).filter(|(_, at)| now.saturating_sub(*at) < PLAN_TTL_MS).map(|(free, _)| *free) |
| 242 | }); |
| 243 | if let Some(free) = kept { |
| 244 | return free; |
| 245 | } |
| 246 | let free = is_free(billing, namespace).await; |
| 247 | FREE.with(|kept| kept.borrow_mut().insert(namespace.to_owned(), (free, now))); |
| 248 | free |
| 249 | } |
| 250 | |
| 251 | /// The private storage a free workspace may push to: 1 GB unless set. |
| 252 | pub fn free_private_bytes(env: &worker::Env) -> i64 { |
| 253 | env.var("FREE_PRIVATE_STORAGE_BYTES") |
| 254 | .ok() |
| 255 | .and_then(|value| value.to_string().parse().ok()) |
| 256 | .unwrap_or(1_000_000_000) |
| 257 | } |
| 258 | |
| 259 | /// Whether a push to a private repository should be refused: a free |
| 260 | /// workspace whose private repositories already hold its free amount. Free |
| 261 | /// workspaces are never charged for storage; past it, pushes stop instead. |
| 262 | /// Only ever asked for a free workspace: one on the plan pays for storage |
| 263 | /// past the free amount and is never refused. |
| 264 | pub fn storage_full(private_bytes: i64, free_bytes: i64) -> bool { |
| 265 | private_bytes >= free_bytes |
| 266 | } |
| 267 | |
| 268 | /// The answer to a push a free workspace has no room for. Plain text on |
| 269 | /// the push's first request, which git shows as the reason. |
| 270 | pub fn storage_full_response(namespace: &str, private_bytes: i64, free_bytes: i64) -> Result<Response> { |
| 271 | let gb = |bytes: i64| bytes as f64 / 1_000_000_000.0; |
| 272 | let message = format!( |
| 273 | "{namespace}'s private repositories hold {:.2} GB, and a free workspace has {:.0} GB. Free workspaces are never charged for storage, so pushes to private repositories stop here. Make the repository public, delete what you no longer need, or start the g1t plan, where storage past it is usage at cost plus 20% and pushes never stop: https://g1t.sh/{namespace}/-/billing |
| 274 | ", |
| 275 | gb(private_bytes), |
| 276 | gb(free_bytes) |
| 277 | ); |
| 278 | Response::error(message, 403) |
| 279 | } |
| 280 | |
| 281 | /// The answer to a free workspace past its share: try again next hour. |
| 282 | pub fn too_many(namespace: &str, free_cap: u64, hourly: u64) -> Result<Response> { |
| 283 | let message = format!( |
| 284 | "{namespace} has made more than {free_cap} git operations this month, so g1t allows {hourly} an hour until the month turns. Free workspaces are never charged for git operations. On the g1t plan they are never slowed: past {free_cap} a month they are usage at cost plus 20%: https://g1t.sh/{namespace}/-/billing\n" |
| 285 | ); |
| 286 | let response = Response::error(message, 429)?; |
| 287 | response.headers().set("retry-after", "3600")?; |
| 288 | Ok(response) |
| 289 | } |
| 290 | |
| 291 | #[cfg(test)] |
| 292 | mod tests { |
| 293 | use super::*; |
| 294 | |
| 295 | #[test] |
| 296 | fn operations_are_counted_by_the_hour() { |
| 297 | assert_eq!(hour_key("2026-10-14T09:59:59.000Z"), "2026-10-14T09"); |
| 298 | } |
| 299 | |
| 300 | fn pkt(payload: &str) -> Vec<u8> { |
| 301 | format!("{:04x}{payload}", payload.len() + 4).into_bytes() |
| 302 | } |
| 303 | |
| 304 | #[test] |
| 305 | fn each_git_request_is_metered_by_what_it_asks() { |
| 306 | use GitService::{ReceivePack, UploadPack}; |
| 307 | assert_eq!(classify(UploadPack, "info/refs", true, None), GitCall::RefAdvertisement); |
| 308 | assert_eq!(classify(ReceivePack, "info/refs", true, None), GitCall::RefAdvertisement); |
| 309 | let ls_refs = [pkt("command=ls-refs\n"), b"0001".to_vec(), pkt("peel\n"), b"0000".to_vec()].concat(); |
| 310 | assert_eq!(classify(UploadPack, "git-upload-pack", false, Some(&ls_refs)), GitCall::LsRefs); |
| 311 | let fetch = [pkt("command=fetch\n"), b"0001".to_vec(), pkt("want 1111111111111111111111111111111111111111\n"), pkt("done\n"), b"0000".to_vec()].concat(); |
| 312 | assert_eq!(classify(UploadPack, "git-upload-pack", false, Some(&fetch)), GitCall::Fetch); |
| 313 | let v0 = [pkt("want 1111111111111111111111111111111111111111 side-band-64k\n"), b"0000".to_vec(), pkt("done\n")].concat(); |
| 314 | assert_eq!(classify(UploadPack, "git-upload-pack", false, Some(&v0)), GitCall::Fetch); |
| 315 | assert_eq!(classify(ReceivePack, "git-receive-pack", false, None), GitCall::ReceivePack); |
| 316 | // By default only fetches and pushes are operations; listing refs, |
| 317 | // and anything g1t answered from its cache, never are. |
| 318 | let mapping = crate::meters::Mapping::defaults(); |
| 319 | assert_eq!(mapping.billable(GitCall::Fetch.meter()), 1.0); |
| 320 | assert_eq!(mapping.billable(GitCall::ReceivePack.meter()), 1.0); |
| 321 | assert_eq!(mapping.billable(GitCall::LsRefs.meter()), 0.0); |
| 322 | assert_eq!(mapping.billable(GitCall::RefAdvertisement.meter()), 0.0); |
| 323 | for call in [GitCall::RefAdvertisement, GitCall::LsRefs, GitCall::Fetch, GitCall::ReceivePack] { |
| 324 | assert_eq!(mapping.billable(call.cached_meter()), 0.0); |
| 325 | assert_eq!(mapping.cost(call.cached_meter()), 0.0); |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn the_standing_kept_here_moves_with_local_counts_and_turns_with_the_hour() { |
| 331 | let mut standing = Standing { hour_key: "2026-10-14T09".into(), month: 50_000.0, hour: 59.0, read_at: 1_000 }; |
| 332 | assert_eq!(standing.at("2026-10-14T09", 1_000), Some((50_000, 59))); |
| 333 | standing.add("2026-10-14T09", 2.0); |
| 334 | assert_eq!(standing.at("2026-10-14T09", 2_000), Some((50_002, 61))); |
| 335 | // A new hour starts at nothing; the month goes on. |
| 336 | assert_eq!(standing.at("2026-10-14T10", 2_000), Some((50_002, 0))); |
| 337 | standing.add("2026-10-14T10", 1.0); |
| 338 | assert_eq!(standing.at("2026-10-14T10", 2_000), Some((50_003, 1))); |
| 339 | // A new month too. |
| 340 | assert_eq!(standing.at("2026-11-01T00", 2_000), Some((0, 0))); |
| 341 | // Read too long ago: not gone by, so nobody is slowed on old news. |
| 342 | assert_eq!(standing.at("2026-10-14T10", 1_000 + STANDING_TTL_MS + 1), None); |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | fn a_free_workspace_pushes_until_its_private_storage_is_full() { |
| 347 | assert!(!storage_full(999_999_999, 1_000_000_000)); |
| 348 | assert!(storage_full(1_000_000_000, 1_000_000_000)); |
| 349 | assert!(storage_full(3_000_000_000, 1_000_000_000)); |
| 350 | } |
| 351 | |
| 352 | #[test] |
| 353 | fn a_free_workspace_is_slowed_only_past_its_monthly_cap() { |
| 354 | // Under the cap: never slowed, however busy the hour. |
| 355 | assert!(!slow_down(49_999, 5_000, 50_000, 60)); |
| 356 | // Past it: 60 an hour, then wait. |
| 357 | assert!(!slow_down(50_001, 60, 50_000, 60)); |
| 358 | assert!(slow_down(50_001, 61, 50_000, 60)); |
| 359 | } |
| 360 | } |