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.

g1t.sh

Commit

Job logs are deleted 90 days after their job finished. Every line a job printed was kept in the actions database for good (up to 4 MB a job), and a free workspace with self-hosted runners could fill it without limit, both a cost and a risk to the database's size cap that every repository shares. Once an hour the actions cron deletes the logs of up to 200 jobs, and earlier attempts, that finished longer ago than LOG_RETENTION_DAYS (90, a wrangler var; 0 keeps logs for good), and marks each so it is not looked at again (migration 0021 adds logs_purged_at and an index). The run, its jobs, steps, annotations and summaries stay. The Actions guide says so.

syntaqxcommitted Parent6a70726Browse files
7 files+197−10/7 viewed
+8−1
12581258 fold, errors and warnings are marked, and secrets are replaced with
12591259 `***`. The page keeps itself up to date while the run goes on.
12601260
1261+A job's log is kept for **90 days** after the job finished. After that its
1262+lines are deleted; the run, its jobs, their steps and conclusions,
1263+annotations, job summaries and artifacts (for their own retention) stay.
1264+Download a log you need to keep longer. On an installation of your own,
1265+`LOG_RETENTION_DAYS` in the actions service's settings changes the 90 days,
1266+and `0` keeps logs for good.
1267+
12611268 ### Live logs
12621269
12631270 While a job runs, its log reaches the page within about two seconds of
20062013 | `get_artifact` | `GET …/actions/artifacts/{artifact_id}` |
20072014 | `download_artifact` | `GET …/actions/artifacts/{artifact_id}/zip`: a `302` to a link good for 10 minutes |
20082015 | `delete_artifact` | `DELETE …/actions/artifacts/{artifact_id}` |
2009−| `artifact_retention`, `set_artifact_retention` | `GET` and `PUT …/actions/permissions/artifact-and-log-retention` with `days` (it sets artifacts' days only; logs are kept with their run) |
2016+| `artifact_retention`, `set_artifact_retention` | `GET` and `PUT …/actions/permissions/artifact-and-log-retention` with `days` (it sets artifacts' days only; logs are kept for 90 days) |
20102017
20112018 Secrets and variables have a tool of their own, `secret`:
20122019
+8−0
1+-- Job logs are deleted LOG_RETENTION_DAYS after their job finished
2+-- (src/retention.rs). A job, or an earlier attempt, whose log is gone is
3+-- marked so the hourly sweep does not look at it again; the job itself,
4+-- its steps and its summary stay.
5+ALTER TABLE jobs ADD COLUMN logs_purged_at TEXT;
6+ALTER TABLE job_attempts ADD COLUMN logs_purged_at TEXT;
7+CREATE INDEX IF NOT EXISTS jobs_logs_due ON jobs (finished_at) WHERE logs_purged_at IS NULL;
8+CREATE INDEX IF NOT EXISTS job_attempts_logs_due ON job_attempts (finished_at) WHERE logs_purged_at IS NULL;
+5−0
2929 mod run_artifacts;
3030 mod broker;
3131 mod cache;
32+mod limits;
3233 mod managed;
3334 mod payload;
3435 mod plan;
3637 mod reach;
3738 mod registry;
3839 mod rename;
40+mod retention;
3941 pub mod runtime;
4042 mod runners;
4143 mod settings;
124126 sealer: Option<Sealer>,
125127 /// Signs the toolkit's blob links (runtime.rs), from `ACTIONS_KEY`.
126128 blob_key: Option<String>,
129+ /// Log retention and free workspaces' caps, from wrangler vars (limits.rs).
130+ limits: limits::Limits,
127131 }
128132
129133 impl Actions {
142146 cache: env.bucket("ACTIONS_CACHE").ok(),
143147 sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())),
144148 blob_key: env.secret("ACTIONS_KEY").ok().map(|key| runtime::blob_key(&key.to_string())),
149+ limits: limits::Limits::from_env(env),
145150 })
146151 }
147152
+83−0
1+//! What g1t keeps and allows for a workspace, from wrangler vars, so an
2+//! installation of its own can change them:
3+//!
4+//! - `LOG_RETENTION_DAYS` (90): a finished job's log is deleted this many
5+//! days after it finished (retention.rs). `0` keeps logs for good.
6+//! - `FREE_ACTIONS_STORAGE_BYTES` (2 GB): what a workspace without the
7+//! plan may keep in artifacts and the cache together; past it, new
8+//! uploads are refused (run_artifacts.rs, cache.rs). `0` is no cap.
9+//! - `FREE_SELF_HOSTED_JOBS` (4): jobs a workspace without the plan may
10+//! have running on its self-hosted runners at once (plan.rs). `0` is no
11+//! cap.
12+
13+use worker::Env;
14+
15+pub const DEFAULT_LOG_RETENTION_DAYS: u64 = 90;
16+pub const DEFAULT_FREE_ACTIONS_STORAGE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
17+pub const DEFAULT_FREE_SELF_HOSTED_JOBS: u64 = 4;
18+
19+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20+pub struct Limits {
21+ pub log_retention_days: u64,
22+ pub free_actions_storage_bytes: u64,
23+ pub free_self_hosted_jobs: u64,
24+}
25+
26+impl Default for Limits {
27+ fn default() -> Self {
28+ Limits {
29+ log_retention_days: DEFAULT_LOG_RETENTION_DAYS,
30+ free_actions_storage_bytes: DEFAULT_FREE_ACTIONS_STORAGE_BYTES,
31+ free_self_hosted_jobs: DEFAULT_FREE_SELF_HOSTED_JOBS,
32+ }
33+ }
34+}
35+
36+/// A var as a whole number, or `default` when it is unset or not one.
37+pub fn number(raw: Option<String>, default: u64) -> u64 {
38+ raw.and_then(|raw| raw.trim().parse().ok()).unwrap_or(default)
39+}
40+
41+impl Limits {
42+ pub fn from_env(env: &Env) -> Limits {
43+ let var = |name: &str| env.var(name).ok().map(|value| value.to_string());
44+ Limits {
45+ log_retention_days: number(var("LOG_RETENTION_DAYS"), DEFAULT_LOG_RETENTION_DAYS),
46+ free_actions_storage_bytes: number(var("FREE_ACTIONS_STORAGE_BYTES"), DEFAULT_FREE_ACTIONS_STORAGE_BYTES),
47+ free_self_hosted_jobs: number(var("FREE_SELF_HOSTED_JOBS"), DEFAULT_FREE_SELF_HOSTED_JOBS),
48+ }
49+ }
50+}
51+
52+/// `2 GB`, `1.5 GB`, `500 MB`: a cap as people read it.
53+pub fn shown_bytes(bytes: u64) -> String {
54+ const GB: u64 = 1024 * 1024 * 1024;
55+ const MB: u64 = 1024 * 1024;
56+ if bytes >= GB {
57+ let tenths = (bytes * 10).div_ceil(GB);
58+ if tenths % 10 == 0 { format!("{} GB", tenths / 10) } else { format!("{}.{} GB", tenths / 10, tenths % 10) }
59+ } else {
60+ format!("{} MB", bytes.div_ceil(MB))
61+ }
62+}
63+
64+#[cfg(test)]
65+mod tests {
66+ use super::*;
67+
68+ #[test]
69+ fn vars_fall_back_to_their_defaults() {
70+ assert_eq!(number(None, 90), 90);
71+ assert_eq!(number(Some(" 30 ".into()), 90), 30);
72+ assert_eq!(number(Some("0".into()), 90), 0);
73+ assert_eq!(number(Some("ninety".into()), 90), 90);
74+ assert_eq!(Limits::default().free_actions_storage_bytes, 2 * 1024 * 1024 * 1024);
75+ }
76+
77+ #[test]
78+ fn caps_read_in_gigabytes_or_megabytes() {
79+ assert_eq!(shown_bytes(2 * 1024 * 1024 * 1024), "2 GB");
80+ assert_eq!(shown_bytes(1536 * 1024 * 1024), "1.5 GB");
81+ assert_eq!(shown_bytes(500 * 1024 * 1024), "500 MB");
82+ }
83+}
+6−0
28442844 worker::console_error!("actions: the blob parts' sweep failed: {error}");
28452845 }
28462846 }
2847+ // And logs of jobs that finished longer ago than LOG_RETENTION_DAYS.
2848+ if (now_ms / 60_000) % 60 == 17
2849+ && let Err(error) = self.sweep_logs(now_ms).await
2850+ {
2851+ worker::console_error!("actions: the logs' sweep failed: {error}");
2852+ }
28472853 self.start_queued().await
28482854 }
28492855 }
+78−0
1+//! Deleting old job logs (`LOG_RETENTION_DAYS`, limits.rs): once an hour,
2+//! the logs of jobs, and of earlier attempts, that finished longer ago than
3+//! that. A job keeps its steps, conclusion, annotations and summary; only
4+//! its log lines go, and the job is marked (`logs_purged_at`) so it is not
5+//! looked at again. A few hundred jobs a sweep, so a backlog drains over
6+//! hours instead of in one heavy query.
7+
8+use g1t_contracts::time::rfc3339;
9+use serde::Deserialize;
10+use worker::Result;
11+
12+use crate::Actions;
13+
14+/// Jobs (and earlier attempts) whose logs one sweep deletes, at most.
15+const PER_SWEEP: u32 = 200;
16+const DAY_MS: u64 = 24 * 60 * 60 * 1000;
17+
18+/// The moment before which a finished job's log is deleted; None keeps
19+/// every log.
20+pub fn cutoff(now_ms: u64, days: u64) -> Option<String> {
21+ (days > 0).then(|| rfc3339(now_ms.saturating_sub(days.saturating_mul(DAY_MS))))
22+}
23+
24+#[derive(Deserialize)]
25+struct Due {
26+ id: String,
27+ log_id: String,
28+}
29+
30+impl Actions {
31+ pub(crate) async fn sweep_logs(&self, now_ms: u64) -> Result<()> {
32+ let Some(before) = cutoff(now_ms, self.limits.log_retention_days) else {
33+ return Ok(());
34+ };
35+ for (table, log_id) in [("jobs", "id"), ("job_attempts", "log_id")] {
36+ let due = self
37+ .db
38+ .prepare(format!(
39+ "SELECT id, {log_id} AS log_id FROM {table}
40+ WHERE logs_purged_at IS NULL AND finished_at IS NOT NULL AND finished_at < ?
41+ LIMIT {PER_SWEEP}"
42+ ))
43+ .bind(&[before.as_str().into()])?
44+ .all()
45+ .await?
46+ .results::<Due>()?;
47+ if due.is_empty() {
48+ continue;
49+ }
50+ let at = rfc3339(now_ms);
51+ let mut statements = Vec::with_capacity(due.len() * 2);
52+ for job in &due {
53+ statements.push(self.db.prepare("DELETE FROM logs WHERE job_id = ?").bind(&[job.log_id.as_str().into()])?);
54+ statements.push(
55+ self.db
56+ .prepare(format!("UPDATE {table} SET logs_purged_at = ? WHERE id = ?"))
57+ .bind(&[at.as_str().into(), job.id.as_str().into()])?,
58+ );
59+ }
60+ self.db.batch(statements).await?;
61+ }
62+ Ok(())
63+ }
64+}
65+
66+#[cfg(test)]
67+mod tests {
68+ use super::*;
69+
70+ #[test]
71+ fn logs_older_than_the_retention_go_and_zero_keeps_them() {
72+ let now = 100 * DAY_MS;
73+ assert_eq!(cutoff(now, 90), Some(rfc3339(10 * DAY_MS)));
74+ assert_eq!(cutoff(now, 0), None);
75+ // A retention longer than time itself deletes nothing it should keep.
76+ assert_eq!(cutoff(now, 1000), Some(rfc3339(0)));
77+ }
78+}
+9−0
4141 "queues": {
4242 "consumers": [{ "queue": "g1t-events-actions", "max_batch_size": 10, "max_batch_timeout": 1, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }]
4343 },
44+ // What g1t keeps and allows (src/limits.rs): days a finished job's log is
45+ // kept (0: for good); what a workspace without the plan may keep in
46+ // artifacts and the cache together, in bytes; and jobs it may have
47+ // running on its self-hosted runners at once. 0 is no cap.
48+ "vars": {
49+ "LOG_RETENTION_DAYS": "90",
50+ "FREE_ACTIONS_STORAGE_BYTES": "2147483648",
51+ "FREE_SELF_HOSTED_JOBS": "4"
52+ },
4453 // Schedules, jobs waiting for room, and jobs whose runner went quiet.
4554 "triggers": { "crons": ["* * * * *"] },
4655 // Every log kept: few requests, and a failed job must be traceable.

Comments

Sign in to comment.