flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/billing/src/storage.rs

329 lines14,391 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1//! Usage other services meter through the month, charged once it is over:
2//! security scans, search embeddings, and private repository storage,
3//! which billing measures itself each day.
4//!
5//! Each reports what it cost g1t so far this month (`note_pending`), so the
6//! workspace's limit counts it as it happens. When the month is over,
7//! billing charges it once: at cost plus the margin, on the account's
8//! terms, after the Team credit and the trial credit (see `credits`), dated
9//! the month's last second so it falls in that month's statement and
10//! invoice.
11//!
12//! **Storage.** The git store does not report a repository's size, so the
13//! repos service counts the packs pushed through g1t's git endpoints (see
14//! `g1t_contracts::repos::StorageArgs`): a lower bound. Each day billing
15//! records what each workspace's private repositories hold and what is
16//! free that day (`FREE_PRIVATE_STORAGE_BYTES`, or
17//! `TEAM_PRIVATE_STORAGE_BYTES` on Team). Like Cloudflare's own storage
18//! billing, a month's GB-months are the days' amounts past the free one,
19//! added up and divided by 30. Public repositories are never charged.
20
21use g1t_contracts::billing::{Entitlements, EntitlementsArgs};
22use g1t_contracts::new_id;
23use g1t_contracts::repos::{StorageArgs, WorkspaceStorage};
24use g1t_contracts::time::rfc3339;
25use g1t_kit::now_ms;
26use serde::Deserialize;
27use worker::Result;
28
29use crate::credits::{self, Drawn, Eligible};
30use crate::{Billing, optional};
31
32/// Sources billing charges itself when the month is over.
33pub(crate) const CHARGED_HERE: [&str; 3] = ["security", "context", "storage"];
34
35/// A gigabyte, as Cloudflare bills storage.
36pub(crate) const GB: f64 = 1_000_000_000.0;
37
38/// GB-months from a month's daily measures, each `(private, free)` bytes:
39/// what was past the free amount each day, over 30 days.
40pub(crate) fn storage_gb_months(days: &[(i64, i64)]) -> f64 {
41 days.iter().map(|(private, free)| (private - free).max(0) as f64).sum::<f64>() / GB / 30.0
42}
43
44/// What `gb_months` cost g1t at `micros_per_gb_month`, rounded up.
45pub(crate) fn storage_cost(gb_months: f64, micros_per_gb_month: f64) -> i64 {
46 (gb_months * micros_per_gb_month).ceil() as i64
47}
48
49/// What a source is called on the statement.
50pub(crate) fn title(source: &str) -> &'static str {
51 match source {
52 "security" => "Security scans",
53 "context" => "Search embeddings",
54 "storage" => "Private repository storage past the free amount",
55 _ => "Metered usage",
56 }
57}
58
59/// A usage entry to put on the ledger.
60pub(crate) struct UsageLine<'a> {
61 pub workspace: &'a str,
62 /// What the workspace is charged, after terms and what paid for it.
63 pub charged: i64,
64 pub description: &'a str,
65 pub repo: Option<&'a str>,
66 pub task: &'a str,
67 pub cost: i64,
68 pub reference: &'a str,
69 pub created_at: &'a str,
70 pub drawn: Drawn,
71}
72
73impl Billing {
74 /// Puts a usage entry on the ledger and takes it off the balance, as
75 /// one write.
76 pub(crate) async fn post_usage(&self, line: UsageLine<'_>) -> Result<()> {
77 self.db
78 .batch(vec![
79 self.db
80 .prepare(
81 "INSERT INTO ledger
82 (id, workspace, kind, amount_micros, description, repo, task, cost_micros, reference,
83 created_at, billed_to, credit_micros, trial_micros, oss_micros)
84 VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t', ?, ?, ?)",
85 )
86 .bind(&[
87 new_id("led", now_ms()).into(),
88 line.workspace.into(),
89 (-(line.charged as f64)).into(),
90 line.description.into(),
91 optional(line.repo),
92 line.task.into(),
93 (line.cost as f64).into(),
94 line.reference.into(),
95 line.created_at.into(),
96 (line.drawn.credit as f64).into(),
97 (line.drawn.trial as f64).into(),
98 (line.drawn.oss as f64).into(),
99 ])?,
100 self.db
101 .prepare(
102 "INSERT INTO accounts (workspace, balance_micros, created_at) VALUES (?1, ?2, ?3)
103 ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2",
104 )
105 .bind(&[line.workspace.into(), (-(line.charged as f64)).into(), rfc3339(now_ms()).into()])?,
106 ])
107 .await?;
108 Ok(())
109 }
110
111 /// Writes down what a source cost g1t so far in `month`, and what it
112 /// will be charged, replacing the last figure.
113 pub(crate) async fn set_pending(&self, workspace: &str, source: &str, month: &str, cost_micros: i64) -> Result<()> {
114 let charge = credits::with_margin(cost_micros, self.margin_percent);
115 self.db
116 .prepare(
117 "INSERT INTO pending_usage (workspace, source, month, charge_micros, cost_micros, updated_at)
118 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
119 ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, cost_micros = ?5, updated_at = ?6",
120 )
121 .bind(&[
122 workspace.to_lowercase().into(),
123 source.into(),
124 month.into(),
125 (charge as f64).into(),
126 (cost_micros.max(0) as f64).into(),
127 rfc3339(now_ms()).into(),
128 ])?
129 .run()
130 .await?;
131 Ok(())
132 }
133
134 /// Charges every month that is over for the usage billing charges
135 /// itself, once each.
136 pub(crate) async fn charge_pending(&self) -> Result<()> {
137 if self.stripe.is_none() {
138 return Ok(());
139 }
140 let now = rfc3339(now_ms());
141 let current = credits::month_of(&now);
142 #[derive(Deserialize)]
143 struct Row {
144 workspace: String,
145 source: String,
146 month: String,
147 cost_micros: Option<i64>,
148 }
149 let marks = CHARGED_HERE.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", ");
150 let due = self
151 .db
152 .prepare(format!(
153 "SELECT workspace, source, month, cost_micros FROM pending_usage
154 WHERE month < ? AND charged_at IS NULL AND source IN ({marks}) ORDER BY month LIMIT 50"
155 ))
156 .bind(&[current.as_str().into()])?
157 .all()
158 .await?
159 .results::<Row>()?;
160 for row in due {
161 // Claimed first, so two crons never charge it twice.
162 let claimed = self
163 .db
164 .prepare(
165 "UPDATE pending_usage SET charged_at = ?1
166 WHERE workspace = ?2 AND source = ?3 AND month = ?4 AND charged_at IS NULL RETURNING workspace",
167 )
168 .bind(&[now.as_str().into(), row.workspace.as_str().into(), row.source.as_str().into(), row.month.as_str().into()])?
169 .first::<serde_json::Value>(None)
170 .await?;
171 let cost = row.cost_micros.unwrap_or(0);
172 if claimed.is_none() || cost <= 0 {
173 continue;
174 }
175 let base = credits::with_margin(cost, self.margin_percent);
176 let (charge, terms_note) = self.charged(&row.workspace, base).await?;
177 let drawn = self.draw(&row.workspace, charge, &row.month, &Eligible { trial: true, repo: None }).await?;
178 let detail = if row.source == "storage" {
179 let gb_months = self.gb_months(&row.workspace, &row.month).await?;
180 format!(": {gb_months:.2} GB-months")
181 } else {
182 String::new()
183 };
184 let description = format!("{} in {}{detail}{terms_note}{}", title(&row.source), row.month, drawn.note());
185 let reference = format!("{}/{}/{}", row.source, row.workspace, row.month);
186 let created_at = credits::month_end(&row.month);
187 self.post_usage(UsageLine {
188 workspace: &row.workspace,
189 charged: charge - drawn.total(),
190 description: &description,
191 repo: None,
192 task: &row.source,
193 cost,
194 reference: &reference,
195 created_at: &created_at,
196 drawn,
197 })
198 .await?;
199 }
200 Ok(())
201 }
202
203 /// A workspace's private storage past the free amount in `month`.
204 async fn gb_months(&self, workspace: &str, month: &str) -> Result<f64> {
205 #[derive(Deserialize)]
206 struct Day {
207 private_bytes: i64,
208 free_bytes: i64,
209 }
210 let days = self
211 .db
212 .prepare("SELECT private_bytes, free_bytes FROM storage_days WHERE workspace = ? AND substr(day, 1, 7) = ?")
213 .bind(&[workspace.into(), month.into()])?
214 .all()
215 .await?
216 .results::<Day>()?;
217 Ok(storage_gb_months(&days.iter().map(|d| (d.private_bytes, d.free_bytes)).collect::<Vec<_>>()))
218 }
219
220 /// Once a day: what each workspace's private repositories hold, and
221 /// what this month's storage past the free amount comes to so far.
222 pub(crate) async fn measure_storage(&self) -> Result<()> {
223 let Some(repos) = &self.repos else { return Ok(()) };
224 let list: Vec<WorkspaceStorage> = g1t_kit::call(repos, "storage", &StorageArgs {}).await?;
225 let now = rfc3339(now_ms());
226 let (day, month) = (&now[..10], credits::month_of(&now));
227 let price = self.price("private_storage").await?.map_or(500_000.0, |(cost, _)| cost);
228 for workspace in list {
229 let slug = workspace.namespace.to_lowercase();
230 let free = if self.team_on(&slug).await? { self.plans.team_storage_bytes } else { self.plans.free_storage_bytes };
231 self.db
232 .prepare(
233 "INSERT INTO storage_days (workspace, day, private_bytes, free_bytes) VALUES (?1, ?2, ?3, ?4)
234 ON CONFLICT (workspace, day) DO UPDATE SET private_bytes = ?3, free_bytes = ?4",
235 )
236 .bind(&[slug.as_str().into(), day.into(), (workspace.private_bytes as f64).into(), (free as f64).into()])?
237 .run()
238 .await?;
239 let gb_months = self.gb_months(&slug, &month).await?;
240 if gb_months > 0.0 {
241 self.set_pending(&slug, "storage", &month, storage_cost(gb_months, price)).await?;
242 }
243 }
244 Ok(())
245 }
246
247 /// `entitlements`: what the workspace's plans give it now.
248 pub(crate) async fn entitlements(&self, a: EntitlementsArgs) -> Result<Entitlements> {
249 let workspace = a.workspace.to_lowercase();
250 let team = self.team_on(&workspace).await?;
251 let now = rfc3339(now_ms());
252 let month = credits::month_of(&now);
253 #[derive(Deserialize)]
254 struct Stored {
255 private_bytes: Option<i64>,
256 }
257 let stored = self
258 .db
259 .prepare("SELECT private_bytes FROM storage_days WHERE workspace = ? ORDER BY day DESC LIMIT 1")
260 .bind(&[workspace.as_str().into()])?
261 .first::<Stored>(None)
262 .await?
263 .and_then(|s| s.private_bytes)
264 .unwrap_or(0);
265 #[derive(Deserialize)]
266 struct Sum {
267 micros: Option<i64>,
268 }
269 let oss = self
270 .db
271 .prepare("SELECT SUM(oss_micros) AS micros FROM ledger WHERE workspace = ? AND created_at >= ?")
272 .bind(&[workspace.as_str().into(), format!("{month}-01").into()])?
273 .first::<Sum>(None)
274 .await?
275 .and_then(|s| s.micros)
276 .unwrap_or(0);
277 Ok(Entitlements {
278 team,
279 audit_retention_days: if team { self.plans.team_audit_days } else { self.plans.audit_days },
280 free_private_storage_bytes: if team { self.plans.team_storage_bytes } else { self.plans.free_storage_bytes },
281 private_storage_bytes: stored,
282 team_credit_micros: if team { self.plans.team_included_micros } else { 0 },
283 team_credit_used_micros: if team { self.allowance_used("team_credit", &workspace, &month).await? } else { 0 },
284 oss_paid_micros: oss,
285 build_seconds_included: self.plans.build_seconds,
286 build_seconds_used: self.allowance_used("build_seconds", &workspace, &month).await?.max(0) as u32,
287 min_charge_micros: self.plans.min_charge_micros,
288 workspace,
289 })
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn storage_past_the_free_amount_is_counted_by_the_day() {
299 // 3 GB private with 1 GB free, every day of a 30-day month: 2 GB-months.
300 let month = vec![(3_000_000_000, 1_000_000_000); 30];
301 assert!((storage_gb_months(&month) - 2.0).abs() < 1e-9);
302 // Under the free amount: nothing.
303 assert_eq!(storage_gb_months(&[(500_000_000, 1_000_000_000); 30]), 0.0);
304 // Team: 50 GB free.
305 assert_eq!(storage_gb_months(&[(30_000_000_000, 50_000_000_000); 30]), 0.0);
306 // Ten days of 4 GB past it: a third of 4 GB-months.
307 let days = vec![(5_000_000_000, 1_000_000_000); 10];
308 assert!((storage_gb_months(&days) - 4.0 / 3.0).abs() < 1e-9);
309 }
310
311 #[test]
312 fn storage_is_priced_at_cloudflares_rate_plus_the_margin() {
313 // $0.50 a GB-month to g1t: 2 GB-months cost $1.00, charged $1.20.
314 let cost = storage_cost(2.0, 500_000.0);
315 assert_eq!(cost, 1_000_000);
316 assert_eq!(credits::with_margin(cost, 20), 1_200_000);
317 // A fraction of a millionth rounds up.
318 assert_eq!(storage_cost(0.000_000_001, 500_000.0), 1);
319 }
320
321 #[test]
322 fn embeddings_and_scans_are_charged_at_cost_plus_the_margin() {
323 // 10 million tokens at $0.067 a million: $0.67, charged $0.804.
324 assert_eq!(credits::with_margin(670_000, 20), 804_000);
325 assert_eq!(title("context"), "Search embeddings");
326 assert_eq!(title("security"), "Security scans");
327 assert!(CHARGED_HERE.contains(&"storage") && !CHARGED_HERE.contains(&"deployments"));
328 }
329}