Skip to content

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

Commit

Merge branch 'worktree-agent-a282ed55d7f8de7b6' into ship-blame

syntaqxcommitted Parents9dc8b9ec1e1984Browse files
18 files+679−1140/18 viewed
+11−4
477477
478478 Each Worker keeps a share of its invocations' logs, set by
479479 `head_sampling_rate` in its `wrangler.jsonc`: every one for billing,
480−identity, runner, actions, deployments, status and sudo, and a tenth for
481−the rest. Repos and work keep every one for now, while rare ~60 s stalls
482−are traced, and go back to a tenth once they are found. Metrics are not sampled. To chase a rare error on a sampled
483−Worker, raise its rate to 1 while you look.
480+identity, runner, actions, deployments, status and sudo, a quarter for
481+repos and work, which every page asks, and a tenth for the rest. Metrics
482+are not sampled. To chase a rare error on a sampled Worker, raise its rate
483+to 1 while you look.
484+
485+Slow work in repos logs itself as fields, so the invocations kept can be
486+counted by what was slow: an RPC answer that took a second or more logs
487+`rpc` (the method) and `answered_ms`, and a slow or partial About run logs
488+`about_run` (the repository's id), `took_ms` and `partial`. In Workers
489+Logs, filter `g1t-repos` on `answered_ms` greater than 0, and multiply
490+what you count by four.
+5−2
3333
3434 The license, security policy, languages and contributors are read from the
3535 default branch in the background each time it moves, and kept by commit, so
36−the page never waits for them. A repository pushed to for the first time
37−shows **Reading the default branch…** for a few seconds.
36+the page never waits for them. A push to the default branch starts the
37+reading as soon as git has its answer; a branch that moved otherwise, such
38+as by merging a pull request, is read again when its page is next opened.
39+A repository pushed to for the first time shows **Reading the default
40+branch…** for a few seconds.
3841
3942 ### Languages
4043
+8−0
9494 request when it caught up with `main` is credited to whoever wrote it on
9595 `main`, not to the merge.
9696
97+Blame reads up to 400 commits back, for up to six seconds. A line older
98+than that is credited to the oldest commit read that has it, and the view
99+says the history is long. A file's blame at a commit is kept once worked
100+out, so opening it again, or from a commit's link, shows at once. Search
101+engines and other crawlers are asked to leave blame pages alone
102+(`Disallow: /*?blame=` in `/robots.txt`); the file itself stays open to
103+them.
104+
97105 ### Why over the API
98106
99107 The `repository` tool's `why` action, or
+1−0
639639 </Link>
640640 <Link
641641 to={`${fileBase}/${file.path}?blame=1`}
642+ rel="nofollow"
642643 className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"
643644 >
644645 Blame
+2−0
460460 const toggle = (label: string, on: boolean, search: string) => (
461461 <Link
462462 to={{ search }}
463+ // Blame is costly to work out and adds nothing to a search index.
464+ rel={search ? "nofollow" : undefined}
463465 preventScrollReset
464466 className={`rounded px-2.5 py-1.5 transition-colors sm:px-2 sm:py-0.5 ${on ? "bg-raised text-fg" : "hover:text-fg"}`}
465467 >
+18−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { robotsTxt } from "./robots.ts";
5+
6+test("everything may be crawled but a file's blame", () => {
7+ const text = robotsTxt("https://g1t.example");
8+ assert.match(text, /^User-agent: \*\nAllow: \/\n/);
9+ assert.match(text, /^Disallow: \/\*\?blame=$/m);
10+ assert.match(text, /^Disallow: \/\*&blame=$/m);
11+ assert.match(text, /^Sitemap: https:\/\/g1t\.example\/sitemap\.xml$/m);
12+ assert.doesNotMatch(text, /docs\.g1t\.sh/, "a self-hosted g1t has no docs site");
13+ assert.ok(text.endsWith("\n"));
14+});
15+
16+test("g1t.sh names its docs' map too", () => {
17+ assert.match(robotsTxt("https://g1t.sh"), /^Sitemap: https:\/\/docs\.g1t\.sh\/sitemap-index\.xml$/m);
18+});
+22−0
1+/**
2+ * What /robots.txt says: everything may be crawled but a file's blame
3+ * (`?blame=1`), and where the site's maps are.
4+ *
5+ * Blame is the costliest file view to work out, one per file per commit a
6+ * crawler finds, and adds nothing an index needs that the file itself does
7+ * not have: on 2026-10-14 crawlers asked for 1,150 blame pages in four
8+ * hours, against a handful from people. The links to it are `nofollow` too,
9+ * and its page is `noindex` (routes/repo/blob.tsx).
10+ */
11+
12+/** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */
13+const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml";
14+
15+/** Paths crawlers are kept from, wherever the query puts `blame`. */
16+export const DISALLOWED = ["/*?blame=", "/*&blame="];
17+
18+export function robotsTxt(site: string): string {
19+ const lines = ["User-agent: *", "Allow: /", ...DISALLOWED.map((path) => `Disallow: ${path}`), "", `Sitemap: ${site}/sitemap.xml`];
20+ if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`);
21+ return `${lines.join("\n")}\n`;
22+}
+4−1
1111 import { getViewer, unwrap } from "../../lib/session.server";
1212
1313 export function meta({ params, ...args }: Route.MetaArgs) {
14− return page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` });
14+ const tags = page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` });
15+ // A file's blame is kept out of search indexes; the file itself is not (lib/robots.ts).
16+ const blame = new URLSearchParams(args.location.search).has("blame");
17+ return blame ? [...tags, { name: "robots", content: "noindex, nofollow" }] : tags;
1518 }
1619
1720 export async function loader({ params, context, request }: Route.LoaderArgs) {
+4−9
11 /**
2− * /robots.txt: everything may be crawled, and where the site's maps are.
3− * Served from the request's own origin, so a self-hosted g1t names itself.
2+ * /robots.txt (lib/robots.ts). Served from the request's own origin, so a
3+ * self-hosted g1t names itself.
44 */
55 import type { Route } from "./+types/robots-txt";
6−
7−/** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */
8−const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml";
6+import { robotsTxt } from "../lib/robots";
97
108 export function loader({ request }: Route.LoaderArgs) {
11− const site = new URL(request.url).origin;
12− const lines = ["User-agent: *", "Allow: /", "", `Sitemap: ${site}/sitemap.xml`];
13− if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`);
14− return new Response(`${lines.join("\n")}\n`, {
9+ return new Response(robotsTxt(new URL(request.url).origin), {
1510 headers: {
1611 "content-type": "text/plain; charset=utf-8",
1712 "cache-control": "public, max-age=86400",
+3−2
793793 pub ranges: Vec<BlameRange>,
794794 /// The commits the ranges name, each once.
795795 pub commits: Vec<Commit>,
796− /// True when the history was too long to read in full, so the oldest
797− /// lines are given to the oldest commit read.
796+ /// True when the history was too long to read in full (too many
797+ /// commits, or too long to read), so the oldest lines are given to the
798+ /// oldest commit read that has them.
798799 pub partial: bool,
799800 }
800801
+1−1
477477 ranges: BlameRange[];
478478 /** The commits the ranges name, each once. */
479479 commits: Commit[];
480− /** True when the history was too long to read in full. */
480+ /** True when the history was too long to read in full (too many commits, or too long to read). */
481481 partial: boolean;
482482 };
483483
+25−6
241241 Ok((Outcome::Ok(answer), refresh))
242242 }
243243
244− /// Works out `repo`'s About again, if no one else is. In the
245− /// background: a failure is logged and the lease let go early.
246− pub(crate) async fn refresh(&self, repo: &Repo) {
244+ /// Works out `repo`'s About again, if no one else is, reading for
245+ /// `budget_ms` at most. In the background: a failure is logged and the
246+ /// lease let go early. A run that was slow or partial is logged with how
247+ /// long it took (`about_run`), as a slow RPC answer is.
248+ pub(crate) async fn refresh(&self, repo: &Repo, budget_ms: u64) {
247249 let db = self.db();
250+ let started = now_ms();
248251 match stats::claim(db, &repo.id, now_ms()).await {
249252 Ok(true) => {}
250253 Ok(false) => return,
258261 let Some(head) = git.log(&repo.default_branch, 1).await?.into_iter().next() else {
259262 return Ok(None);
260263 };
261− stats::work_out(&git, &head.hash, &head.tree_hash, self.identity.as_ref()).await.map(Some)
264+ stats::work_out(&git, &head.hash, &head.tree_hash, self.identity.as_ref(), budget_ms).await.map(Some)
262265 };
263266 match worked.await {
264267 Ok(Some(worked)) => {
268+ let took = now_ms().saturating_sub(started);
269+ if worked.partial || took >= SLOW_RUN_MS {
270+ worker::console_log!(
271+ "{}",
272+ serde_json::json!({ "about_run": repo.id, "took_ms": took, "partial": worked.partial, "commits": worked.commits })
273+ );
274+ }
265275 if let Err(error) = stats::keep(db, &repo.id, &worked).await {
266276 worker::console_error!("the About of {} was worked out but not kept: {error}", repo.id);
267277 stats::release(db, &repo.id).await;
657667 }
658668 }
659669
670+/// Runs this long or longer are logged (`refresh`).
671+const SLOW_RUN_MS: u64 = 5_000;
672+
660673 /// Works out `repo`'s About after the answer has gone, when an answer
661−/// found it behind.
674+/// found it behind. What the run read is kept for the next one (trees,
675+/// blob sizes and history by hash, store.rs) by writes it starts as it
676+/// goes; the `waitUntil` lasts until they are done, or the next run would
677+/// read the same again.
662678 pub(crate) fn refresh_later(env: &Env, ctx: &worker::Context, refresh: Refresh) {
663679 let Some(repo) = refresh else { return };
664680 let env = env.clone();
665681 ctx.wait_until(async move {
666682 match crate::service(&env) {
667− Ok(repos) => repos.refresh(&repo).await,
683+ Ok(repos) => {
684+ repos.refresh(&repo, stats::BUDGET_MS).await;
685+ repos.deferred.settle().await;
686+ }
668687 Err(error) => worker::console_error!("the About of {} was not worked out: {error}", repo.id),
669688 }
670689 });
+417−56
55 //! none of them was written by the commit. Following every parent, not just
66 //! the first, matters here: catch-up merges bring the default branch into a
77 //! pull request, and their lines belong to whoever wrote them on that branch.
8+//!
9+//! Reading is what costs: each commit's file is found by reading the trees
10+//! on the way to it, and each read is a round trip to the store or its cache.
11+//! So the walk reads ahead: the trees of the next [`READ_AHEAD`] commits,
12+//! level by level and each level at once, and then, at once, the texts of the
13+//! versions it will compare. The walk stops when every line has its commit,
14+//! after [`MAX_COMMITS`] commits, before more than the caller's reads of the
15+//! store, or once the caller's time is up; stopped early, a line goes to the
16+//! oldest commit the walk reached it in, and the answer is marked partial.
17+//!
18+//! A blame of a commit and path never changes, so `lib.rs` keeps the answer
19+//! by repository, commit and path ([`cache_url`]).
820
9−use std::collections::HashMap;
21+use std::collections::{HashMap, HashSet};
22+use std::rc::Rc;
1023
1124 use g1t_contracts::repos::{Blame, BlameRange, Commit, EntryKind, TreeEntry};
1225 use similar::{ChangeTag, TextDiff};
1629
1730 /// How far back the history is searched. Lines older than this are given to
1831 /// the oldest commit reached, and the answer is marked partial.
19−const MAX_COMMITS: u32 = 400;
32+pub const MAX_COMMITS: u32 = 400;
2033 /// Files larger than this are not blamed.
2134 const MAX_FILE_BYTES: usize = 512 * 1024;
35+/// Commits whose trees and texts are read together, ahead of the walk.
36+const READ_AHEAD: usize = 32;
37+/// Reads of the store one blame makes at most. Each costs up to three
38+/// subrequests (a Cache API look, the store, a Cache API put), so this keeps
39+/// a call well inside the 10,000 a Worker invocation may make.
40+pub const MAX_READS: u32 = 2_500;
41+
42+/// Where a blame is kept: by repository, the commit it was read at, and a
43+/// hash of the path. `v1`: a change to how lines are credited moves it.
44+pub fn cache_url(repo_id: &str, head: &str, path: &str) -> String {
45+ format!("https://blame.g1t.internal/v1/{repo_id}/{head}/{}", g1t_secrets::sha256_hex(path))
46+}
47+
48+/// How long a blame is kept. One that ran to its end (the first commit, or
49+/// [`MAX_COMMITS`]) is the same every time, so for good; one cut short by
50+/// time or reads could do better on a quieter store, so briefly.
51+pub fn cache_control(blamed: &Blamed) -> &'static str {
52+ if blamed.cut_short { "max-age=600" } else { "max-age=31536000, immutable" }
53+}
54+
55+/// A blame, and whether time or reads cut it short.
56+#[derive(Debug)]
57+pub struct Blamed {
58+ pub blame: Blame,
59+ pub cut_short: bool,
60+}
61+
62+/// A version of the file: its lines, each ended by a newline, as the diff
63+/// compares them, and how many there are.
64+struct Text {
65+ joined: String,
66+ lines: usize,
67+}
2268
69+impl Text {
70+ fn of(text: &str) -> Text {
71+ let mut joined = String::with_capacity(text.len() + 1);
72+ let mut lines = 0;
73+ // Every line ends in a newline, so a last line matches the same line elsewhere.
74+ for line in text.lines() {
75+ joined.push_str(line);
76+ joined.push('\n');
77+ lines += 1;
78+ }
79+ Text { joined, lines }
80+ }
81+}
82+
2383 /// Reads files out of commits, remembering trees and texts already read:
2484 /// most commits share most of their trees.
2585 struct Reader<'a, R: GitRepo> {
2686 repo: &'a R,
27− trees: HashMap<String, Vec<TreeEntry>>,
28− texts: HashMap<String, Option<String>>,
87+ trees: HashMap<String, Option<Vec<TreeEntry>>>,
88+ texts: HashMap<String, Option<Rc<Text>>>,
89+ /// Reads of the store so far, trees, texts and history.
90+ reads: u32,
2991 }
3092
3193 impl<'a, R: GitRepo> Reader<'a, R> {
94+ /// Reads the trees not read yet, all at once.
95+ async fn read_trees(&mut self, hashes: Vec<String>) -> Result<()> {
96+ let wanted: Vec<String> = hashes
97+ .into_iter()
98+ .filter(|hash| !self.trees.contains_key(hash))
99+ .collect::<HashSet<_>>()
100+ .into_iter()
101+ .collect();
102+ self.reads += wanted.len() as u32;
103+ let found = futures_util::future::join_all(wanted.iter().map(|hash| self.repo.read_tree(hash))).await;
104+ for (hash, tree) in wanted.into_iter().zip(found) {
105+ self.trees.insert(hash, tree?);
106+ }
107+ Ok(())
108+ }
109+
110+ /// Reads, level by level and each level at once, the trees on the way
111+ /// to `path` in each of `roots`.
112+ async fn read_paths(&mut self, roots: Vec<String>, path: &str) -> Result<()> {
113+ let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
114+ let mut level = roots;
115+ for (index, part) in parts.iter().enumerate() {
116+ self.read_trees(level.clone()).await?;
117+ if index + 1 == parts.len() {
118+ break;
119+ }
120+ level = level
121+ .iter()
122+ .filter_map(|hash| {
123+ let entries = self.trees.get(hash)?.as_ref()?;
124+ entries.iter().find(|entry| entry.name == *part && entry.kind == EntryKind::Tree).map(|entry| entry.hash.clone())
125+ })
126+ .collect();
127+ }
128+ Ok(())
129+ }
130+
131+ /// Reads the texts not read yet, all at once.
132+ async fn read_texts(&mut self, blobs: Vec<String>) -> Result<()> {
133+ let wanted: Vec<String> =
134+ blobs.into_iter().filter(|blob| !self.texts.contains_key(blob)).collect::<HashSet<_>>().into_iter().collect();
135+ self.reads += wanted.len() as u32;
136+ let found = futures_util::future::join_all(wanted.iter().map(|blob| self.repo.read_blob(blob))).await;
137+ for (blob, bytes) in wanted.into_iter().zip(found) {
138+ let text = bytes?
139+ .filter(|bytes| bytes.len() <= MAX_FILE_BYTES && !bytes.contains(&0))
140+ .and_then(|bytes| String::from_utf8(bytes).ok())
141+ .map(|text| Rc::new(Text::of(&text)));
142+ self.texts.insert(blob, text);
143+ }
144+ Ok(())
145+ }
146+
32147 /// The blob hash of `path` in the tree `root`, if it is a file there.
33148 async fn blob_at(&mut self, root: &str, path: &str) -> Result<Option<String>> {
34149 let mut tree = root.to_owned();
35150 let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
36151 for (index, part) in parts.iter().enumerate() {
37152 if !self.trees.contains_key(&tree) {
38− let entries = self.repo.read_tree(&tree).await?.unwrap_or_default();
39− self.trees.insert(tree.clone(), entries);
153+ self.read_trees(vec![tree.clone()]).await?;
40154 }
41− let Some(entry) = self.trees[&tree].iter().find(|entry| entry.name == *part) else {
155+ let Some(entry) = self.trees[&tree].as_ref().and_then(|entries| entries.iter().find(|entry| entry.name == *part)) else {
42156 return Ok(None);
43157 };
44158 let last = index == parts.len() - 1;
51165 Ok(None)
52166 }
53167
54− /// The lines of a blob, or `None` if it is binary or too large.
55− async fn lines(&mut self, blob: &str) -> Result<Option<Vec<String>>> {
168+ /// A version's text, or `None` if it is binary or too large.
169+ async fn text(&mut self, blob: &str) -> Result<Option<Rc<Text>>> {
56170 if !self.texts.contains_key(blob) {
57− let text = self
58− .repo
59− .read_blob(blob)
60− .await?
61− .filter(|bytes| bytes.len() <= MAX_FILE_BYTES && !bytes.contains(&0))
62− .and_then(|bytes| String::from_utf8(bytes).ok());
63− self.texts.insert(blob.to_owned(), text);
171+ self.read_texts(vec![blob.to_owned()]).await?;
64172 }
65− Ok(self.texts[blob]
66− .as_ref()
67− .map(|text| text.lines().map(str::to_owned).collect()))
173+ Ok(self.texts[blob].clone())
68174 }
175+
176+ /// Reads ahead for `commits`: the trees to `path` in each, then the
177+ /// texts of each version that differs from the next commit's (its
178+ /// parent on the walk), which are the ones the walk compares.
179+ async fn read_ahead(&mut self, commits: &[Commit], path: &str) -> Result<()> {
180+ self.read_paths(commits.iter().map(|commit| commit.tree_hash.clone()).collect(), path).await?;
181+ let mut blobs = Vec::with_capacity(commits.len());
182+ for commit in commits {
183+ blobs.push(self.blob_at(&commit.tree_hash, path).await?);
184+ }
185+ let mut wanted = Vec::new();
186+ for pair in blobs.windows(2) {
187+ if let [Some(child), Some(parent)] = pair
188+ && child != parent
189+ {
190+ wanted.push(child.clone());
191+ wanted.push(parent.clone());
192+ }
193+ }
194+ self.read_texts(wanted).await
195+ }
69196 }
70197
71198 /// For each line of `new`, the index of the same, unchanged line in `old`.
72−fn carried(old: &[String], new: &[String]) -> Vec<Option<usize>> {
73− // Every line ends in a newline, so a last line matches the same line elsewhere.
74− let text = |lines: &[String]| lines.iter().map(|line| format!("{line}\n")).collect::<String>();
75− let (before, after) = (text(old), text(new));
76− let diff = TextDiff::from_lines(&before, &after);
77− let mut map = vec![None; new.len()];
199+fn carried(old: &Text, new: &Text) -> Vec<Option<usize>> {
200+ let diff = TextDiff::from_lines(&old.joined, &new.joined);
201+ let mut map = vec![None; new.lines];
78202 for change in diff.iter_all_changes() {
79203 if change.tag() == ChangeTag::Equal
80204 && let (Some(o), Some(n)) = (change.old_index(), change.new_index())
81− && let Some(slot) = map.get_mut(n) {
82− *slot = Some(o);
83− }
205+ && let Some(slot) = map.get_mut(n)
206+ {
207+ *slot = Some(o);
208+ }
84209 }
85210 map
86211 }
87212
88213 /// Who last changed each line of `path` as of `head`. `None` if the file is
89−/// missing or not text.
214+/// missing or not text. The walk stops before more than `max_reads` reads of
215+/// the store, or once `out_of_time` says so (asked between read-aheads,
216+/// after the first).
90217 pub async fn blame<R: GitRepo>(
91218 repo: &R,
92219 head: &str,
93220 path: &str,
94−) -> Result<Option<Blame>> {
221+ out_of_time: &dyn Fn() -> bool,
222+ max_reads: u32,
223+) -> Result<Option<Blamed>> {
95224 let history = repo.log(head, MAX_COMMITS).await?;
96225 let Some(first) = history.first() else {
97226 return Ok(None);
100229 repo,
101230 trees: HashMap::new(),
102231 texts: HashMap::new(),
232+ reads: 1,
103233 };
234+ // The first read-ahead also finds the file at the head.
235+ let first_window = &history[..history.len().min(READ_AHEAD + 1)];
236+ reader.read_ahead(first_window, path).await?;
237+ let mut read_ahead_to = first_window.len();
104238 let Some(blob) = reader.blob_at(&first.tree_hash, path).await? else {
105239 return Ok(None);
106240 };
107− let Some(lines) = reader.lines(&blob).await? else {
241+ let Some(text) = reader.text(&blob).await? else {
108242 return Ok(None);
109243 };
110− let total = lines.len();
111− let by_hash: HashMap<&str, &Commit> =
112− history.iter().map(|commit| (commit.hash.as_str(), commit)).collect();
244+ let total = text.lines;
245+ let by_hash: HashMap<&str, &Commit> = history.iter().map(|commit| (commit.hash.as_str(), commit)).collect();
246+ let segments = path.split('/').filter(|part| !part.is_empty()).count();
247+ // The most one read-ahead reads: every tree on the way, two texts each.
248+ let window_reads = (READ_AHEAD * (segments + 2)) as u32;
113249
114250 // For each commit still to visit: (line in its version, line in the head's).
115251 let mut pending: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
116252 pending.insert(first.hash.clone(), (0..total).map(|line| (line, line)).collect());
117253 let mut owner: Vec<Option<String>> = vec![None; total];
118254 let mut partial = false;
255+ let mut cut_short = false;
119256
120257 // The log is newest first, so a commit is reached after its children.
121− for commit in &history {
258+ for (index, commit) in history.iter().enumerate() {
259+ if pending.is_empty() {
260+ break;
261+ }
262+ if index + 1 >= read_ahead_to && read_ahead_to < history.len() {
263+ if out_of_time() || reader.reads + window_reads > max_reads {
264+ cut_short = true;
265+ break;
266+ }
267+ let window = &history[index..history.len().min(index + READ_AHEAD + 1)];
268+ reader.read_ahead(window, path).await?;
269+ read_ahead_to = index + window.len();
270+ }
122271 let Some(mut lines_here) = pending.remove(&commit.hash) else {
123272 continue;
124273 };
127276 let Some(blob) = reader.blob_at(&commit.tree_hash, path).await? else {
128277 continue;
129278 };
130− let Some(text) = reader.lines(&blob).await? else {
131− continue;
132− };
279+ // Read only when a parent's version differs.
280+ let mut text: Option<Rc<Text>> = None;
133281 let mut unexplained = lines_here;
134282 for parent_hash in &commit.parents {
135283 if unexplained.is_empty() {
146294 let handed: Vec<(usize, usize)> = if parent_blob == blob {
147295 std::mem::take(&mut unexplained)
148296 } else {
149− let Some(parent_text) = reader.lines(&parent_blob).await? else {
297+ if text.is_none() {
298+ text = reader.text(&blob).await?;
299+ }
300+ let (Some(here), Some(parent_text)) = (&text, reader.text(&parent_blob).await?) else {
301+ // A version that is not text: its lines are taken as
302+ // this commit's.
150303 continue;
151304 };
152− let map = carried(&parent_text, &text);
305+ let map = carried(&parent_text, here);
153306 let (moved, kept): (Vec<_>, Vec<_>) = unexplained
154307 .into_iter()
155308 .partition(|(here, _)| map.get(*here).copied().flatten().is_some());
167320 owner[final_line] = Some(commit.hash.clone());
168321 }
169322 }
170− // Lines handed to commits older than the history read.
323+ // Lines the walk did not finish with: each goes to the oldest commit it
324+ // was followed to.
171325 if !pending.is_empty() {
172326 partial = true;
173− let oldest = history.last().map(|commit| commit.hash.clone());
174− for line in owner.iter_mut().filter(|line| line.is_none()) {
175− *line = oldest.clone();
327+ for (hash, lines) in pending {
328+ for (_, final_line) in lines {
329+ if owner[final_line].is_none() {
330+ owner[final_line] = Some(hash.clone());
331+ }
332+ }
176333 }
177334 }
178335
188345 let mut commits: Vec<Commit> = Vec::new();
189346 for range in &ranges {
190347 if !commits.iter().any(|commit| commit.hash == range.commit)
191− && let Some(commit) = by_hash.get(range.commit.as_str()) {
192− commits.push((*commit).clone());
193− }
348+ && let Some(commit) = by_hash.get(range.commit.as_str())
349+ {
350+ commits.push((*commit).clone());
351+ }
194352 }
195− Ok(Some(Blame {
196− head: first.hash.clone(),
197− ranges,
198− commits,
199− partial,
353+ Ok(Some(Blamed {
354+ blame: Blame {
355+ head: first.hash.clone(),
356+ ranges,
357+ commits,
358+ partial,
359+ },
360+ cut_short,
200361 }))
201362 }
202363
203364 #[cfg(test)]
204365 mod tests {
366+ use std::cell::{Cell, RefCell};
367+ use std::future::Future;
368+ use std::pin::pin;
369+ use std::task::{Context, Poll, Waker};
370+
371+ use g1t_contracts::repos::{Branch, GitAccess, Signature};
372+
205373 use super::*;
374+ use crate::store::Scope;
206375
207− fn lines(text: &str) -> Vec<String> {
208− text.lines().map(str::to_owned).collect()
376+ fn run<F: Future>(future: F) -> F::Output {
377+ match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
378+ Poll::Ready(output) => output,
379+ Poll::Pending => panic!("the fake store never waits"),
380+ }
209381 }
210382
383+ fn text(text: &str) -> Text {
384+ Text::of(text)
385+ }
386+
211387 #[test]
212388 fn unchanged_lines_are_carried_to_the_parent() {
213− let map = carried(&lines("a\nb\nc"), &lines("a\nx\nb\nc"));
389+ let map = carried(&text("a\nb\nc"), &text("a\nx\nb\nc"));
214390 assert_eq!(map, vec![Some(0), None, Some(1), Some(2)]);
215391 }
216392
217393 #[test]
218394 fn a_changed_line_is_not_carried() {
219− let map = carried(&lines("a\nb"), &lines("a\nB"));
395+ let map = carried(&text("a\nb"), &text("a\nB"));
220396 assert_eq!(map, vec![Some(0), None]);
221397 }
398+
399+ #[test]
400+ fn a_last_line_without_a_newline_matches_one_with() {
401+ let map = carried(&text("a\nb\n"), &text("a\nb"));
402+ assert_eq!(map, vec![Some(0), Some(1)]);
403+ }
404+
405+ /// A repository whose history is `src/f.txt` growing a line per commit.
406+ #[derive(Default)]
407+ struct Fake {
408+ trees: HashMap<String, Vec<TreeEntry>>,
409+ blobs: HashMap<String, String>,
410+ history: Vec<Commit>,
411+ trees_read: RefCell<Vec<String>>,
412+ blobs_read: RefCell<Vec<String>>,
413+ }
414+
415+ impl GitRepo for Fake {
416+ async fn access(&self, _scope: Scope) -> Result<GitAccess> {
417+ unimplemented!()
418+ }
419+ async fn branches(&self) -> Result<Vec<Branch>> {
420+ Ok(Vec::new())
421+ }
422+ async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> {
423+ let start = if git_ref == "main" { Some(0) } else { self.history.iter().position(|commit| commit.hash == git_ref) };
424+ Ok(start.map(|start| self.history.iter().skip(start).take(limit as usize).cloned().collect()).unwrap_or_default())
425+ }
426+ async fn parents(&self, _commit_hash: &str) -> Result<Option<Vec<String>>> {
427+ Ok(None)
428+ }
429+ async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
430+ self.trees_read.borrow_mut().push(tree_hash.to_owned());
431+ Ok(self.trees.get(tree_hash).cloned())
432+ }
433+ async fn read_blob(&self, blob_hash: &str) -> Result<Option<Vec<u8>>> {
434+ self.blobs_read.borrow_mut().push(blob_hash.to_owned());
435+ Ok(self.blobs.get(blob_hash).map(|text| text.as_bytes().to_vec()))
436+ }
437+ async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
438+ Ok(None)
439+ }
440+ async fn fork(&self, _target_key: &str, _default_branch_only: bool) -> Result<()> {
441+ Ok(())
442+ }
443+ }
444+
445+ fn commit(hash: &str, tree: &str, parent: Option<&str>) -> Commit {
446+ Commit {
447+ hash: hash.into(),
448+ tree_hash: tree.into(),
449+ message: format!("commit {hash}"),
450+ author: Signature { name: "a".into(), email: "a@example.com".into() },
451+ parents: parent.map(|p| vec![p.to_owned()]).unwrap_or_default(),
452+ authored_at: String::new(),
453+ }
454+ }
455+
456+ /// `commits` commits, newest first: commit n appends line n to
457+ /// `src/f.txt`. Every tenth commit only touches `README.md` beside it,
458+ /// so `src` stays the same tree there. The repository root changes in
459+ /// every commit.
460+ fn growing(commits: usize) -> Fake {
461+ let mut fake = Fake::default();
462+ let mut lines: Vec<String> = Vec::new();
463+ for n in 0..commits {
464+ let changes_file = n == 0 || n % 10 != 0;
465+ if changes_file {
466+ lines.push(format!("line {n}"));
467+ }
468+ let blob = format!("b{}", lines.len());
469+ fake.blobs.insert(blob.clone(), lines.join("\n"));
470+ let src = format!("s{}", lines.len());
471+ fake.trees.insert(src.clone(), vec![TreeEntry { name: "f.txt".into(), hash: blob, kind: EntryKind::Blob }]);
472+ let root = format!("r{n}");
473+ fake.trees.insert(
474+ root.clone(),
475+ vec![
476+ TreeEntry { name: "README.md".into(), hash: format!("readme{n}"), kind: EntryKind::Blob },
477+ TreeEntry { name: "src".into(), hash: src, kind: EntryKind::Tree },
478+ ],
479+ );
480+ let parent = (n > 0).then(|| format!("c{}", n - 1));
481+ fake.history.insert(0, commit(&format!("c{n}"), &root, parent.as_deref()));
482+ }
483+ fake
484+ }
485+
486+ fn owners(blame: &Blame) -> Vec<(u32, u32, String)> {
487+ blame.ranges.iter().map(|range| (range.start, range.end, range.commit.clone())).collect()
488+ }
489+
490+ #[test]
491+ fn each_line_goes_to_the_commit_that_wrote_it() {
492+ let fake = growing(5);
493+ let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().unwrap();
494+ assert!(!blamed.cut_short && !blamed.blame.partial);
495+ assert_eq!(blamed.blame.head, "c4");
496+ assert_eq!(
497+ owners(&blamed.blame),
498+ (0..5).map(|n| (n + 1, n + 1, format!("c{n}"))).collect::<Vec<_>>()
499+ );
500+ assert_eq!(blamed.blame.commits.len(), 5);
501+ }
502+
503+ #[test]
504+ fn a_missing_or_binary_file_has_no_blame() {
505+ let mut fake = growing(3);
506+ assert!(run(blame(&fake, "main", "src/nope.txt", &|| false, MAX_READS)).unwrap().is_none());
507+ assert!(run(blame(&fake, "unknown", "src/f.txt", &|| false, MAX_READS)).unwrap().is_none());
508+ fake.blobs.insert("b3".into(), "a\0b".into());
509+ assert!(run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().is_none());
510+ }
511+
512+ #[test]
513+ fn each_tree_and_text_is_read_once() {
514+ let fake = growing(60);
515+ let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().unwrap();
516+ assert!(!blamed.cut_short);
517+ let trees = fake.trees_read.borrow();
518+ assert_eq!(trees.len(), trees.iter().collect::<HashSet<_>>().len(), "no tree is read twice");
519+ let blobs = fake.blobs_read.borrow();
520+ assert_eq!(blobs.len(), blobs.iter().collect::<HashSet<_>>().len(), "no text is read twice");
521+ // Each root, and each distinct `src`, once.
522+ assert_eq!(trees.len(), 60 + 55);
523+ }
524+
525+ #[test]
526+ fn the_walk_stops_once_every_line_has_its_commit() {
527+ // The newest 30 commits rewrite the whole file, so nothing older is needed.
528+ let mut fake = growing(200);
529+ fake.blobs.insert("b181".into(), "only line".into());
530+ let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().unwrap();
531+ assert_eq!(owners(&blamed.blame), vec![(1, 1, "c199".to_owned())]);
532+ assert!(fake.trees_read.borrow().len() < 2 * (READ_AHEAD + 1) * 2, "read only the first read-ahead or two");
533+ }
534+
535+ #[test]
536+ fn out_of_time_stops_the_walk_and_credits_the_oldest_commit_reached() {
537+ let fake = growing(100);
538+ let asked = Cell::new(0);
539+ let out_of_time = || {
540+ asked.set(asked.get() + 1);
541+ true
542+ };
543+ let blamed = run(blame(&fake, "main", "src/f.txt", &out_of_time, MAX_READS)).unwrap().unwrap();
544+ assert!(blamed.cut_short && blamed.blame.partial);
545+ assert_eq!(asked.get(), 1, "asked once, after the first read-ahead");
546+ // The newest lines still have their own commits; the rest stop at
547+ // the last commit of the first read-ahead.
548+ let ranges = owners(&blamed.blame);
549+ assert_eq!(ranges.last().unwrap().2, "c99");
550+ let reached = format!("c{}", 99 - READ_AHEAD);
551+ assert_eq!(ranges[0].2, reached);
552+ assert_eq!(cache_control(&blamed), "max-age=600");
553+ }
554+
555+ #[test]
556+ fn reads_are_bounded() {
557+ let fake = growing(300);
558+ let max = 150;
559+ let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, max)).unwrap().unwrap();
560+ assert!(blamed.cut_short && blamed.blame.partial);
561+ let reads = fake.trees_read.borrow().len() + fake.blobs_read.borrow().len() + 1;
562+ assert!(reads <= max as usize, "{reads} reads, at most {max}");
563+ }
564+
565+ #[test]
566+ fn a_history_longer_than_is_read_is_partial_but_kept_for_good() {
567+ let fake = growing(MAX_COMMITS as usize + 20);
568+ let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, 100_000)).unwrap().unwrap();
569+ assert!(blamed.blame.partial && !blamed.cut_short);
570+ assert_eq!(cache_control(&blamed), "max-age=31536000, immutable");
571+ }
572+
573+ #[test]
574+ fn a_blame_is_kept_by_repository_commit_and_path() {
575+ let url = cache_url("repo_1", "abc", "src/f.txt");
576+ assert!(url.starts_with("https://blame.g1t.internal/v1/repo_1/abc/"));
577+ assert_ne!(url, cache_url("repo_1", "abc", "src/g.txt"));
578+ assert_ne!(url, cache_url("repo_1", "abd", "src/f.txt"));
579+ assert_ne!(url, cache_url("repo_2", "abc", "src/f.txt"));
580+ // A path never reaches the URL as it is.
581+ assert!(!cache_url("r", "h", "a/../b?x#y").contains(".."));
582+ }
222583 }
+5−0
130130 self.inner.read_blob(blob_hash).await
131131 }
132132
133+ /// The store's own, which keeps sizes by hash without reading bytes.
134+ async fn blob_size(&self, blob_hash: &str) -> Result<Option<u64>> {
135+ self.inner.blob_size(blob_hash).await
136+ }
137+
133138 async fn read_file(&self, git_ref: &str, path: &str) -> Result<Option<Vec<u8>>> {
134139 self.inner.read_file(self.resolve(git_ref), path).await
135140 }
+125−18
100100 const LAST_COMMITS_WALK_MS: u64 = 20_000;
101101 /// How long a path's history may walk when the caller gives no budget.
102102 const PATH_LOG_WALK_MS: u64 = 10_000;
103+/// How long a blame walks before it answers with what it has: a page waits
104+/// on it. A blame cut short is kept only briefly (blame.rs, `cache_control`).
105+const BLAME_WALK_MS: u64 = 6_000;
103106
104107 /// One path segment, percent-encoded for a cache key.
105108 fn urlencoding_segment(segment: &str) -> String {
978981 };
979982 let git = self.read_git(&repo).await?;
980983 let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone());
981− Ok(match blame::blame(&git, &git_ref, &a.file_path).await? {
982− Some(blame) => Outcome::Ok(blame),
983− None => not_found(),
984− })
984+ // The commit the ref names, so the answer can be kept by it: a
985+ // blame of a commit and path never changes. Most blames are of a
986+ // commit already (the site links files by commit, and crawlers
987+ // follow those links), which needs no read.
988+ let head = if store::is_commit_hash(&git_ref) {
989+ git_ref
990+ } else {
991+ match git.log(&git_ref, 1).await?.into_iter().next() {
992+ Some(head) => head.hash,
993+ None => return Ok(not_found()),
994+ }
995+ };
996+ let url = blame::cache_url(&repo.id, &head, &a.file_path);
997+ if let Ok(Some(mut kept)) = worker::Cache::default().get(url.as_str(), false).await
998+ && let Ok(found) = kept.json::<Blame>().await
999+ {
1000+ return Ok(Outcome::Ok(found));
1001+ }
1002+ let started = now_ms();
1003+ let out_of_time = move || now_ms().saturating_sub(started) > BLAME_WALK_MS;
1004+ let Some(blamed) = blame::blame(&git, &head, &a.file_path, &out_of_time, blame::MAX_READS).await? else {
1005+ return Ok(not_found());
1006+ };
1007+ // Kept once the answer has gone.
1008+ if let Ok(mut response) = worker::Response::from_json(&blamed.blame) {
1009+ let _ = response.headers_mut().set("cache-control", blame::cache_control(&blamed));
1010+ self.deferred.spawn(async move {
1011+ let _ = worker::Cache::default().put(url.as_str(), response).await;
1012+ });
1013+ }
1014+ Ok(Outcome::Ok(blamed.blame))
9851015 }
9861016
9871017 async fn log(&self, a: LogArgs) -> Result<Outcome<Vec<Commit>>> {
10351065 head.hash,
10361066 a.tree_path.split('/').map(urlencoding_segment).collect::<Vec<_>>().join("/")
10371067 );
1038− let cache = worker::Cache::default();
1039− if let Ok(Some(mut kept)) = cache.get(key.as_str(), false).await {
1040− if let Ok(found) = kept.json::<g1t_contracts::repos::LastCommits>().await {
1041− return Ok(Outcome::Ok(found));
1042− }
1068+ if let Ok(Some(mut kept)) = worker::Cache::default().get(key.as_str(), false).await
1069+ && let Ok(found) = kept.json::<g1t_contracts::repos::LastCommits>().await
1070+ {
1071+ return Ok(Outcome::Ok(found));
10431072 }
10441073 let memo = last_commits::Memo::new(&repo.id, &git_ref, &a.tree_path);
1045− let shared = self.shared.as_deref();
1046− let progress = memo.get(shared).await;
1074+ let shared = self.shared.clone();
1075+ let progress = memo.get(shared.as_deref()).await;
10471076 // Asked with a budget, the walk stops past it; without one, past
10481077 // LAST_COMMITS_WALK_MS, well before the caller's waitUntil ends.
10491078 let started = worker::Date::now().as_millis();
10501079 let budget = a.budget_ms.unwrap_or(LAST_COMMITS_WALK_MS);
10511080 let out_of_time = move || worker::Date::now().as_millis().saturating_sub(started) > budget;
10521081 let walk = last_commits::last_commits(&git, &head.hash, &a.tree_path, progress, &out_of_time, last_commits::MAX_READS).await?;
1082+ let settled = walk.progress.settled();
1083+ // What the walk found is kept once the answer has gone: the caller
1084+ // waits on neither write.
10531085 if walk.reads > 0 {
1054− memo.keep(shared, &walk.progress).await;
1086+ let progress = walk.progress.clone();
1087+ self.deferred.spawn(async move { memo.keep(shared.as_deref(), &progress).await });
10551088 }
1056− let settled = walk.progress.settled();
10571089 let found = g1t_contracts::repos::LastCommits { complete: walk.progress.complete(), entries: walk.progress.found };
1058− if !settled {
1059− return Ok(Outcome::Ok(found));
1060− }
1061− if let Ok(mut response) = worker::Response::from_json(&found) {
1090+ if settled && let Ok(mut response) = worker::Response::from_json(&found) {
10621091 let _ = response.headers_mut().set("cache-control", "max-age=604800");
1063− let _ = cache.put(key.as_str(), response).await;
1092+ self.deferred.spawn(async move {
1093+ let _ = worker::Cache::default().put(key.as_str(), response).await;
1094+ });
10641095 }
10651096 Ok(Outcome::Ok(found))
10661097 }
25772608 announced
25782609 }
25792610
2611+/// How long after a push's answer its About may still be reading: the
2612+/// waitUntil it runs in ends 30 s after the answer, and what it worked out
2613+/// must still be written.
2614+const PUSH_ABOUT_BY_MS: u64 = 24_000;
2615+/// Less time left than this after recording a push, and its About is left
2616+/// for the next view to start.
2617+const MIN_PUSH_ABOUT_MS: u64 = 5_000;
2618+
2619+/// How long a push's About may read, `elapsed` after the push's answer:
2620+/// what is left before [`PUSH_ABOUT_BY_MS`], or `None` when that is too
2621+/// little to be worth starting.
2622+fn push_about_budget(elapsed: u64) -> Option<u64> {
2623+ let left = PUSH_ABOUT_BY_MS.saturating_sub(elapsed);
2624+ (left >= MIN_PUSH_ABOUT_MS).then_some(left)
2625+}
2626+
2627+/// The repository whose About a push should work out: one that has an
2628+/// About, when the push moved its default branch.
2629+fn about_after_push(push: &PushDone) -> Option<Repo> {
2630+ let moved_default = push.pushed.iter().any(|pushed| pushed.branch() == Some(push.repo.default_branch.as_str()));
2631+ (stats::has_about(&push.repo) && moved_default).then(|| push.repo.clone())
2632+}
2633+
25802634 /// A push the store accepted, to be recorded once git has its answer.
25812635 struct PushDone {
25822636 repo: Repo,
26182672 return;
26192673 }
26202674 let env = env.clone();
2675+ // A push that moved the default branch has its About worked out
2676+ // now, so the next view finds it current rather than starting the
2677+ // run itself (about.rs, `refresh`).
2678+ let about = self.push.as_ref().and_then(about_after_push);
2679+ let started = now_ms();
26212680 ctx.wait_until(async move {
26222681 let repos = match service(&env) {
26232682 Ok(repos) => repos,
26332692 worker::console_error!("push not recorded: {error}");
26342693 }
26352694 repos.deferred.settle().await;
2695+ if let Some(repo) = about
2696+ && let Some(budget) = push_about_budget(now_ms().saturating_sub(started))
2697+ {
2698+ repos.refresh(&repo, budget).await;
2699+ repos.deferred.settle().await;
2700+ }
26362701 });
26372702 }
26382703 }
33063371 }
33073372
33083373 #[cfg(test)]
3374+mod push_about_tests {
3375+ use super::*;
3376+
3377+ fn push(refs: &[&str], fork_of: Option<&str>) -> PushDone {
3378+ let repo: Repo = serde_json::from_value(serde_json::json!({
3379+ "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false,
3380+ "ownerId": "usr_1", "defaultBranch": "main", "forkOf": fork_of, "createdAt": ""
3381+ }))
3382+ .unwrap();
3383+ PushDone {
3384+ repo,
3385+ pushed: refs.iter().map(|git_ref| git_http::Pushed { git_ref: (*git_ref).into(), before: None, after: "abc".into() }).collect(),
3386+ deleted: Vec::new(),
3387+ pack_bytes: 0,
3388+ actor: None,
3389+ unscanned: false,
3390+ caused_by_job: None,
3391+ credit: None,
3392+ }
3393+ }
3394+
3395+ #[test]
3396+ fn a_push_to_the_default_branch_works_out_the_about() {
3397+ assert!(about_after_push(&push(&["refs/heads/main"], None)).is_some());
3398+ assert!(about_after_push(&push(&["refs/heads/topic", "refs/heads/main"], None)).is_some());
3399+ assert!(about_after_push(&push(&["refs/heads/topic"], None)).is_none());
3400+ assert!(about_after_push(&push(&["refs/tags/main"], None)).is_none());
3401+ // A pull request's working copy has no About.
3402+ assert!(about_after_push(&push(&["refs/heads/main"], Some("rep_0"))).is_none());
3403+ }
3404+
3405+ #[test]
3406+ fn a_push_about_reads_only_within_what_is_left() {
3407+ assert_eq!(push_about_budget(0), Some(PUSH_ABOUT_BY_MS));
3408+ assert_eq!(push_about_budget(4_000), Some(PUSH_ABOUT_BY_MS - 4_000));
3409+ assert_eq!(push_about_budget(PUSH_ABOUT_BY_MS - MIN_PUSH_ABOUT_MS), Some(MIN_PUSH_ABOUT_MS));
3410+ assert_eq!(push_about_budget(PUSH_ABOUT_BY_MS - MIN_PUSH_ABOUT_MS + 1), None);
3411+ assert_eq!(push_about_budget(60_000), None);
3412+ }
3413+}
3414+
3415+#[cfg(test)]
33093416 mod push_to_create_tests {
33103417 use super::*;
33113418
+15−7
2929
3030 /// A run older than this is taken to have died.
3131 pub const LEASE_MS: u64 = 120_000;
32−/// How long a run reads before keeping what it has.
33−const BUDGET_MS: u64 = 22_000;
32+/// How long a run reads before keeping what it has, at most.
33+pub const BUDGET_MS: u64 = 22_000;
3434 /// The most commits of history read for contributors.
3535 pub const MAX_HISTORY: usize = 3_000;
36−/// Commits read at a time.
37−const LOG_PAGE: u32 = 200;
36+/// Commits read at a time. A history is kept by the commit it starts from
37+/// (store.rs), and a page from a new head is the few commits since the last
38+/// one, spliced onto the page kept from it; but each later page starts
39+/// wherever the one before it ended, which moves with every push, and so is
40+/// read from the store again. Pages as long as the store reads in one go
41+/// (as `MAX_ANCESTRY` in lib.rs does) leave most repositories one page: 200
42+/// made g1t's own history four, three of them read again after each push.
43+const LOG_PAGE: u32 = 1_000;
3844 /// The most blobs sized for languages.
3945 const MAX_SIZED: usize = 6_000;
4046 /// Blobs sized at once: each is a round trip to the store.
258264 }
259265
260266 /// Works out the About of `repo` at `head` (a commit). `identity` matches
261−/// authors' addresses to accounts.
262−pub async fn work_out<R: GitRepo>(git: &R, head: &str, tree: &str, identity: Option<&Fetcher>) -> Result<Worked> {
267+/// authors' addresses to accounts. Reads stop past `budget_ms` (at most
268+/// [`BUDGET_MS`]), leaving the answer partial.
269+pub async fn work_out<R: GitRepo>(git: &R, head: &str, tree: &str, identity: Option<&Fetcher>, budget_ms: u64) -> Result<Worked> {
263270 let started = now_ms();
264− let out_of_time = move || now_ms().saturating_sub(started) > BUDGET_MS;
271+ let budget_ms = budget_ms.min(BUDGET_MS);
272+ let out_of_time = move || now_ms().saturating_sub(started) > budget_ms;
265273 let never: Vec<String> = NEVER_READ.iter().map(|dir| (*dir).to_owned()).collect();
266274 let files = async {
267275 let (files, truncated) = crate::listing::changed(git, None, tree, &never, MAX_LISTED_FILES).await?;
+8−4
136136 { "name": "PACK_FILL_LIMIT", "namespace_id": "4301", "simple": { "limit": 30, "period": 60 } },
137137 { "name": "ANONYMOUS_FETCH_LIMIT", "namespace_id": "4302", "simple": { "limit": 120, "period": 60 } }
138138 ],
139− // Logs of every request for now, to trace the rare ~60 s stalls a tenth
140− // of requests kept missing. Put it back to 0.1 once they are found:
141− // every git request and page is one.
142− "observability": { "enabled": true, "head_sampling_rate": 1 }
139+ // Logs of a quarter of requests: every git request and page is one, so
140+ // keeping every one costs more than it tells, and a quarter still
141+ // catches a rare error within hours. Slow answers still show: an RPC
142+ // answer that took a second or more logs `{"rpc", "answered_ms"}`
143+ // (src/lib.rs, SLOW_RPC_MS) and a slow or partial About run
144+ // `{"about_run", "took_ms"}` (src/about.rs), so in the quarter kept they
145+ // can be counted by method without reading request lines.
146+ "observability": { "enabled": true, "head_sampling_rate": 0.25 }
143147 }
+5−4
2626 "queues": {
2727 "consumers": [{ "queue": "g1t-events-work", "max_batch_size": 100, "max_batch_timeout": 1, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }]
2828 },
29− // Logs of every request for now, to trace the rare ~60 s stalls a tenth
30− // of requests kept missing. Put it back to 0.1 once they are found:
31− // every issue and pull request page asks it.
32− "observability": { "enabled": true, "head_sampling_rate": 1 }
29+ // Logs of a quarter of requests: every issue and pull request page asks
30+ // it, so keeping every one costs more than it tells, and a quarter
31+ // still catches a rare error within hours. Repos, which it asks most,
32+ // logs its slow answers as fields (`answered_ms`, services/repos/src/lib.rs).
33+ "observability": { "enabled": true, "head_sampling_rate": 0.25 }
3334 }

Comments

Sign in to comment.