onetaskgraph_github_projects/accounting.rs
1//! What a session of requests to GitHub cost, counted rather than argued about.
2//!
3//! Nothing here decides anything: it records what left this crate and adds it up. It
4//! exists because a query strategy cannot be chosen between without measuring what each
5//! one costs.
6//!
7//! # What one record carries
8//!
9//! One [`Request`] per outgoing HTTP request. A GraphQL request is named by the document
10//! it sent, read out of [`graphql::DOCUMENTS`] rather than from
11//! a second list of names, and carries that document's worst-case node count under the
12//! bindings that request actually sent — [`node_count`], the same
13//! offline calculation `tests/node_count.rs` holds every document to, never a second
14//! arithmetic. A REST request sends no document and has no node count, so it names the
15//! endpoint it addressed instead. Both record whether they read or wrote, how they ended,
16//! and the rate-limit facts that response's own headers carried.
17//!
18//! **Two quantities of GitHub's, kept apart by name.** `nodeCount` is the most nodes *one
19//! query may return*, checked per query; it is what [`Call::Document`] carries.
20//! `cost` is rate-limit points, metered per hour across everything one credential does; it
21//! is what [`Spend`] is in. A document well under the node limit says nothing about the
22//! second.
23//!
24//! # How a session's spend is arrived at, and what it is not
25//!
26//! Per budget, accumulated per call, from whatever that call itself makes attributable:
27//!
28//! - **[`Budget::Rest`] is metered in requests,** so a call is its own measure and is
29//! attributed one request ([`Basis::Counted`]).
30//! - **[`Budget::Graphql`] is metered in points.** Where the request was shaped so GitHub
31//! reports its own `cost` — a document selecting `rateLimit { cost }` — that is what is
32//! attributed ([`Basis::Reported`]). Otherwise this repository's stated cost model
33//! applies: **GitHub charges at least one point for any call, so one point is
34//! attributed** ([`Basis::Modelled`]), and the report says how much of the total came
35//! that way. That is a **lower bound** and the accounting says so rather than implying a
36//! measurement: a call over a large connection really costs more, and no document this
37//! source sends today asks GitHub what.
38//! - **A rate-limited refusal is attributed nothing** ([`Basis::NotRun`]), because a
39//! request GitHub refused for a rate limit did not run — the same reading of a refusal
40//! that makes retrying one safe in [`GitHubProjectsSource::graphql`](crate::GitHubProjectsSource).
41//!
42//! **What a session spent is never inferred by differencing a shared counter.** This
43//! account is shared and rate-limited, and other work draws on the same budgets in the same
44//! window, so an allowance that fell by sixty while this session made ten calls measures
45//! the account rather than the session. The report gives that movement anyway — it is worth
46//! seeing — and says on its face that it is the account's and not this session's.
47//!
48//! # Where a reader finds the report
49//!
50//! [`Session::report`] renders one from a snapshot, and the credentialed lane in
51//! `tests/live.rs` prints it at the end of every run, passed or failed — from a `Drop`, so
52//! that the run whose cost is most worth reading, the one that broke, is not the run that
53//! skips it. It carries no credential, no token, no issue body and no board content: a call
54//! is named by a document description this crate wrote or by an [`Endpoint`], which is a
55//! path template rather than the URL a run built, and everything else in it is a number.
56
57use std::collections::BTreeMap;
58use std::fmt::Write as _;
59use std::sync::Mutex;
60
61use onetaskgraph_plugin_api::{Metered, Metering};
62use serde_json::Value;
63
64pub use reqwest::StatusCode;
65
66use crate::{Limiter, Variables, graphql, largest_page_sizes, node_count};
67
68/// Which of GitHub's two separately metered budgets a request drew on.
69///
70/// They are two because GitHub meters them separately, and a single total would hide which
71/// one is close to exhaustion.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub enum Budget {
74 /// The GraphQL API, metered in points.
75 Graphql,
76 /// The REST API, metered in requests.
77 Rest,
78}
79
80impl Budget {
81 /// This budget's name, as GitHub's own `x-ratelimit-resource` header spells it.
82 #[must_use]
83 pub const fn name(self) -> &'static str {
84 match self {
85 Self::Graphql => "graphql",
86 Self::Rest => "rest",
87 }
88 }
89 /// What GitHub meters this budget in.
90 #[must_use]
91 pub const fn unit(self) -> &'static str {
92 match self {
93 Self::Graphql => "points",
94 Self::Rest => "requests",
95 }
96 }
97}
98
99/// Whether a request read or wrote.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
101pub enum Mode {
102 /// It asked for something.
103 Read,
104 /// It changed something.
105 Write,
106}
107
108impl Mode {
109 /// What a GraphQL document does.
110 ///
111 /// Every mutation this source sends creates content and no query does, so the keyword
112 /// is the whole of the question — the same test [`crate::GitHubProjectsSource`] paces
113 /// its own writes by.
114 #[must_use]
115 pub fn of_document(document: &str) -> Self {
116 if crate::is_mutation(document) {
117 Self::Write
118 } else {
119 Self::Read
120 }
121 }
122 /// This mode's name in a report.
123 #[must_use]
124 pub const fn name(self) -> &'static str {
125 match self {
126 Self::Read => "read",
127 Self::Write => "write",
128 }
129 }
130}
131
132/// The HTTP methods a REST call to GitHub is made with.
133///
134/// A closed set rather than a string: a record cannot then carry a method that is not one,
135/// and a misspelling is refused by [`Method::parse`] where it happens rather than quietly
136/// counted as a write. It is this crate's own enum rather than an HTTP client's, so which
137/// client a caller sends its own requests with stays the caller's business.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
139pub enum Method {
140 /// `GET`.
141 Get,
142 /// `HEAD`.
143 Head,
144 /// `POST`.
145 Post,
146 /// `PUT`.
147 Put,
148 /// `PATCH`.
149 Patch,
150 /// `DELETE`.
151 Delete,
152}
153
154impl Method {
155 /// The method `name` spells, whatever its case, or `None` when it spells none of them.
156 #[must_use]
157 pub fn parse(name: &str) -> Option<Self> {
158 Some(match name.trim().to_ascii_uppercase().as_str() {
159 "GET" => Self::Get,
160 "HEAD" => Self::Head,
161 "POST" => Self::Post,
162 "PUT" => Self::Put,
163 "PATCH" => Self::Patch,
164 "DELETE" => Self::Delete,
165 _ => return None,
166 })
167 }
168 /// Its name, as HTTP spells it.
169 #[must_use]
170 pub const fn name(self) -> &'static str {
171 match self {
172 Self::Get => "GET",
173 Self::Head => "HEAD",
174 Self::Post => "POST",
175 Self::Put => "PUT",
176 Self::Patch => "PATCH",
177 Self::Delete => "DELETE",
178 }
179 }
180 /// Whether it reads or writes.
181 #[must_use]
182 pub const fn mode(self) -> Mode {
183 match self {
184 Self::Get | Self::Head => Mode::Read,
185 Self::Post | Self::Put | Self::Patch | Self::Delete => Mode::Write,
186 }
187 }
188}
189
190/// How one request ended.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
192pub enum Outcome {
193 /// GitHub answered with what was asked for.
194 Answered,
195 /// GitHub did not answer it, for a reason that is not a rate limit — including a
196 /// request that never reached GitHub at all, which carries no headers to read.
197 Refused,
198 /// A rate limiter refused it, so it never ran.
199 RateLimited,
200}
201
202impl Outcome {
203 /// How a response ended, read the way this source's own limiter reads one.
204 ///
205 /// `budget_exhausted` is whether `x-ratelimit-remaining` was exactly `0`, which
206 /// *explains* a failing response rather than making a successful one fail. A GraphQL
207 /// success carrying `errors` is a refusal only its caller can rule on, so this answers
208 /// [`Outcome::Answered`] for one and the caller narrows it.
209 ///
210 /// The status is a [`StatusCode`] rather than a number, so a status HTTP has no room
211 /// for cannot be asked about at all — and an HTTP client has already parsed one for
212 /// every response it hands back, so nothing is asked of a caller that it did not have.
213 #[must_use]
214 // llmlint: ignore[invalid_states_unrepresentable] `budget_exhausted` is one header read as the yes-or-no it is — whether `x-ratelimit-remaining` was exactly `0` — so both of its values are meaningful and there is no third state a type could forbid. It is also the argument [`Limiter::classify`] already takes, on the line below, so giving this one wrapper its own spelling would mean two vocabularies for one header rather than one.
215 pub fn of_response(status: StatusCode, budget_exhausted: bool, body: &str) -> Self {
216 if Limiter::classify(status, budget_exhausted, body).is_some() {
217 return Self::RateLimited;
218 }
219 if status.is_success() {
220 Self::Answered
221 } else {
222 Self::Refused
223 }
224 }
225 /// This outcome's name in a report.
226 #[must_use]
227 pub const fn name(self) -> &'static str {
228 match self {
229 Self::Answered => "answered",
230 Self::Refused => "refused",
231 Self::RateLimited => "rate-limited",
232 }
233 }
234}
235
236/// The rate-limit facts one response's own headers carried.
237///
238/// Every figure is optional because every one of them is absent from some real response: a
239/// request that never reached GitHub has no headers at all, and a refusal from an
240/// intermediary carries whichever of them that intermediary felt like carrying. An absent
241/// figure is reported as unknown rather than guessed at.
242///
243/// **These are observations of one response, so the only ways to have one are to observe a
244/// response ([`RateLimit::read`]) or to have observed nothing ([`RateLimit::default`]).**
245/// The fields are read through the accessors below rather than assembled: a hand-built set
246/// could say the account had more remaining than its whole allowance, or name a resource
247/// GitHub would never name, and a report built on it would be a measurement of nothing.
248#[derive(Debug, Clone, Default, PartialEq, Eq)]
249pub struct RateLimit {
250 /// `x-ratelimit-limit`: the whole allowance for this budget's window.
251 limit: Option<u64>,
252 /// `x-ratelimit-remaining`: what was left of it when GitHub answered.
253 remaining: Option<u64>,
254 /// `x-ratelimit-used`: what the *account* had spent, which is not what this session
255 /// spent — other work shares the budget.
256 used: Option<u64>,
257 /// `x-ratelimit-reset`: the Unix second the allowance comes back.
258 reset: Option<u64>,
259 /// `x-ratelimit-resource`: which budget GitHub says these figures are about.
260 resource: Option<String>,
261}
262
263impl RateLimit {
264 /// Read the five headers GitHub carries a budget's state in.
265 ///
266 /// `header` is given a lower-case header name and answers that response's value for
267 /// it. Taking a lookup rather than a header map is what keeps the HTTP client this
268 /// crate happens to use out of its public interface, so a caller sending its own
269 /// requests with its own client records into the same accounting.
270 #[must_use]
271 pub fn read(header: impl Fn(&str) -> Option<String>) -> Self {
272 let number = |name: &str| {
273 header(name)
274 .as_deref()
275 .map(str::trim)
276 .and_then(|value| value.parse::<u64>().ok())
277 };
278 // The three allowance figures are one fact and are read as one. A response saying
279 // more was left of a budget than the whole of it, or more of it used than it holds,
280 // cannot be true of any account — and a report built on it would be a measurement of
281 // nothing, which is what the type documentation above refuses. So a set that cannot
282 // all be true is a budget state this response did not carry: dropped **together**,
283 // because which of the three is the wrong one is not knowable from here, and
284 // reported as unknown rather than repaired into a figure nothing observed. `reset`
285 // and `resource` are independent of them and survive. `Allowance::read` in
286 // `onetaskgraph-live` refuses the same impossibility where the gate reads it.
287 let (limit, remaining, used) = (
288 number("x-ratelimit-limit"),
289 number("x-ratelimit-remaining"),
290 number("x-ratelimit-used"),
291 );
292 let over_the_whole = |figure: Option<u64>| match (figure, limit) {
293 (Some(figure), Some(limit)) => figure > limit,
294 _ => false,
295 };
296 // And the same impossibility one step on, which neither figure shows on its own:
297 // `used` and `remaining` are two views of one allowance — what the window has spent
298 // and what is left of it — so between them they cannot exceed the whole. A response
299 // saying 4,000 used and 4,000 left of 5,000 has each figure inside the limit and
300 // still cannot be true of any account.
301 //
302 // **Only that direction is refused, and a sum falling short of the whole is kept
303 // deliberately.** It accounts for less of the budget than exists rather than for
304 // more of it than could, which no arithmetic forbids and which a response really
305 // carries: the loopback board this crate is tested against answers `remaining: 0`
306 // beside a `used` naming what that session itself spent, wherever it stands in for a
307 // budget somebody else exhausted. Dropping those three would report the budget as
308 // unknown, which is a worse answer than a short one — and it could not reach what
309 // the report says this SESSION spent in any case, because that figure is attributed
310 // per call and is never differenced out of these.
311 let more_than_the_whole_between_them = match (used, remaining, limit) {
312 (Some(used), Some(remaining), Some(limit)) => used.saturating_add(remaining) > limit,
313 _ => false,
314 };
315 let readable = !over_the_whole(remaining)
316 && !over_the_whole(used)
317 && !more_than_the_whole_between_them;
318 Self {
319 limit: limit.filter(|_| readable),
320 remaining: remaining.filter(|_| readable),
321 used: used.filter(|_| readable),
322 reset: number("x-ratelimit-reset"),
323 // The one field here that is not a number, so the one that could carry a third
324 // party's arbitrary bytes into a value this crate hands back. GitHub names these
325 // `core`, `graphql`, `search`, `integration_manifest`; anything that is not
326 // spelled like one is dropped rather than stored.
327 resource: header("x-ratelimit-resource")
328 .map(|value| value.trim().to_owned())
329 .filter(|value| is_resource_name(value)),
330 }
331 }
332 /// The whole allowance for this budget's window, as this response reported it.
333 #[must_use]
334 pub const fn limit(&self) -> Option<u64> {
335 self.limit
336 }
337 /// What was left of it when GitHub answered.
338 #[must_use]
339 pub const fn remaining(&self) -> Option<u64> {
340 self.remaining
341 }
342 /// What the **account** had spent — not what this session spent, because other work
343 /// draws on the same budget in the same window.
344 #[must_use]
345 pub const fn used_by_the_account(&self) -> Option<u64> {
346 self.used
347 }
348 /// The Unix second the allowance comes back.
349 #[must_use]
350 pub const fn reset(&self) -> Option<u64> {
351 self.reset
352 }
353 /// Which budget GitHub said these figures were about, when it named one it spells.
354 #[must_use]
355 pub fn resource(&self) -> Option<&str> {
356 self.resource.as_deref()
357 }
358 /// Whether these headers say the budget is exactly spent, which is what
359 /// [`Outcome::of_response`] reads.
360 #[must_use]
361 pub fn exhausted(&self) -> bool {
362 self.remaining == Some(0)
363 }
364}
365
366/// Whether a value is spelled like one of GitHub's rate-limit resource names.
367///
368/// ASCII letters, digits, underscores and hyphens, and short. It is a shape rather than a
369/// list because GitHub adds resources — `code_search` arrived after `search` — and a name
370/// this does not recognise should be reported as GitHub sent it rather than dropped for
371/// being new.
372fn is_resource_name(value: &str) -> bool {
373 !value.is_empty()
374 && value.len() <= 64
375 && value
376 .bytes()
377 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
378}
379
380/// Where one call's attributed spend came from.
381///
382/// It is on the record rather than folded into the number so a report can say how much of
383/// a session's total is GitHub's own figure and how much is this repository's model.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
385pub enum Basis {
386 /// The budget is metered in requests, so the call is its own measure.
387 Counted,
388 /// GitHub reported this call's own cost, because the request asked it to.
389 Reported,
390 /// This repository's stated cost model: one point, GitHub's documented minimum for any
391 /// call, which is a lower bound rather than a measurement.
392 Modelled,
393 /// A rate limiter refused the request, so it never ran and is attributed nothing.
394 NotRun,
395}
396
397impl Basis {
398 /// This basis in a report, as a phrase that says what the figure is worth.
399 #[must_use]
400 pub const fn name(self) -> &'static str {
401 match self {
402 Self::Counted => "counted",
403 Self::Reported => "reported by GitHub",
404 Self::Modelled => "this repository's one-point-per-call lower bound",
405 Self::NotRun => "not run",
406 }
407 }
408}
409
410/// What one call is attributed against its budget, and where that figure came from.
411///
412/// **The amount and the basis are one fact, so they are settled together and read apart.**
413/// Three of the four bases fix the amount outright — a call against a budget metered in
414/// requests is one request, the model's lower bound is one point, and a request a rate
415/// limiter refused never ran and is nothing — and only [`Basis::Reported`] carries a figure
416/// of its own, GitHub's. Constructing the pair field by field would let a report say a call
417/// GitHub never ran spent forty points, which is a measurement of nothing; the four
418/// constructors below are the only ways to have one, and each is the invariant for its own
419/// basis.
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub struct Spend {
422 amount: u64,
423 basis: Basis,
424}
425
426impl Spend {
427 /// One request against a budget metered in requests.
428 const fn counted() -> Self {
429 Self {
430 amount: 1,
431 basis: Basis::Counted,
432 }
433 }
434 /// What GitHub itself reported this call cost.
435 const fn reported(cost: u64) -> Self {
436 Self {
437 amount: cost,
438 basis: Basis::Reported,
439 }
440 }
441 /// This repository's lower bound: one point, GitHub's documented minimum for any call.
442 const fn modelled() -> Self {
443 Self {
444 amount: 1,
445 basis: Basis::Modelled,
446 }
447 }
448 /// Nothing, because a rate limiter refused the request and it never ran.
449 const fn not_run() -> Self {
450 Self {
451 amount: 0,
452 basis: Basis::NotRun,
453 }
454 }
455 /// The amount, in that budget's own unit.
456 #[must_use]
457 pub const fn amount(self) -> u64 {
458 self.amount
459 }
460 /// What makes it that amount.
461 #[must_use]
462 pub const fn basis(self) -> Basis {
463 self.basis
464 }
465}
466
467/// A REST endpoint, spelled the way GitHub's own documentation spells one.
468///
469/// `GET /repos/{owner}/{repo}/labels`: a method and a path template whose segments are
470/// literals or `{placeholder}`s, never the URL a run built from it. That is what makes two
471/// runs' reports compare line for line, and it is why this is a type with one constructor
472/// rather than a string a caller fills in — a record holding
473/// `https://api.github.com/repos/octo-org/board/labels?per_page=100` would put a board's
474/// name, and whatever else a query string carried, into a report that promises to carry
475/// neither.
476///
477/// **What it rules on, and what it cannot.** [`Endpoint::parse`] refuses a host, a query
478/// string, a fragment, whitespace, a byte outside the small set a path template is spelled
479/// from, and anything long — every way an addressed URL differs in *shape* from a template.
480/// It cannot tell a template's literal segment from a filled-in one, because
481/// `/repos/octo-org/board/labels` is spelled exactly like a template of literals; keeping
482/// the placeholders unfilled is the caller's, and the reason `Request::rest`'s own
483/// documentation says to pass the template.
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct Endpoint {
486 method: Method,
487 path: String,
488 name: String,
489}
490
491impl Endpoint {
492 /// The endpoint `path` spells under `method`, or `None` when `path` is not spelled like
493 /// a path template at all.
494 ///
495 /// Refusing it here is refusing it where it is written, which is the same answer
496 /// [`Method::parse`] gives a method HTTP has no verb for: a caller learns at the call
497 /// site rather than finding a URL in a report that was supposed to hold none.
498 #[must_use]
499 pub fn parse(method: Method, path: &str) -> Option<Self> {
500 if !is_path_template(path) {
501 return None;
502 }
503 Some(Self {
504 method,
505 path: path.to_owned(),
506 name: format!("{} {path}", method.name()),
507 })
508 }
509 /// The method it is addressed with.
510 #[must_use]
511 pub const fn method(&self) -> Method {
512 self.method
513 }
514 /// The path template, without the method.
515 #[must_use]
516 pub fn path(&self) -> &str {
517 &self.path
518 }
519 /// What it is called in a report: the method and the template, as above.
520 #[must_use]
521 pub fn name(&self) -> &str {
522 &self.name
523 }
524}
525
526/// Whether `path` is spelled like one of GitHub's documented endpoint templates.
527///
528/// An absolute path of `/`-separated segments, each a literal of the bytes a documented
529/// endpoint uses or a single `{placeholder}`. A host, a query string, a fragment, an empty
530/// segment and anything over the length GitHub's own longest endpoint needs are all refused,
531/// because each of them is a way an addressed URL — which carries what a run touched —
532/// differs from the template it was built from.
533fn is_path_template(path: &str) -> bool {
534 if path.is_empty() || path.len() > 128 || !path.starts_with('/') {
535 return false;
536 }
537 path[1..].split('/').all(|segment| {
538 let placeholder = segment
539 .strip_prefix('{')
540 .and_then(|rest| rest.strip_suffix('}'));
541 match placeholder {
542 Some(name) => {
543 !name.is_empty()
544 && name
545 .bytes()
546 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
547 }
548 None => {
549 !segment.is_empty()
550 && segment.bytes().all(|byte| {
551 byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
552 })
553 }
554 }
555 })
556}
557
558/// What one request asked for.
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub enum Call {
561 /// A GraphQL request, which sends a document.
562 Document {
563 /// What the sender was doing, from
564 /// [`graphql::DOCUMENTS`] when the document is one of
565 /// this source's own, and from the caller's own name when it is not.
566 name: String,
567 /// The document's worst-case node count under the bindings that request sent, or
568 /// `None` when the calculation could not rule on the document — which is a defect
569 /// in the document rather than a cost of zero.
570 node_count: Option<u64>,
571 },
572 /// A REST request, which sends no document and has no node count.
573 Endpoint {
574 /// The endpoint it addressed. An [`Endpoint`] rather than a string, so a record
575 /// cannot hold the URL a run built — see that type for what it rules on.
576 endpoint: Endpoint,
577 },
578}
579
580impl Call {
581 /// What this call is called in a report.
582 #[must_use]
583 pub fn name(&self) -> &str {
584 match self {
585 Self::Document { name, .. } => name,
586 Self::Endpoint { endpoint } => endpoint.name(),
587 }
588 }
589 /// The budget it draws on.
590 #[must_use]
591 pub const fn budget(&self) -> Budget {
592 match self {
593 Self::Document { .. } => Budget::Graphql,
594 Self::Endpoint { .. } => Budget::Rest,
595 }
596 }
597 /// The most nodes it may return, for a GraphQL request that has such a number.
598 #[must_use]
599 pub const fn node_count(&self) -> Option<u64> {
600 match self {
601 Self::Document { node_count, .. } => *node_count,
602 Self::Endpoint { .. } => None,
603 }
604 }
605}
606
607/// One outgoing HTTP request, described before it is sent.
608///
609/// [`Sending::finished`], and [`Sending::answered`] for the ordinary case, are what turn it
610/// into a [`Request`]: a record with no outcome is one nobody could add up, so the outcome
611/// is the step that produces the record rather than a field that might be missing.
612///
613/// Everything a request carries before it is sent is settled by the constructor that made
614/// it, which is why there is no builder step here that could attach a reported GraphQL cost
615/// to a REST call — a budget metered in requests has no such figure, and GitHub reports
616/// none for one.
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct Sending {
619 call: Call,
620 mode: Mode,
621 drawn: Drawn,
622}
623
624/// What a request draws on, and what only that kind of request can carry.
625///
626/// The reported cost lives inside the GraphQL arm rather than beside the call, because a
627/// REST request has nowhere to have got one from: GitHub meters that budget in requests and
628/// reports no per-call figure at all.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630enum Drawn {
631 /// The GraphQL budget, with GitHub's own reported cost for this call when the request
632 /// was shaped so GitHub reported one.
633 Graphql { reported_cost: Option<u64> },
634 /// The REST budget, which a call is its own measure of.
635 Rest,
636}
637
638impl Sending {
639 /// GitHub answered it, with the rate-limit facts its response's headers carried.
640 #[must_use]
641 pub fn answered(self, limits: RateLimit) -> Request {
642 self.finished(Outcome::Answered, limits)
643 }
644 /// However it ended, with the rate-limit facts its response's headers carried.
645 #[must_use]
646 pub fn finished(self, outcome: Outcome, limits: RateLimit) -> Request {
647 let spend = match (self.drawn, outcome) {
648 (_, Outcome::RateLimited) => Spend::not_run(),
649 (Drawn::Rest, _) => Spend::counted(),
650 (
651 Drawn::Graphql {
652 reported_cost: Some(cost),
653 },
654 _,
655 ) => Spend::reported(cost),
656 (
657 Drawn::Graphql {
658 reported_cost: None,
659 },
660 _,
661 ) => Spend::modelled(),
662 };
663 Request {
664 call: self.call,
665 mode: self.mode,
666 outcome,
667 limits,
668 spend,
669 }
670 }
671}
672
673/// What a GraphQL document this crate does not send is called until its sender names it.
674const UNNAMED_DOCUMENT: &str = "talking to GitHub";
675
676/// One outgoing HTTP request, and what it cost.
677#[derive(Debug, Clone, PartialEq, Eq)]
678pub struct Request {
679 call: Call,
680 mode: Mode,
681 outcome: Outcome,
682 limits: RateLimit,
683 spend: Spend,
684}
685
686impl Request {
687 /// Begin recording one GraphQL request carrying `document` under `variables`.
688 ///
689 /// The name comes from [`graphql::DOCUMENTS`] — the
690 /// inventory, not a second list — and the node count from
691 /// [`node_count`] under the page sizes `variables` really binds, so
692 /// a page read with a caller's smaller limit is counted at that limit rather than at
693 /// the worst case. Any page-size variable the request leaves unbound keeps the largest
694 /// value this source could send it, which is the worst case for exactly that document.
695 ///
696 /// `otherwise` names a document the inventory does not hold, which is how a caller's own
697 /// calls — a schema introspection, a residue sweep — are named beside this source's;
698 /// `None` leaves such a document under the placeholder, and a document the inventory
699 /// does hold keeps its entry's name either way.
700 ///
701 /// `reported_cost` is GitHub's own `cost` for **this** call, from a response to a
702 /// request shaped to report one. A `rateLimit(dryRun: true)` probe reports what some
703 /// other document would cost and never what this call spent, so its figure does not
704 /// belong here.
705 #[must_use]
706 pub fn graphql(
707 document: &str,
708 variables: &Value,
709 otherwise: Option<&str>,
710 reported_cost: Option<u64>,
711 ) -> Sending {
712 let name = document_name(document);
713 Sending {
714 call: Call::Document {
715 name: match (name, otherwise) {
716 (UNNAMED_DOCUMENT, Some(otherwise)) => otherwise.to_owned(),
717 (name, _) => name.to_owned(),
718 },
719 // llmlint: ignore[invalid_states_unrepresentable] `None` here is not a missing invariant, it is the honest reading of a document this calculation could not rule on — and the state is reachable, because this constructor is public and `otherwise` exists for a caller's OWN documents (a schema introspection, a residue sweep) which no test of this source's inventory sweeps. Refusing to build the record would lose the request from the accounting entirely, which is strictly worse than recording a call whose node count is unknown. It is never totalled as zero: `Session::total_node_count` filter-maps it out, the report prints the total "over N GraphQL requests that have one", and the by-document section lists only those. Every document this source itself sends is held countable by `tests/node_count.rs`.
720 node_count: node_count(document, &bindings(variables)).ok(),
721 },
722 mode: Mode::of_document(document),
723 drawn: Drawn::Graphql { reported_cost },
724 }
725 }
726 /// Begin recording one REST request against `endpoint`.
727 ///
728 /// `endpoint` is the endpoint rather than the URL that was built from it — `GET
729 /// /repos/{owner}/{repo}/labels`, not the repository and label a run happened to name —
730 /// because a report is compared between runs and carries no board content. It is an
731 /// [`Endpoint`] rather than a string for that reason: what a report may hold is settled
732 /// where the endpoint is written, not here.
733 #[must_use]
734 pub fn rest(endpoint: Endpoint) -> Sending {
735 Sending {
736 mode: endpoint.method().mode(),
737 call: Call::Endpoint { endpoint },
738 drawn: Drawn::Rest,
739 }
740 }
741 /// What was called.
742 #[must_use]
743 pub const fn call(&self) -> &Call {
744 &self.call
745 }
746 /// What it is called in a report.
747 #[must_use]
748 pub fn name(&self) -> &str {
749 self.call.name()
750 }
751 /// Whether it read or wrote.
752 #[must_use]
753 pub const fn mode(&self) -> Mode {
754 self.mode
755 }
756 /// How it ended.
757 #[must_use]
758 pub const fn outcome(&self) -> Outcome {
759 self.outcome
760 }
761 /// The budget it drew on.
762 #[must_use]
763 pub const fn budget(&self) -> Budget {
764 self.call.budget()
765 }
766 /// The most nodes it may return, for a GraphQL request that has such a number.
767 #[must_use]
768 pub const fn node_count(&self) -> Option<u64> {
769 self.call.node_count()
770 }
771 /// What it is attributed against its budget, and where that figure came from.
772 #[must_use]
773 pub const fn spend(&self) -> Spend {
774 self.spend
775 }
776 /// The rate-limit facts this response's own headers carried.
777 #[must_use]
778 pub const fn rate_limit(&self) -> &RateLimit {
779 &self.limits
780 }
781}
782
783/// What this source calls the document it just sent, from the inventory rather than a copy.
784fn document_name(document: &str) -> &str {
785 graphql::DOCUMENTS
786 .iter()
787 .find(|(known, _)| *known == document)
788 .map_or(UNNAMED_DOCUMENT, |(_, doing)| *doing)
789}
790
791/// The page sizes one request really bound, over the largest this source can send.
792///
793/// Starting from [`largest_page_sizes`] rather than from nothing is what keeps a document
794/// countable when the request leaves one of its page-size variables out: the calculation
795/// refuses a `first:` it has no binding for, and the worst case is the honest answer for a
796/// size nobody narrowed. A variable no `first:` references — a project number, a page
797/// cursor — is ignored by the calculation, so passing every integer through costs nothing.
798fn bindings(variables: &Value) -> Variables {
799 let mut bindings = largest_page_sizes();
800 if let Some(bound) = variables.as_object() {
801 for (name, value) in bound {
802 if let Some(size) = value.as_u64().and_then(|size| u32::try_from(size).ok()) {
803 bindings.insert(name.clone(), size);
804 }
805 }
806 }
807 bindings
808}
809
810/// Which attachment request GitHub answered.
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub enum AttachmentOperation {
813 /// The content-creating upload.
814 Upload,
815 /// An authenticated attachment read.
816 Read,
817}
818
819/// One attachment response, retained inside the owning plugin for live verification reports.
820#[derive(Debug, Clone)]
821pub struct AttachmentResponse {
822 /// The referenced asset's validated name.
823 pub name: onetaskgraph_plugin_api::AssetName,
824 /// The endpoint GitHub answered.
825 pub url: reqwest::Url,
826 /// The status GitHub actually returned.
827 pub status: reqwest::StatusCode,
828 /// Whether this was the content-creating upload rather than a read.
829 pub operation: AttachmentOperation,
830}
831
832/// Every request one session sent, and what each cost.
833///
834/// It is on this crate's ordinary code path — [`crate::GitHubProjectsSource`] records into
835/// one at the single place a request leaves the crate, with no environment variable, no
836/// feature and no build configuration to know about, because an instrument nobody switches
837/// on measures nothing. It is constructible and recordable-into from outside the crate for
838/// the other half of the same reason: a caller making its own calls beside this source's —
839/// the credentialed lane verifying a schema, sweeping residue, cleaning up — accounts for
840/// the whole session rather than for this source's share of it.
841#[derive(Debug, Default)]
842pub struct Accounting {
843 requests: Mutex<Vec<Request>>,
844 attachment_responses: Mutex<Vec<AttachmentResponse>>,
845 estimates: Mutex<BTreeMap<Budget, u64>>,
846}
847
848impl Accounting {
849 /// An accounting with nothing in it.
850 #[must_use]
851 pub fn new() -> Self {
852 Self::default()
853 }
854 /// Record what a caller estimated this session would spend against `budget`, before
855 /// it started.
856 ///
857 /// An estimate is not an observation and is kept apart from one everywhere below: it is
858 /// what a precondition decided on, and the point of carrying it here is that a report
859 /// can put it beside what the session really spent, so a reader sees how far the model
860 /// was from GitHub's own figures rather than being told to trust it. The last estimate
861 /// recorded for a budget is the one reported, because a precondition decides once.
862 pub fn estimate(&self, budget: Budget, cost: u64) {
863 self.estimates
864 .lock()
865 .unwrap_or_else(std::sync::PoisonError::into_inner)
866 .insert(budget, cost);
867 }
868 /// Record one request.
869 pub fn record(&self, request: Request) {
870 // A poisoned lock costs the accounting, never the work: a panic elsewhere must not
871 // turn measuring the session into a second failure on top of the first.
872 self.requests
873 .lock()
874 .unwrap_or_else(std::sync::PoisonError::into_inner)
875 .push(request);
876 }
877 pub(crate) fn record_attachment(&self, response: AttachmentResponse) {
878 self.attachment_responses
879 .lock()
880 .unwrap_or_else(std::sync::PoisonError::into_inner)
881 .push(response);
882 }
883 /// The actual upload and read statuses, for a caller verifying a credentialed copy.
884 #[must_use]
885 pub fn attachment_responses(&self) -> Vec<AttachmentResponse> {
886 self.attachment_responses
887 .lock()
888 .unwrap_or_else(std::sync::PoisonError::into_inner)
889 .clone()
890 }
891 /// A snapshot of what has been recorded so far: a value to hold and compare, never a
892 /// live borrow of this accounting.
893 #[must_use]
894 pub fn snapshot(&self) -> Session {
895 Session {
896 requests: self
897 .requests
898 .lock()
899 .unwrap_or_else(std::sync::PoisonError::into_inner)
900 .clone(),
901 estimates: self
902 .estimates
903 .lock()
904 .unwrap_or_else(std::sync::PoisonError::into_inner)
905 .clone(),
906 }
907 }
908}
909
910/// One session's requests, as a value a caller can hold, compare and report on.
911#[derive(Debug, Clone, Default, PartialEq, Eq)]
912pub struct Session {
913 requests: Vec<Request>,
914 estimates: BTreeMap<Budget, u64>,
915}
916
917impl Session {
918 /// The requests it holds, in the order they were recorded.
919 #[must_use]
920 pub fn requests(&self) -> &[Request] {
921 &self.requests
922 }
923 /// How many requests this session sent.
924 #[must_use]
925 pub fn total_requests(&self) -> usize {
926 self.requests.len()
927 }
928 /// The nodes every GraphQL request that has a node count may return, added up.
929 #[must_use]
930 pub fn total_node_count(&self) -> u64 {
931 self.requests
932 .iter()
933 .filter_map(Request::node_count)
934 .fold(0, u64::saturating_add)
935 }
936 /// What a precondition estimated this session would spend against `budget`, before it
937 /// started, when one estimated anything at all.
938 #[must_use]
939 pub fn estimated(&self, budget: Budget) -> Option<u64> {
940 self.estimates.get(&budget).copied()
941 }
942 /// What this session is **attributed** against `budget`, summed per call.
943 ///
944 /// Attributed rather than spent, and the difference is the point: only a call GitHub
945 /// itself reported a cost for is a measurement. A call against a budget metered in
946 /// requests is its own measure, and every other GraphQL call carries [`Basis::Modelled`]
947 /// — one point, GitHub's documented minimum, which is a **lower bound**. So this is what
948 /// the session can account for, never a claim about what GitHub charged; [`Self::budgets`]
949 /// is where the same total comes apart by basis, and that is what says how much of it is
950 /// measured.
951 #[must_use]
952 pub fn attributed(&self, budget: Budget) -> u64 {
953 self.requests
954 .iter()
955 .filter(|request| request.budget() == budget)
956 .fold(0, |total, request| {
957 total.saturating_add(request.spend.amount)
958 })
959 }
960 /// Every budget this session touched, in a stable order.
961 #[must_use]
962 pub fn budgets(&self) -> Vec<BudgetReport> {
963 let mut touched: BTreeMap<Budget, BudgetReport> = BTreeMap::new();
964 // Seeded from the estimates first: a budget a precondition sized this session
965 // against and the session then never reached is worth reporting as exactly that,
966 // rather than vanishing from the report that is supposed to compare the two.
967 for (budget, estimated) in &self.estimates {
968 touched
969 .entry(*budget)
970 .or_insert_with(|| BudgetReport::of(*budget))
971 .estimated = Some(*estimated);
972 }
973 for request in &self.requests {
974 let budget = request.budget();
975 touched
976 .entry(budget)
977 .or_insert_with(|| BudgetReport::of(budget))
978 .record(request);
979 }
980 touched.into_values().collect()
981 }
982 /// This session as the plugin interface's [`Metering`]: every request it holds, and
983 /// what each of GitHub's two budgets was attributed, split by basis.
984 ///
985 /// Both budgets are always named, a budget this session never drew on at zero, so a
986 /// reading taken before a session's first request lines up with one taken after it.
987 /// What [`Basis::Modelled`] attributed is `modelled` and everything else is `measured`
988 /// — a request a rate limiter refused is attributed nothing either way.
989 #[must_use]
990 pub fn metering(&self) -> Metering {
991 Metering {
992 requests: u64::try_from(self.total_requests()).unwrap_or(u64::MAX),
993 budgets: [Budget::Graphql, Budget::Rest]
994 .into_iter()
995 .map(|budget| {
996 let (measured, modelled) = self
997 .requests
998 .iter()
999 .filter(|request| request.budget() == budget)
1000 .fold(
1001 (0_u64, 0_u64),
1002 |(measured, modelled), request| match request.spend.basis {
1003 Basis::Modelled => {
1004 (measured, modelled.saturating_add(request.spend.amount))
1005 }
1006 _ => (measured.saturating_add(request.spend.amount), modelled),
1007 },
1008 );
1009 Metered {
1010 budget: budget.name().to_owned(),
1011 unit: budget.unit().to_owned(),
1012 measured,
1013 modelled,
1014 }
1015 })
1016 .collect(),
1017 }
1018 }
1019 /// The session report: what a person puts two runs of side by side.
1020 #[must_use]
1021 pub fn report(&self) -> String {
1022 let mut report = String::from("github-projects session accounting\n");
1023 let count = |mode: Mode| {
1024 self.requests
1025 .iter()
1026 .filter(|request| request.mode == mode)
1027 .count()
1028 };
1029 let ended = |outcome: Outcome| {
1030 self.requests
1031 .iter()
1032 .filter(|request| request.outcome == outcome)
1033 .count()
1034 };
1035 let _ = writeln!(
1036 report,
1037 "requests {}: {} {}, {} {}; {} {}, {} {}, {} {}",
1038 self.total_requests(),
1039 count(Mode::Read),
1040 Mode::Read.name(),
1041 count(Mode::Write),
1042 Mode::Write.name(),
1043 ended(Outcome::Answered),
1044 Outcome::Answered.name(),
1045 ended(Outcome::Refused),
1046 Outcome::Refused.name(),
1047 ended(Outcome::RateLimited),
1048 Outcome::RateLimited.name(),
1049 );
1050 report.push_str("requests by call\n");
1051 let mut by_call: BTreeMap<&str, (usize, u64, usize)> = BTreeMap::new();
1052 for request in &self.requests {
1053 let entry = by_call.entry(request.name()).or_insert((0, 0, 0));
1054 entry.0 += 1;
1055 if let Some(nodes) = request.node_count() {
1056 entry.1 = entry.1.saturating_add(nodes);
1057 entry.2 += 1;
1058 }
1059 }
1060 for (name, (requests, _, _)) in &by_call {
1061 let _ = writeln!(report, " {name:<52}{requests:>6}");
1062 }
1063 let counted = self
1064 .requests
1065 .iter()
1066 .filter(|request| request.node_count().is_some())
1067 .count();
1068 let _ = writeln!(
1069 report,
1070 "node count {} over {counted} GraphQL requests that have one",
1071 self.total_node_count(),
1072 );
1073 report.push_str("node count by document\n");
1074 for (name, (_, nodes, requests)) in by_call.iter().filter(|(_, (_, _, with))| *with > 0) {
1075 let counted = format!(
1076 "{requests} {}",
1077 if *requests == 1 {
1078 "request"
1079 } else {
1080 "requests"
1081 }
1082 );
1083 let _ = writeln!(report, " {name:<52}{counted:>14}{nodes:>12} nodes");
1084 }
1085 for budget in self.budgets() {
1086 report.push_str(&budget.render());
1087 }
1088 report
1089 }
1090}
1091
1092/// One budget a session drew on, with its own figures kept apart from the account's.
1093///
1094/// **Every figure here is a total over requests that really drew on this budget, so the only
1095/// way to have one is to add those requests up.** [`Session::budgets`] is that, and
1096/// [`BudgetReport::record`] is where a request joins one: the request count, the spend, its
1097/// three attributions and the account's own readings all move together, from the same record,
1098/// so a report cannot say it summarises nine requests while its attributions add up to four,
1099/// or carry a REST budget's figures under [`Budget::Graphql`]. The fields are read through
1100/// the accessors below for exactly the reason [`RateLimit`]'s are: a hand-assembled set of
1101/// totals would be a measurement of nothing, and this whole accounting exists because a
1102/// number nobody measured was argued about instead.
1103#[derive(Debug, Clone, PartialEq, Eq)]
1104pub struct BudgetReport {
1105 budget: Budget,
1106 estimated: Option<u64>,
1107 requests: usize,
1108 attributed: u64,
1109 reported: u64,
1110 modelled: u64,
1111 counted: u64,
1112 not_run: usize,
1113 limit: Option<u64>,
1114 used_by_the_account: Option<u64>,
1115 remaining_first_seen: Option<u64>,
1116 remaining_last_seen: Option<u64>,
1117}
1118
1119impl BudgetReport {
1120 /// A report of `budget` with nothing added to it yet.
1121 const fn of(budget: Budget) -> Self {
1122 Self {
1123 budget,
1124 estimated: None,
1125 requests: 0,
1126 attributed: 0,
1127 reported: 0,
1128 modelled: 0,
1129 counted: 0,
1130 not_run: 0,
1131 limit: None,
1132 used_by_the_account: None,
1133 remaining_first_seen: None,
1134 remaining_last_seen: None,
1135 }
1136 }
1137 /// Add one of this budget's requests, and everything its response said about it.
1138 ///
1139 /// The one place these totals move, which is what makes them agree with each other and
1140 /// with the requests they are over.
1141 fn record(&mut self, request: &Request) {
1142 self.requests += 1;
1143 self.attributed = self.attributed.saturating_add(request.spend.amount);
1144 match request.spend.basis {
1145 Basis::Reported => {
1146 self.reported = self.reported.saturating_add(request.spend.amount);
1147 }
1148 Basis::Modelled => {
1149 self.modelled = self.modelled.saturating_add(request.spend.amount);
1150 }
1151 Basis::Counted => {
1152 self.counted = self.counted.saturating_add(request.spend.amount);
1153 }
1154 // Attributed nothing, and counted as one of the requests that ran into the
1155 // limiter. Its headers are still read below — a refusal for a spent budget is
1156 // the response whose figures say most about that budget's state.
1157 Basis::NotRun => self.not_run += 1,
1158 }
1159 if let Some(limit) = request.limits.limit() {
1160 self.limit = Some(limit);
1161 }
1162 if let Some(used) = request.limits.used_by_the_account() {
1163 self.used_by_the_account = Some(used);
1164 }
1165 if let Some(remaining) = request.limits.remaining() {
1166 self.remaining_first_seen.get_or_insert(remaining);
1167 self.remaining_last_seen = Some(remaining);
1168 }
1169 }
1170 /// Which budget.
1171 #[must_use]
1172 pub const fn budget(&self) -> Budget {
1173 self.budget
1174 }
1175 /// What a precondition estimated this session would spend against it before it
1176 /// started, when one estimated anything at all.
1177 ///
1178 /// It is an estimate rather than a measurement, and the report says so where it prints
1179 /// it: what makes it worth carrying is that a reader can see how far the model was from
1180 /// what the session really spent.
1181 #[must_use]
1182 pub const fn estimated(&self) -> Option<u64> {
1183 self.estimated
1184 }
1185 /// How many of this session's requests drew on it.
1186 #[must_use]
1187 pub const fn requests(&self) -> usize {
1188 self.requests
1189 }
1190 /// What this session itself is **attributed** against it, summed per call.
1191 ///
1192 /// Attributed rather than spent, for the reason [`Session::attributed`] gives: some of
1193 /// this figure is [`Basis::Modelled`], GitHub's documented one-point minimum, which is a
1194 /// lower bound and not a measurement. The four figures below are the same total split by
1195 /// basis, which is what says how much of it GitHub itself reported.
1196 #[must_use]
1197 pub const fn attributed(&self) -> u64 {
1198 self.attributed
1199 }
1200 /// How much of that GitHub itself reported.
1201 #[must_use]
1202 pub const fn reported(&self) -> u64 {
1203 self.reported
1204 }
1205 /// How much of it is this repository's one-point-per-call lower bound.
1206 #[must_use]
1207 pub const fn modelled(&self) -> u64 {
1208 self.modelled
1209 }
1210 /// How much of it is a count of requests against a budget metered in requests.
1211 #[must_use]
1212 pub const fn counted(&self) -> u64 {
1213 self.counted
1214 }
1215 /// How many of its requests a rate limiter refused, so they never ran and are
1216 /// attributed nothing.
1217 #[must_use]
1218 pub const fn not_run(&self) -> usize {
1219 self.not_run
1220 }
1221 /// The whole allowance, as GitHub's own headers reported it.
1222 #[must_use]
1223 pub const fn limit(&self) -> Option<u64> {
1224 self.limit
1225 }
1226 /// What the **account** had spent, as GitHub's own headers reported it. Not this
1227 /// session's spend: other work draws on the same budget in the same window.
1228 #[must_use]
1229 pub const fn used_by_the_account(&self) -> Option<u64> {
1230 self.used_by_the_account
1231 }
1232 /// The allowance remaining when this session's first request against this budget was
1233 /// answered.
1234 #[must_use]
1235 pub const fn remaining_first_seen(&self) -> Option<u64> {
1236 self.remaining_first_seen
1237 }
1238 /// The allowance remaining when its last one was.
1239 #[must_use]
1240 pub const fn remaining_last_seen(&self) -> Option<u64> {
1241 self.remaining_last_seen
1242 }
1243 /// How far the **account's** remaining allowance fell while this session ran.
1244 ///
1245 /// A fall rather than a movement, and the difference is not pedantry: an allowance that
1246 /// *rose* is the hourly window having reset mid-session, which is not a negative spend
1247 /// and answers zero here. Either way it is not this session's spend, and the report says
1248 /// so where it prints it — this account is shared, so the difference between two
1249 /// readings of a shared counter measures the account.
1250 #[must_use]
1251 pub fn account_allowance_fall(&self) -> Option<u64> {
1252 Some(
1253 self.remaining_first_seen?
1254 .saturating_sub(self.remaining_last_seen?),
1255 )
1256 }
1257 /// This budget's lines of the session report.
1258 #[must_use]
1259 pub fn render(&self) -> String {
1260 let unknown = |value: Option<u64>| {
1261 value.map_or_else(|| "not reported".to_owned(), |value| value.to_string())
1262 };
1263 let mut lines = format!(
1264 "budget {}, metered in {}\n",
1265 self.budget.name(),
1266 self.budget.unit()
1267 );
1268 let _ = writeln!(
1269 lines,
1270 " this session sent {} requests and is attributed {} {} — measured only where \
1271 GitHub reported it: {} {}, {} {}, {} {}; {} {}",
1272 self.requests,
1273 self.attributed,
1274 self.budget.unit(),
1275 self.reported,
1276 Basis::Reported.name(),
1277 self.modelled,
1278 Basis::Modelled.name(),
1279 self.counted,
1280 Basis::Counted.name(),
1281 self.not_run,
1282 Basis::NotRun.name(),
1283 );
1284 if let Some(estimated) = self.estimated {
1285 let _ = writeln!(
1286 lines,
1287 " a precondition estimated {estimated} {} before this session started, \
1288 against the {} attributed above",
1289 self.budget.unit(),
1290 self.attributed,
1291 );
1292 }
1293 let _ = writeln!(
1294 lines,
1295 " the account: limit {}, {} used, {} remaining when this session finished",
1296 unknown(self.limit),
1297 unknown(self.used_by_the_account),
1298 unknown(self.remaining_last_seen),
1299 );
1300 let _ = writeln!(
1301 lines,
1302 " the account's remaining allowance fell {} while this session ran; that is the \
1303 account's own consumption and not this session's spend, because other work \
1304 draws on the same budget in the same window",
1305 unknown(self.account_allowance_fall()),
1306 );
1307 lines
1308 }
1309}