Commit

One operation mapping, owned by repos; billing reads it instead of keeping its own

- Billing's cost reconciliation reads git_operations and artifacts_usage (with repos' mapping) and keeps no weights of its own. - Migration 0023 drops the unused billable_units table. - BILLING_OPERATIONS.md explains which raw meters are operations and how to change it.

syntaqxcommitted Parent3050fe6Browse files
4 files+118−710/4 viewed
+21−9
2222 | The ledger | Every charge: its cost at the price book's cost, what it was charged at price, what paid for it. | read, never written |
2323 | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` |
2424 | `plan_payments` | The plan's $20. | read |
25−| repos `git_operations` (and `artifacts_usage` once it ships) | g1t's own counts per workspace. | `own_counts` (`git_operations`, `artifacts_<raw meter>`) |
25+| repos `git_operations` | Operations customers are charged for, per workspace, counted by repos through its `operation_mapping`. | `own_counts` meter `git_operations` |
26+| repos `artifacts_usage` | Every raw meter of the git store (`git.fetch`, `git.receive_pack`, `binding.*`, …) per day and workspace, with repos' `operation_mapping`. | `own_counts` meters `artifacts_<raw meter>`, and `cost_operations` (raw counts × the mapping's `cost_operations`: what g1t expects Cloudflare to bill) |
2627
2728 A meter's slug is Cloudflare's name lower-cased with words joined by `_`
2829 and the "(First … included)" note dropped: `Workers for Platforms CPU ms
124125
125126 | Kind | When | What to do |
126127 | --- | --- | --- |
127−| Count | g1t's count and Cloudflare's differ by more than the mapping's `drift_percent` (10%) | Find out what Cloudflare counts. If it counts more (binding reads, `ls-refs`), either change the weights in `billable_units` so customers are charged for what Cloudflare counts, or leave the count and let the per-unit cost rise (below). |
128+| Count | g1t's count and Cloudflare's differ by more than the mapping's `drift_percent` (10%) | Find out what Cloudflare counts: compare its events with `own_counts` `artifacts_*` and `cost_operations`. If it counts more (binding reads, `ls-refs`), either change repos' `operation_mapping` so customers are charged for what Cloudflare counts, or leave it and let the per-unit cost rise (below). |
128129 | Cost | Cloudflare charged more than `drift_percent` away from the price book's cost of the same usage, with at least `min_daily_cost` | A price is stale: check the proposals. |
129130 | Leak | Cost of at least `min_daily_cost` and nothing charged for it (never for `platform`), or a meter in `unmapped` | Map the meter (below), or decide it is overhead (`platform`). |
130131
167168 - **Drift threshold**.
168169
169170 It applies from the next run; **Read the bill now** applies it at once.
170−Every change is in the audit log (`cost_mapping`). For a raw meter's
171−weight in billable units (`billable_units`, e.g. `git_operations` ←
172−`upload_pack` 1, `receive_pack` 1, `binding_read` 0), use
173−`npx wrangler d1 execute g1t-billing --remote --command "INSERT …"` until
174−sudo has a form; a weight applies to counts from then on.
171+Every change is in the audit log (`cost_mapping`).
172+
173+## Which raw meters are operations
174+
175+There is one mapping, and the repos service owns it: `operation_mapping`
176+in g1t-repos' database, one row per raw meter with `cost_operations` (how
177+many operations Cloudflare bills for it) and `billable_operations` (how
178+many the customer is charged for). Change it with repos'
179+`set_operation_mapping` RPC (services only), or
180+`npx wrangler d1 execute g1t-repos --remote` until sudo has a form. A
181+change applies to counts from then on, never to what was counted.
182+Billing keeps no mapping of its own: it reads repos' `git_operations`
183+(already mapped) for what customers are charged, and `artifacts_usage`
184+(raw counts with the mapping) for `cost_operations`. Migration 0023 drops
185+the `billable_units` table 0022 made for this, which was never written.
175186
176187 ## Alerts runbook
177188
189200
190201 ## Tables (migration `0022_costs_and_margin.sql`)
191202
192−`cost_lines`, `cost_map`, `revenue_map`, `billable_units`, `own_counts`,
203+`cost_lines`, `cost_map`, `revenue_map`, `own_counts`,
193204 `pending_days`, `margin_days`, `workspace_costs`, `cost_drift`,
194205 `margin_alerts`, `price_versions` (seeded with every current price as
195206 version 1), `price_proposals`, `price_notices`, `cost_settings` (the
196207 guardrails, seeded), the `actions_cache` price, and `ledger.price_version`.
197208 Every create is `IF NOT EXISTS` and every seed `INSERT OR IGNORE`; the one
198−`ALTER` is applied once by D1's migration tracking.
209+`ALTER` is applied once by D1's migration tracking. Migration
210+`0023_one_operation_mapping.sql` drops `billable_units` (see above).
+6−0
1+-- One mapping from Artifacts' raw meters to operations: the repos service's
2+-- operation_mapping (set_operation_mapping), which says for each raw meter
3+-- how many operations it is to Cloudflare and to the customer. Billing
4+-- reads it with the raw counts (artifacts_usage) and keeps none of its own,
5+-- so the table 0022 made for that, never written, goes.
6+DROP TABLE IF EXISTS billable_units;
+90−61
380380 .then(|| (day[..10].to_owned(), workspace.to_lowercase(), slug(meter), count))
381381 }
382382
383−/// A price meter's units from raw counts and `billable_units` weights:
384−/// what g1t charges for, from what was counted.
385−pub(crate) fn billable(raw: &[(String, f64)], weights: &BTreeMap<String, f64>) -> f64 {
386− raw.iter().map(|(meter, count)| count * weights.get(meter).copied().unwrap_or(0.0)).sum()
383+/// The repos service's operation mapping, as `artifacts_usage` returns it
384+/// beside the rows: for each raw meter (slugged), how many operations it
385+/// is to Cloudflare (`cost_operations`) and to the customer
386+/// (`billable_operations`). Repos owns this mapping
387+/// (`set_operation_mapping`); billing only reads it.
388+pub(crate) fn operation_mapping(body: &Value) -> BTreeMap<String, (f64, f64)> {
389+ body["mapping"]
390+ .as_array()
391+ .map(|rows| {
392+ rows.iter()
393+ .filter_map(|r| {
394+ let meter = r["meter"].as_str()?;
395+ Some((slug(meter), (r["cost_operations"].as_f64().unwrap_or(0.0), r["billable_operations"].as_f64().unwrap_or(0.0))))
396+ })
397+ .collect()
398+ })
399+ .unwrap_or_default()
400+}
401+
402+/// Raw counts weighted by one column of the mapping: what Cloudflare
403+/// should count (`cost`), or what customers are charged for.
404+pub(crate) fn weighted(raw: &[(String, f64)], mapping: &BTreeMap<String, (f64, f64)>, cost: bool) -> f64 {
405+ raw.iter()
406+ .map(|(meter, count)| count * mapping.get(meter).map_or(0.0, |(c, b)| if cost { *c } else { *b }))
407+ .sum()
387408 }
388409
389410 impl Billing {
507528 .collect())
508529 }
509530
510− /// g1t's own counts for the days: raw Artifacts meters from the repos
511− /// service's `artifacts_usage` when it answers, and git operations,
512− /// from those raw meters and `billable_units` weights when both exist,
513− /// else from its `git_operations`.
531+ /// g1t's own counts for the days, all from the repos service, which
532+ /// owns the mapping from raw meters to operations (`operation_mapping`):
533+ ///
534+ /// - `git_operations`: what customers are charged for, as repos counts
535+ /// it (`git_operations`, already through its mapping).
536+ /// - `cost_operations`: what g1t expects Cloudflare to bill, from the
537+ /// raw meters (`artifacts_usage`) and the mapping's cost column.
538+ /// - `artifacts_<meter>`: each raw meter.
514539 pub(crate) async fn count_own(&self, since: &str, until: &str) -> Result<()> {
515540 let Some(repos) = &self.repos else { return Ok(()) };
516541 let days = days_between(since, until);
517542 let fetched_at = rfc3339(now_ms());
518543 let mut rows: Vec<(String, String, String, f64)> = Vec::new();
519544
520− // Raw meters, while the repos service may not have them yet.
521− let raw: Vec<(String, String, String, f64)> =
522− match g1t_kit::call::<_, Value>(repos, "artifacts_usage", &json!({ "from": since, "to": until })).await {
523− Ok(Value::Array(list)) => list.iter().filter_map(raw_usage_row).collect(),
524− Ok(other) => other["rows"].as_array().map(|l| l.iter().filter_map(raw_usage_row).collect()).unwrap_or_default(),
525− Err(_) => Vec::new(),
526− };
527− #[derive(Deserialize)]
528− struct Weight {
529− raw_meter: String,
530− weight: f64,
531− }
532− let weights: BTreeMap<String, f64> = self
533− .db
534− .prepare("SELECT raw_meter, weight FROM billable_units WHERE price_meter = 'git_operations'")
535− .all()
536− .await?
537− .results::<Weight>()?
538− .into_iter()
539− .map(|w| (slug(&w.raw_meter), w.weight))
540− .collect();
541− for (day, workspace, meter, count) in &raw {
542− rows.push((day.clone(), format!("artifacts_{meter}"), workspace.clone(), *count));
543− }
544− if !raw.is_empty() && !weights.is_empty() {
545+ // Raw meters and repos' mapping; skipped while repos does not answer.
546+ if let Ok(body) = g1t_kit::call::<_, Value>(repos, "artifacts_usage", &json!({ "from": since, "to": until })).await {
547+ let mapping = operation_mapping(&body);
545548 let mut by: BTreeMap<(String, String), Vec<(String, f64)>> = BTreeMap::new();
546− for (day, workspace, meter, count) in &raw {
547− by.entry((day.clone(), workspace.clone())).or_default().push((meter.clone(), *count));
549+ for (day, workspace, meter, count) in body["rows"].as_array().map(|l| l.iter().filter_map(raw_usage_row).collect::<Vec<_>>()).unwrap_or_default() {
550+ by.entry((day, workspace)).or_default().push((meter, count));
548551 }
549552 for ((day, workspace), counts) in by {
550− rows.push((day, "git_operations".to_owned(), workspace, billable(&counts, &weights)));
551− }
552− } else {
553− let mut cumulative = Vec::with_capacity(days.len());
554− for day in &days {
555− let list: Vec<WorkspaceGitOperations> = g1t_kit::call(
556− repos,
557− "git_operations",
558− &GitOperationsArgs { month: day[..7].to_owned(), since: Some(format!("{day}T00")), namespace: None },
559− )
560− .await?;
561− cumulative.push(list.into_iter().map(|w| (w.namespace.to_lowercase(), w.operations)).collect::<BTreeMap<_, _>>());
553+ // Several stores (namespaces) can give the same meter.
554+ let mut merged: BTreeMap<String, f64> = BTreeMap::new();
555+ for (meter, count) in &counts {
556+ *merged.entry(meter.clone()).or_default() += count;
557+ }
558+ for (meter, count) in &merged {
559+ rows.push((day.clone(), format!("artifacts_{meter}"), workspace.clone(), *count));
560+ }
561+ if !mapping.is_empty() {
562+ rows.push((day, "cost_operations".to_owned(), workspace, weighted(&counts, &mapping, true)));
563+ }
562564 }
563− for (day, workspace, count) in daily_from_cumulative(&days, &cumulative) {
564− rows.push((day, "git_operations".to_owned(), workspace, count as f64));
565− }
566565 }
566+ // What customers are charged for, as repos counts it through its mapping.
567+ let mut cumulative = Vec::with_capacity(days.len());
568+ for day in &days {
569+ let list: Vec<WorkspaceGitOperations> = g1t_kit::call(
570+ repos,
571+ "git_operations",
572+ &GitOperationsArgs { month: day[..7].to_owned(), since: Some(format!("{day}T00")), namespace: None },
573+ )
574+ .await?;
575+ cumulative.push(list.into_iter().map(|w| (w.namespace.to_lowercase(), w.operations)).collect::<BTreeMap<_, _>>());
576+ }
577+ for (day, workspace, count) in daily_from_cumulative(&days, &cumulative) {
578+ rows.push((day, "git_operations".to_owned(), workspace, count as f64));
579+ }
567580
568581 // Each day's counts replace what was there.
569582 self.db
757770 }
758771
759772 #[test]
760− fn raw_meters_become_billable_units_by_their_weights() {
761− let row = raw_usage_row(&json!({ "day": "2026-10-15", "namespace": "Acme", "meter": "upload_pack", "count": 12 })).unwrap();
762− assert_eq!(row, ("2026-10-15".into(), "acme".into(), "upload_pack".into(), 12.0));
773+ fn raw_meters_are_weighted_by_the_repos_mapping() {
774+ // As repos' artifacts_usage answers: rows, and its operation_mapping.
775+ let body = json!({
776+ "rows": [
777+ { "day": "2026-10-15", "store": "g1t", "workspace": "Acme", "meter": "git.fetch", "count": 12, "bytes_in": 0, "bytes_out": 0 },
778+ { "day": "2026-10-15", "store": "g1t", "workspace": "acme", "meter": "git.receive_pack", "count": 3, "bytes_in": 0, "bytes_out": 0 },
779+ { "day": "2026-10-15", "store": "g1t", "workspace": "acme", "meter": "binding.read_blob", "count": 400, "bytes_in": 0, "bytes_out": 0 }
780+ ],
781+ "mapping": [
782+ { "meter": "git.fetch", "cost_operations": 1, "billable_operations": 1 },
783+ { "meter": "git.receive_pack", "cost_operations": 1, "billable_operations": 1 },
784+ { "meter": "binding.read_blob", "cost_operations": 0, "billable_operations": 0 }
785+ ],
786+ "truncated": false
787+ });
788+ let row = raw_usage_row(&body["rows"][0]).unwrap();
789+ assert_eq!(row, ("2026-10-15".into(), "acme".into(), "git_fetch".into(), 12.0));
763790 assert!(raw_usage_row(&json!({ "day": "2026-10-15", "count": 1 })).is_none());
764− let weights: BTreeMap<String, f64> = [("upload_pack".to_string(), 1.0), ("receive_pack".to_string(), 1.0), ("binding_read".to_string(), 0.0)].into();
765− let raw = vec![("upload_pack".to_string(), 12.0), ("receive_pack".to_string(), 3.0), ("binding_read".to_string(), 400.0), ("ls_refs".to_string(), 9.0)];
766− assert_eq!(billable(&raw, &weights), 15.0);
767− // Cloudflare turns out to count binding reads: one row changes, and
768− // so does what is counted from then on.
769− let mut weights = weights;
770− weights.insert("binding_read".into(), 1.0);
771− assert_eq!(billable(&raw, &weights), 415.0);
791+ let mut mapping = operation_mapping(&body);
792+ let raw: Vec<(String, f64)> = body["rows"].as_array().unwrap().iter().filter_map(raw_usage_row).map(|r| (r.2, r.3)).collect();
793+ assert_eq!(weighted(&raw, &mapping, true), 15.0);
794+ assert_eq!(weighted(&raw, &mapping, false), 15.0);
795+ // Cloudflare turns out to bill binding reads: repos changes one row
796+ // (set_operation_mapping), and the bill g1t expects follows.
797+ mapping.insert("binding_read_blob".into(), (1.0, 0.0));
798+ assert_eq!(weighted(&raw, &mapping, true), 415.0);
799+ assert_eq!(weighted(&raw, &mapping, false), 15.0);
800+ assert!(operation_mapping(&json!({})).is_empty());
772801 }
773802
774803 #[test]
+1−1
819819 let title = costs::bucket_title(bucket);
820820 let detail = match drift.kind {
821821 DriftKind::Count => format!(
822− "{title}: g1t counted {}, Cloudflare {} over the last {DRIFT_DAYS} days ({:+.1}%). Customers are charged for what g1t counts; check what Cloudflare counts as a unit (billable_units, cost_map).",
822+ "{title}: g1t counted {}, Cloudflare {} over the last {DRIFT_DAYS} days ({:+.1}%). Customers are charged for what g1t counts; check what Cloudflare counts as a unit and change the repos service's operation_mapping (set_operation_mapping).",
823823 crate::features::thousands(drift.ours.max(0.0).round() as u64),
824824 crate::features::thousands(drift.cloudflare.max(0.0).round() as u64),
825825 drift.delta_percent.unwrap_or(0.0)