Skip to content

Commit

The Actions cache takes sccache: protobuf Twirp, single-chunk uploads, ranged downloads, SAS-shaped upload links, and no full listing per saved entry

sccache's GitHub Actions backend (OpenDAL's ghac service, sccache 0.18) works against g1t's toolkit cache as it is: - On g1t it speaks the cache's older protocol (GITHUB_SERVER_URL is not github.com): GET cache?keys=&version=, POST caches, one PATCH of the whole entry, POST caches/{id}. That PATCH is a single chunk from 0 of any size, which chunk_part refused past 32 MB; a first chunk may now be up to 256 MB. - On the newer protocol it sends protobuf, not JSON. The three cache methods now read and answer application/protobuf (field numbers from results/api/v1/cache.proto; the tests hold the bytes prost writes with OpenDAL's own types). A key that already exists is Twirp's already_exists (409) to a protobuf client, which it takes as "someone else has it"; ok:false would read as a broken cache and leave sccache read-only. JSON clients still get ok:false, which the toolkit logs and goes on from. - OpenDAL treats an upload link as container/blob?SAS and refuses one without a query, so upload links carry ?sv=2024-11-04. - Blob downloads answer one byte range (Range or x-ms-range) with 206 and Content-Range, or 416 past the end. The toolkit's client fetches an entry over 128 MB in ranged segments, side by side, and wrote the whole blob into each segment's place when the range was ignored. - Committing an entry listed every entry of the repository to choose evictions. sccache saves an entry per compiled crate, thousands a build: now the sizes are summed and the entries listed only when over the quota. - Twirp's malformed and bad_route errors get Twirp's statuses (400, 404). The Actions guide gains "Caching Rust builds": sccache with the GitHub Actions backend on g1t, a pinned install, what is and is not cached, and why the default branch should run the workflow too.

syntaqxcommitted Parent2eb8556Browse files
3 files+500−250/3 viewed
+395−25
1010 //! `FinalizeCacheEntryUpload`, `GetCacheEntryDownloadURL`) and the
1111 //! artifacts' (`CreateArtifact`, `FinalizeArtifact`, `ListArtifacts`,
1212 //! `GetSignedArtifactURL`, `DeleteArtifact`). JSON, the toolkit's field
13−//! names.
13+//! names; the cache's methods also in protobuf (`application/protobuf`),
14+//! which other clients of the protocol send (sccache, through OpenDAL).
1415 //! - The cache's older protocol, at `{ACTIONS_CACHE_URL}_apis/artifactcache/…`,
1516 //! which the toolkit's client uses whenever the server it runs against is
16−//! not github.com: on g1t, that is the one it uses.
17+//! not github.com: on g1t, that is the one it uses, and sccache's too.
18+//! Its entries are sent in 32 MB chunks, or in one chunk of any size.
1719 //! - Blobs, at `/actions/toolkit/blobs/{token}`: the signed links those
18−//! hand out. Downloads are a plain GET. Uploads speak the part of Azure
19−//! Blob Storage's protocol the toolkit's client uses (Put Blob, Put
20−//! Block, Put Block List), mapped onto an R2 multipart upload: a block's
21−//! id ends in its index, which is its part's number.
20+//! hand out. Downloads are a GET, of the whole blob or of one byte range
21+//! (`Range`), as the toolkit's client fetches large entries in segments.
22+//! Uploads speak the part of Azure Blob Storage's protocol the toolkit's
23+//! client uses (Put Blob, Put Block, Put Block List), mapped onto an R2
24+//! multipart upload: a block's id ends in its index, which is its part's
25+//! number. An upload link carries a query, as an Azure SAS link does,
26+//! which clients that sign their requests with it need.
2227 //!
2328 //! Every call carries the job's runtime token; the actions service checks
2429 //! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there).
8590
8691 // ── Twirp ───────────────────────────────────────────────────────────────────
8792
93+/// Twirp's binary encoding.
94+const PROTOBUF: &str = "application/protobuf";
95+
96+/// Whether a request's `Content-Type` is Twirp's protobuf encoding.
97+fn is_protobuf(content_type: &str) -> bool {
98+ let kind = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
99+ kind == PROTOBUF || kind == "application/x-protobuf"
100+}
101+
102+/// The cache service's messages in protobuf, read into and written from the
103+/// JSON the handlers use (`results/api/v1/cache.proto`, field numbers as
104+/// there). Only what the cache's three methods carry: strings, a repeated
105+/// string, an int64 and a bool. `metadata` (field 1 of each request) is
106+/// skipped: the runtime token says whose cache it is.
107+mod proto {
108+ use serde_json::{Map, Value};
109+
110+ #[derive(Clone, Copy)]
111+ enum Kind {
112+ Text,
113+ Texts,
114+ Int,
115+ Bool,
116+ }
117+
118+ /// A message's fields: number, JSON name, kind.
119+ type Fields = &'static [(u64, &'static str, Kind)];
120+
121+ fn request_fields(method: &str) -> Option<Fields> {
122+ Some(match method {
123+ "CreateCacheEntry" => &[(2, "key", Kind::Text), (3, "version", Kind::Text)],
124+ "FinalizeCacheEntryUpload" => &[(2, "key", Kind::Text), (3, "size_bytes", Kind::Int), (4, "version", Kind::Text)],
125+ "GetCacheEntryDownloadURL" => &[(2, "key", Kind::Text), (3, "restore_keys", Kind::Texts), (4, "version", Kind::Text)],
126+ _ => return None,
127+ })
128+ }
129+
130+ fn response_fields(method: &str) -> Fields {
131+ match method {
132+ "CreateCacheEntry" => &[(1, "ok", Kind::Bool), (2, "signed_upload_url", Kind::Text), (3, "message", Kind::Text)],
133+ "FinalizeCacheEntryUpload" => &[(1, "ok", Kind::Bool), (2, "entry_id", Kind::Int), (3, "message", Kind::Text)],
134+ "GetCacheEntryDownloadURL" => &[(1, "ok", Kind::Bool), (2, "signed_download_url", Kind::Text), (3, "matched_key", Kind::Text)],
135+ _ => &[],
136+ }
137+ }
138+
139+ fn varint(bytes: &[u8], at: &mut usize) -> Option<u64> {
140+ let mut value = 0u64;
141+ for shift in (0..64).step_by(7) {
142+ let byte = *bytes.get(*at)?;
143+ *at += 1;
144+ value |= u64::from(byte & 0x7f) << shift;
145+ if byte & 0x80 == 0 {
146+ return Some(value);
147+ }
148+ }
149+ None
150+ }
151+
152+ fn put_varint(out: &mut Vec<u8>, mut value: u64) {
153+ while value >= 0x80 {
154+ out.push((value as u8 & 0x7f) | 0x80);
155+ value >>= 7;
156+ }
157+ out.push(value as u8);
158+ }
159+
160+ /// A request of `method` as JSON, or None when it is not one.
161+ pub fn request(method: &str, bytes: &[u8]) -> Option<Value> {
162+ let fields = request_fields(method)?;
163+ let mut out = Map::new();
164+ let mut at = 0;
165+ while at < bytes.len() {
166+ let tag = varint(bytes, &mut at)?;
167+ let (number, wire) = (tag >> 3, tag & 7);
168+ let known = fields.iter().find(|(n, _, _)| *n == number);
169+ match wire {
170+ 0 => {
171+ let value = varint(bytes, &mut at)?;
172+ if let Some((_, name, Kind::Int)) = known {
173+ // An int64 is sent as its two's complement.
174+ out.insert((*name).to_owned(), Value::String((value as i64).to_string()));
175+ }
176+ }
177+ 2 => {
178+ let length = usize::try_from(varint(bytes, &mut at)?).ok()?;
179+ let end = at.checked_add(length).filter(|end| *end <= bytes.len())?;
180+ let raw = &bytes[at..end];
181+ at = end;
182+ match known {
183+ Some((_, name, Kind::Text)) => {
184+ out.insert((*name).to_owned(), Value::String(String::from_utf8(raw.to_vec()).ok()?));
185+ }
186+ Some((_, name, Kind::Texts)) => {
187+ let text = Value::String(String::from_utf8(raw.to_vec()).ok()?);
188+ match out.entry((*name).to_owned()).or_insert_with(|| Value::Array(Vec::new())) {
189+ Value::Array(list) => list.push(text),
190+ _ => return None,
191+ }
192+ }
193+ _ => {}
194+ }
195+ }
196+ 1 => at = at.checked_add(8).filter(|end| *end <= bytes.len())?,
197+ 5 => at = at.checked_add(4).filter(|end| *end <= bytes.len())?,
198+ _ => return None,
199+ }
200+ }
201+ Some(Value::Object(out))
202+ }
203+
204+ /// A response of `method` from its JSON. Defaults are left out, as
205+ /// proto3 does.
206+ pub fn response(method: &str, value: &Value) -> Vec<u8> {
207+ let mut out = Vec::new();
208+ for (number, name, kind) in response_fields(method) {
209+ let field = &value[*name];
210+ match kind {
211+ Kind::Bool if field.as_bool() == Some(true) => {
212+ put_varint(&mut out, number << 3);
213+ put_varint(&mut out, 1);
214+ }
215+ Kind::Int => {
216+ let n = field.as_i64().or_else(|| field.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
217+ if n != 0 {
218+ put_varint(&mut out, number << 3);
219+ put_varint(&mut out, n as u64);
220+ }
221+ }
222+ Kind::Text => {
223+ let text = field.as_str().unwrap_or_default();
224+ if !text.is_empty() {
225+ put_varint(&mut out, (number << 3) | 2);
226+ put_varint(&mut out, text.len() as u64);
227+ out.extend_from_slice(text.as_bytes());
228+ }
229+ }
230+ _ => {}
231+ }
232+ }
233+ out
234+ }
235+}
236+
88237 /// A Twirp error: its code and message, at the status Twirp gives it.
89238 fn twirp_error(code: &str, message: &str) -> Result<Response> {
90239 let status = match code {
92241 "permission_denied" => 403,
93242 "not_found" => 404,
94243 "already_exists" => 409,
95− "invalid_argument" => 400,
244+ "invalid_argument" | "malformed" => 400,
245+ "bad_route" => 404,
96246 "failed_precondition" => 412,
97247 "resource_exhausted" => 429,
98248 _ => 500,
155305 })
156306 }
157307
308+/// The Azure Storage version g1t's blob links answer as.
309+const AZURE_VERSION: &str = "2024-11-04";
310+
311+/// An upload link: the blob's, with a query as an Azure SAS link has one.
312+/// A client that treats it as a container, a blob and a SAS token (OpenDAL,
313+/// which sccache uses) refuses a link without one; the token in the path
314+/// is what g1t checks.
315+pub fn upload_url(api: &str, blob: &str) -> String {
316+ format!("{}?sv={AZURE_VERSION}", blob_url(api, blob))
317+}
318+
158319 /// Starts an R2 upload for an entry the service reserved, and the signed
159320 /// link the toolkit sends it to.
160321 async fn start_upload(bucket: &Bucket, services: &Services, job: &str, token: &str, kind: &str, id: &str, object: &str) -> Result<Outcome<String>> {
167328 )
168329 .await?;
169330 Ok(match signed {
170− Outcome::Ok(blob) => Outcome::Ok(blob_url(&services.addresses.api, &blob)),
331+ Outcome::Ok(blob) => Outcome::Ok(upload_url(&services.addresses.api, &blob)),
171332 Outcome::Fail(refused) => Outcome::Fail(refused),
172333 })
173334 }
178339 let Some(job) = runtime_job(&token) else {
179340 return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token.");
180341 };
181− let body: Value = request.json().await.unwrap_or(Value::Null);
342+ // Twirp clients send JSON or protobuf, and are answered in kind.
343+ let binary = is_protobuf(&request.headers().get("content-type")?.unwrap_or_default());
344+ let body: Value = if binary {
345+ match proto::request(method, &request.bytes().await.unwrap_or_default()) {
346+ Some(body) => body,
347+ None => return twirp_error("malformed", "That is not a protobuf message this method takes."),
348+ }
349+ } else {
350+ request.json().await.unwrap_or(Value::Null)
351+ };
352+ let answer = |value: Value| -> Result<Response> {
353+ if binary {
354+ let mut response = Response::from_bytes(proto::response(method, &value))?;
355+ response.headers_mut().set("content-type", PROTOBUF)?;
356+ Ok(response)
357+ } else {
358+ Response::from_json(&value)
359+ }
360+ };
182361 let bucket = env.bucket("ACTIONS_CACHE")?;
183362 let actions = &services.actions;
184363 let (run, own_job) = backend_ids(&token);
201380 let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?;
202381 match found {
203382 Outcome::Ok(Some(CacheHit { key, blob: Some(blob), .. })) => {
204− Response::from_json(&json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key }))
383+ answer(json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key }))
205384 }
206− Outcome::Ok(_) => Response::from_json(&json!({ "ok": false, "signed_download_url": "", "matched_key": "" })),
385+ Outcome::Ok(_) => answer(json!({ "ok": false, "signed_download_url": "", "matched_key": "" })),
207386 Outcome::Fail(refused) => twirp_failure(&refused),
208387 }
209388 }
212391 let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?;
213392 let reserved = match reserved {
214393 Outcome::Ok(reserved) => reserved,
215− // The client warns with this and goes on, as for a key
216− // another job is saving.
217− Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
394+ // A key already saved, or being saved by another job, to a
395+ // protobuf client (OpenDAL's) is Twirp's `already_exists`
396+ // (409), which it takes as "someone else has it": sccache
397+ // then still writes. An `ok: false` would read as a broken
398+ // cache, and sccache would only read from it.
399+ Outcome::Fail(refused) if binary && refused.code == FailureCode::Conflict => return twirp_failure(&refused),
400+ // The toolkit's client logs this ("another job may be
401+ // creating this cache") and goes on.
402+ Outcome::Fail(refused) => return answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
218403 };
219404 match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? {
220− Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })),
221− Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
405+ Outcome::Ok(url) => answer(json!({ "ok": true, "signed_upload_url": url })),
406+ Outcome::Fail(refused) => answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
222407 }
223408 }
224409 (CACHE_SERVICE, "FinalizeCacheEntryUpload") => {
226411 let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?;
227412 let pending = match pending {
228413 Outcome::Ok(pending) => pending,
229− Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
414+ Outcome::Fail(refused) => return answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })),
230415 };
231416 let Some(object) = bucket.head(&pending.object).await? else {
232− return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." }));
417+ return answer(json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." }));
233418 };
234419 match commit_cache(&bucket, services, &job, &token, &pending.id, object.size()).await? {
235− Outcome::Ok(()) => Response::from_json(&json!({ "ok": true, "entry_id": pending.number.to_string() })),
236− Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
420+ Outcome::Ok(()) => answer(json!({ "ok": true, "entry_id": pending.number.to_string() })),
421+ Outcome::Fail(refused) => answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })),
237422 }
238423 }
239424 (ARTIFACT_SERVICE, "CreateArtifact") => {
321506 }
322507
323508 /// The part a chunk of the older protocol is, from its `Content-Range`:
324−/// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them.
509+/// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them. A first
510+/// chunk may be larger, up to `MAX_BLOCK_BYTES`: a client that sends an
511+/// entry in one request (sccache does) sends a single chunk from 0.
325512 pub fn chunk_part(range: &str) -> Option<(u16, u64)> {
326513 let range = range.trim().strip_prefix("bytes ")?;
327514 let (span, _) = range.split_once('/')?;
328515 let (start, end) = span.split_once('-')?;
329516 let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?);
330− if end < start || start % CACHE_PART_BYTES != 0 || end - start + 1 > CACHE_PART_BYTES {
517+ if end < start {
331518 return None;
332519 }
333− Some(((start / CACHE_PART_BYTES + 1) as u16, end - start + 1))
520+ let length = end - start + 1;
521+ if start == 0 && length <= MAX_BLOCK_BYTES {
522+ return Some((1, length));
523+ }
524+ if start % CACHE_PART_BYTES != 0 || length > CACHE_PART_BYTES {
525+ return None;
526+ }
527+ Some(((start / CACHE_PART_BYTES + 1) as u16, length))
334528 }
335529
530+/// The bytes a download's `Range` header asks for, out of `size`: first and
531+/// last, inclusive. None to send the whole blob (no header, or one this
532+/// does not read, such as several ranges); `Some(None)` when the range is
533+/// past the end (416).
534+pub fn byte_range(header: &str, size: u64) -> Option<Option<(u64, u64)>> {
535+ let spec = header.trim().strip_prefix("bytes=")?.trim();
536+ if spec.contains(',') {
537+ return None;
538+ }
539+ let (first, last) = spec.split_once('-')?;
540+ let (first, last) = (first.trim(), last.trim());
541+ let range = if first.is_empty() {
542+ // The last `n` bytes.
543+ let n: u64 = last.parse().ok()?;
544+ if n == 0 || size == 0 {
545+ return Some(None);
546+ }
547+ (size.saturating_sub(n), size - 1)
548+ } else {
549+ let first: u64 = first.parse().ok()?;
550+ let last: u64 = if last.is_empty() { u64::MAX } else { last.parse().ok()? };
551+ if last < first {
552+ return None;
553+ }
554+ if first >= size {
555+ return Some(None);
556+ }
557+ (first, last.min(size - 1))
558+ };
559+ Some(Some(range))
560+}
561+
336562 /// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it.
337563 pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
338564 let token = bearer(&request);
527753 headers.set("content-length", &size.to_string())?;
528754 headers.set("content-type", grant.content_type.as_deref().unwrap_or("application/octet-stream"))?;
529755 headers.set("x-ms-blob-type", "BlockBlob")?;
756+ headers.set("accept-ranges", "bytes")?;
530757 if let Some(name) = &grant.filename {
531758 headers.set("content-disposition", &format!("attachment; filename=\"{}\"", name.replace('"', "")))?;
532759 }
538765 headers(&mut response, object.size())?;
539766 return Ok(response);
540767 }
768+ // One byte range (`Range`, or Azure's `x-ms-range`): the
769+ // toolkit's client fetches a large entry in segments, side by
770+ // side, and writes each where its range says.
771+ let asked = match request.headers().get("x-ms-range")? {
772+ Some(range) => Some(range),
773+ None => request.headers().get("range")?,
774+ };
775+ if let Some(asked) = asked.filter(|r| !r.trim().is_empty()) {
776+ let Some(object) = bucket.head(&grant.object).await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
777+ let size = object.size();
778+ match byte_range(&asked, size) {
779+ Some(Some((first, last))) => {
780+ let length = last - first + 1;
781+ let Some(object) = bucket.get(&grant.object).range(worker::Range::OffsetWithLength { offset: first, length }).execute().await? else {
782+ return azure_error(404, "BlobNotFound", "It is gone.");
783+ };
784+ let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") };
785+ let mut response = Response::from_body(body.response_body()?)?.with_status(206);
786+ headers(&mut response, length)?;
787+ response.headers_mut().set("content-range", &format!("bytes {first}-{last}/{size}"))?;
788+ return Ok(response);
789+ }
790+ Some(None) => {
791+ let mut response = azure_error(416, "InvalidRange", "The range is past the end of the blob.")?;
792+ response.headers_mut().set("content-range", &format!("bytes */{size}"))?;
793+ return Ok(response);
794+ }
795+ // Not a range this reads: the whole blob.
796+ None => {}
797+ }
798+ }
541799 let Some(object) = bucket.get(&grant.object).execute().await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
542800 let size = object.size();
543801 let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") };
674932 let mb32 = CACHE_PART_BYTES;
675933 assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32 - 1)), Some((1, mb32)));
676934 assert_eq!(chunk_part(&format!("bytes {}-{}/*", mb32 * 2, mb32 * 2 + 99)), Some((3, 100)));
677− // Not on a chunk's boundary, too long, or not a range.
935+ // A whole entry in one chunk, as sccache (OpenDAL) sends it: its
936+ // check file is 13 bytes, a compiled crate can be well over 32 MB.
937+ assert_eq!(chunk_part("bytes 0-12/*"), Some((1, 13)));
938+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), Some((1, mb32 + 1)));
939+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES - 1)), Some((1, MAX_BLOCK_BYTES)));
940+ // Not on a chunk's boundary, too long, backwards, or not a range.
678941 assert_eq!(chunk_part("bytes 5-10/*"), None);
679− assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), None);
942+ assert_eq!(chunk_part(&format!("bytes {mb32}-{}/*", mb32 * 2)), None);
943+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES)), None);
944+ assert_eq!(chunk_part("bytes 10-5/*"), None);
680945 assert_eq!(chunk_part("0-10"), None);
681946 }
682947
948+ #[test]
949+ fn downloads_read_one_byte_range() {
950+ // OpenDAL's stat: the first byte.
951+ assert_eq!(byte_range("bytes=0-0", 100), Some(Some((0, 0))));
952+ // The toolkit's segments, the last one cut at the end.
953+ assert_eq!(byte_range("bytes=0-49", 100), Some(Some((0, 49))));
954+ assert_eq!(byte_range("bytes=50-999", 100), Some(Some((50, 99))));
955+ assert_eq!(byte_range("bytes=90-", 100), Some(Some((90, 99))));
956+ assert_eq!(byte_range("bytes=-10", 100), Some(Some((90, 99))));
957+ assert_eq!(byte_range("bytes=-1000", 100), Some(Some((0, 99))));
958+ // Past the end: 416.
959+ assert_eq!(byte_range("bytes=100-200", 100), Some(None));
960+ assert_eq!(byte_range("bytes=0-0", 0), Some(None));
961+ assert_eq!(byte_range("bytes=-0", 100), Some(None));
962+ // Not read: the whole blob.
963+ assert_eq!(byte_range("bytes=0-1,5-6", 100), None);
964+ assert_eq!(byte_range("bytes=9-3", 100), None);
965+ assert_eq!(byte_range("items=0-1", 100), None);
966+ assert_eq!(byte_range("bytes=a-b", 100), None);
967+ }
968+
969+ #[test]
970+ fn upload_links_carry_a_query_as_sas_links_do() {
971+ let url = upload_url("https://api.g1t.sh", "tok.sig");
972+ assert_eq!(url, "https://api.g1t.sh/actions/toolkit/blobs/tok.sig?sv=2024-11-04");
973+ // How OpenDAL reads a signed upload link: a container, a blob in
974+ // it, and a SAS query, all of which must be there.
975+ let rest = url.strip_prefix("https://api.g1t.sh/").unwrap();
976+ let (path, query) = rest.split_once('?').unwrap();
977+ let (container, blob) = path.split_once('/').unwrap();
978+ assert_eq!((container, blob, query), ("actions", "toolkit/blobs/tok.sig", "sv=2024-11-04"));
979+ }
980+
981+ /// A protobuf length-delimited field, as prost writes it.
982+ fn pb_text(number: u8, text: &str) -> Vec<u8> {
983+ let mut out = vec![(number << 3) | 2, text.len() as u8];
984+ out.extend_from_slice(text.as_bytes());
985+ out
986+ }
987+
988+ /// The requests sccache 0.18 sends (OpenDAL 0.58's `ghac` service, with
989+ /// prost): fields in number order, defaults left out, no metadata.
990+ #[test]
991+ fn twirp_reads_sccaches_protobuf_requests() {
992+ assert!(is_protobuf("application/protobuf"));
993+ assert!(is_protobuf("Application/Protobuf; charset=utf-8"));
994+ assert!(!is_protobuf("application/json"));
995+ assert!(!is_protobuf(""));
996+
997+ let key = "sccache/f/c/b/fcb0a1d2e3";
998+ let version = "sccache-v0.18.0";
999+ let create = [pb_text(2, key), pb_text(3, version)].concat();
1000+ let read = proto::request("CreateCacheEntry", &create).unwrap();
1001+ assert_eq!((text(&read, "key"), text(&read, "version")), (key.to_owned(), version.to_owned()));
1002+
1003+ // size_bytes is field 3, a varint: 300 is 0xac 0x02.
1004+ let finalize = [pb_text(2, key), vec![0x18, 0xac, 0x02], pb_text(4, version)].concat();
1005+ let read = proto::request("FinalizeCacheEntryUpload", &finalize).unwrap();
1006+ assert_eq!(number(&read, "size_bytes"), Some(300));
1007+ assert_eq!(text(&read, "version"), version);
1008+
1009+ let lookup = [pb_text(2, key), pb_text(4, version)].concat();
1010+ let read = proto::request("GetCacheEntryDownloadURL", &lookup).unwrap();
1011+ assert_eq!(text(&read, "key"), key);
1012+ assert!(field(&read, "restore_keys").is_null());
1013+ // The toolkit's own lookup, with metadata (skipped) and restore keys.
1014+ let metadata = vec![0x0a, 0x02, 0x08, 0x07];
1015+ let with_restore = [metadata, pb_text(2, "k"), pb_text(3, "k-"), pb_text(3, "x-"), pb_text(4, "v")].concat();
1016+ let read = proto::request("GetCacheEntryDownloadURL", &with_restore).unwrap();
1017+ assert_eq!(field(&read, "restore_keys"), &json!(["k-", "x-"]));
1018+ assert_eq!(text(&read, "version"), "v");
1019+
1020+ // The same three, as prost 0.14 encodes them with OpenDAL's
1021+ // generated types (the `ghac` crate, 0.3.0), byte for byte.
1022+ let recorded = |hex: &str| -> Vec<u8> { (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect() };
1023+ let prefix = "1218736363616368652f662f632f622f66636230613164326533";
1024+ let suffix = "0f736363616368652d76302e31382e30";
1025+ assert_eq!(recorded(&format!("{prefix}1a{suffix}")), create);
1026+ assert_eq!(recorded(&format!("{prefix}18ac0222{suffix}")), finalize);
1027+ assert_eq!(recorded(&format!("{prefix}22{suffix}")), lookup);
1028+
1029+ // Cut short, or not a cache method.
1030+ assert!(proto::request("CreateCacheEntry", &create[..create.len() - 1]).is_none());
1031+ assert!(proto::request("CreateCacheEntry", &[0x12, 0xff]).is_none());
1032+ assert!(proto::request("CreateArtifact", &create).is_none());
1033+ assert_eq!(proto::request("CreateCacheEntry", &[]), Some(json!({})));
1034+ }
1035+
1036+ #[test]
1037+ fn twirp_answers_in_protobuf_as_prost_reads_it() {
1038+ let url = "https://api.g1t.sh/actions/toolkit/blobs/t?sv=2024-11-04";
1039+ let created = proto::response("CreateCacheEntry", &json!({ "ok": true, "signed_upload_url": url }));
1040+ assert_eq!(created, [vec![0x08, 0x01], pb_text(2, url)].concat());
1041+ // Refused: ok false is the default, so only the message is sent.
1042+ let refused = proto::response("CreateCacheEntry", &json!({ "ok": false, "signed_upload_url": "", "message": "no" }));
1043+ assert_eq!(refused, pb_text(3, "no"));
1044+ // entry_id is an int64, given as a string in JSON.
1045+ let finalized = proto::response("FinalizeCacheEntryUpload", &json!({ "ok": true, "entry_id": "300" }));
1046+ assert_eq!(finalized, vec![0x08, 0x01, 0x10, 0xac, 0x02]);
1047+ let found = proto::response("GetCacheEntryDownloadURL", &json!({ "ok": true, "signed_download_url": "u", "matched_key": "k" }));
1048+ assert_eq!(found, [vec![0x08, 0x01], pb_text(2, "u"), pb_text(3, "k")].concat());
1049+ // A miss is an empty message: ok false.
1050+ assert!(proto::response("GetCacheEntryDownloadURL", &json!({ "ok": false, "signed_download_url": "", "matched_key": "" })).is_empty());
1051+ }
1052+
6831053 /// The toolkit's requests, as `@actions/cache` 4 and `@actions/artifact`
6841054 /// 2 send them (protobuf-ts, proto field names, no defaults).
6851055 #[test]
+72−0
4848 | `actions/upload-artifact`, `actions/download-artifact`, `actions/upload-artifact/merge` | The same inputs and outputs as version 4: `retention-days`, `overwrite`, `compression-level`, `include-hidden-files`, `!` exclusions, download by `pattern` with `merge-multiple`, and from another run with `run-id` and `github-token`. Up to 5 GiB each; see [artifacts](#artifacts). |
4949 | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository and branch, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). |
5050 | Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). |
51+| sccache with `SCCACHE_GHA_ENABLED` | The same: its entries go to the repository's cache. See [caching Rust builds](#caching-rust-builds). |
5152 | `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). |
5253 | `docker build`, `push`, `run`, `login`, `compose`, Buildx | The same, with a Docker Engine of the job's own. See [Docker](#docker). |
5354 | `services:` | The same: each service starts before the steps, health checks are waited for, and it is reached at `localhost` on its port and by its name. |
604605 `actions/download-artifact` and `actions/upload-artifact/merge` work:
605606 g1t runs those itself.
606607
608+## Caching Rust builds
609+
610+A checkout gives every file a new modification time, so Cargo compiles
611+your workspace's own crates again even when `target/` was restored from
612+the cache. [sccache](https://github.com/mozilla/sccache) caches each
613+compiler call by what it compiles (the source, the flags, the toolchain
614+and the dependencies), so a crate that did not change comes back from the
615+cache instead. Its GitHub Actions backend works on g1t as it is: it keeps
616+its entries in the repository's cache, under [the cache's](#the-cache)
617+limits and branch rules.
618+
619+1. Install sccache, pinned to a release and checked against its checksum.
620+2. Set `RUSTC_WRAPPER: sccache` and `SCCACHE_GHA_ENABLED: "true"`. The
621+ job's `ACTIONS_RUNTIME_TOKEN` and `ACTIONS_CACHE_URL` are already in
622+ every step's environment; no step needs to export them.
623+3. Set `CARGO_INCREMENTAL: "0"`: sccache does not cache incremental
624+ builds, and a fresh checkout gains nothing from them.
625+
626+```yaml
627+name: CI
628+on:
629+ pull_request:
630+ push:
631+ branches: [main]
632+
633+jobs:
634+ test:
635+ runs-on: ubuntu-latest
636+ env:
637+ RUSTC_WRAPPER: sccache
638+ SCCACHE_GHA_ENABLED: "true"
639+ CARGO_INCREMENTAL: "0"
640+ steps:
641+ - uses: actions/checkout@v5
642+ - name: Install sccache
643+ env:
644+ VERSION: v0.18.0
645+ SHA256: 45f1447fbe231e3037bde351ef70677dd212216c8d62ae7ca409fecc4d6acc89
646+ run: |
647+ name="sccache-$VERSION-x86_64-unknown-linux-musl"
648+ curl -fsSL -o "$RUNNER_TEMP/sccache.tar.gz" \
649+ "https://github.com/mozilla/sccache/releases/download/$VERSION/$name.tar.gz"
650+ echo "$SHA256 $RUNNER_TEMP/sccache.tar.gz" | sha256sum -c -
651+ tar -xzf "$RUNNER_TEMP/sccache.tar.gz" -C "$RUNNER_TEMP"
652+ install -m 0755 "$RUNNER_TEMP/$name/sccache" "$HOME/.cargo/bin/sccache"
653+ - run: cargo test --workspace --locked
654+ - name: sccache's hits and misses
655+ if: always()
656+ run: |
657+ echo '```' >> "$GITHUB_STEP_SUMMARY"
658+ sccache --show-stats | tee -a "$GITHUB_STEP_SUMMARY"
659+ echo '```' >> "$GITHUB_STEP_SUMMARY"
660+```
661+
662+What to expect:
663+
664+| | |
665+| --- | --- |
666+| What is cached | Every library crate (`rlib`): your workspace's crates and their dependencies. |
667+| What is compiled each time | What rustc links: binaries, `cdylib` crates, proc macros, build scripts and test harnesses. |
668+| Its entries | One per compiled crate, often thousands for a workspace, each a few KB to a few MB. They count toward the repository's 10 GiB like any other entry, and the ones not restored for 7 days are deleted. |
669+| Branches | A pull request reads what the default branch saved. Run the workflow on pushes to the default branch too, as above, or each pull request's first run starts with an empty cache. |
670+| Starting over | Set `SCCACHE_GHA_VERSION` to any new value. A new sccache release starts over by itself. |
671+| If the cache cannot be reached | sccache refuses to start. Set `SCCACHE_IGNORE_SERVER_IO_ERROR: "1"` to have rustc run without it instead. |
672+
673+sccache adds to `actions/cache` rather than replacing it: keep caching
674+`~/.cargo/registry/cache` and `target/` keyed by `Cargo.lock`, so Cargo
675+does not call rustc at all for dependencies that did not change, and
676+sccache answers the calls it still makes for your own crates.
677+`Swatinem/rust-cache` works the same way.
678+
607679 ## OIDC tokens
608680
609681 A job can prove which repository, branch and environment it runs for with
+33−0
8686 out
8787 }
8888
89+/// Whether a repository holding `total` bytes must evict to stay within
90+/// `quota`: what `to_evict` would take something from.
91+pub(crate) fn over_quota(total: u64, quota: u64) -> bool {
92+ total > quota
93+}
94+
8995 /// A month's GB-months from the bytes held each day so far: each day's
9096 /// bytes over 30 days.
9197 pub(crate) fn gb_months(days: &[u64]) -> f64 {
328334 if ready.is_none() {
329335 return Ok(fail(FailureCode::NotFound, "No upload of that entry is in progress."));
330336 }
337+ // What the repository holds, summed: only past the quota are its
338+ // entries listed to choose what to evict. A tool that saves an
339+ // entry per compiled file (sccache) commits thousands of small
340+ // entries a build, and listing them all on each was quadratic.
331341 #[derive(Deserialize)]
342+ struct Total {
343+ bytes: Option<f64>,
344+ }
345+ let total = self
346+ .db
347+ .prepare("SELECT SUM(size) AS bytes FROM cache_entries WHERE repo_id = ? AND status = 'ready'")
348+ .bind(&[job.repo_id.as_str().into()])?
349+ .first::<Total>(None)
350+ .await?
351+ .and_then(|t| t.bytes)
352+ .unwrap_or(0.0);
353+ if !over_quota(total as u64, CACHE_REPO_QUOTA_BYTES) {
354+ return Ok(Outcome::Ok(CacheCommitted { evicted: Vec::new() }));
355+ }
356+ #[derive(Deserialize)]
332357 struct Held {
333358 id: String,
334359 object: String,
510535 assert_eq!(to_evict(&held, "new", 7), ["old", "c"]);
511536 // The entry just saved stays, even when it alone is past the quota.
512537 assert_eq!(to_evict(&entries(&[("big", 20), ("a", 1)]), "big", 10), ["a"]);
538+ // Entries are listed only when the sum is over: at or under the
539+ // quota, nothing would be evicted.
540+ for (held, quota) in [(entries(&[("a", 4), ("b", 6)]), 10), (entries(&[("a", 1)]), 12)] {
541+ let total = held.iter().map(|(_, size)| size).sum();
542+ assert!(!over_quota(total, quota));
543+ assert!(to_evict(&held, "a", quota).is_empty());
544+ }
545+ assert!(over_quota(11, 10));
513546 }
514547
515548 #[test]