g1t/services/repos/src/git_ops.rs

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