flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/repos/src/git_ops.rs

360 lines15,880 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::wasm_bindgen::JsValue;
34use 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)]
38pub 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
49impl 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.
73pub 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.
88pub 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.
94pub 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.
99pub struct Limits {
100 pub free_cap: u64,
101 pub hourly: u64,
102}
103
104impl 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)]
112struct 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)]
119pub 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
128impl 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.
157const STANDING_TTL_MS: u64 = 10 * 60 * 1000;
158/// How long billing's answer about a workspace's plan is kept.
159const PLAN_TTL_MS: u64 = 5 * 60 * 1000;
160
161thread_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).
167pub 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.
177pub 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.
182pub 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.
201pub 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.
224pub 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.
238pub 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.
252pub 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.
264pub 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.
270pub 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.
282pub 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)]
292mod 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}