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.
7 files+197−10/7 viewed
| 1258 | 1258 | fold, errors and warnings are marked, and secrets are replaced with | |
| 1259 | 1259 | `***`. The page keeps itself up to date while the run goes on. | |
| 1260 | 1260 | ||
| 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 | + | ||
| 1261 | 1268 | ### Live logs | |
| 1262 | 1269 | ||
| 1263 | 1270 | While a job runs, its log reaches the page within about two seconds of | |
| ⋯ | |||
| 2006 | 2013 | | `get_artifact` | `GET …/actions/artifacts/{artifact_id}` | | |
| 2007 | 2014 | | `download_artifact` | `GET …/actions/artifacts/{artifact_id}/zip`: a `302` to a link good for 10 minutes | | |
| 2008 | 2015 | | `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) | | |
| 2010 | 2017 | ||
| 2011 | 2018 | Secrets and variables have a tool of their own, `secret`: | |
| 2012 | 2019 | ||
| 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; |
| 29 | 29 | mod run_artifacts; | |
| 30 | 30 | mod broker; | |
| 31 | 31 | mod cache; | |
| 32 | + | mod limits; | |
| 32 | 33 | mod managed; | |
| 33 | 34 | mod payload; | |
| 34 | 35 | mod plan; | |
| ⋯ | |||
| 36 | 37 | mod reach; | |
| 37 | 38 | mod registry; | |
| 38 | 39 | mod rename; | |
| 40 | + | mod retention; | |
| 39 | 41 | pub mod runtime; | |
| 40 | 42 | mod runners; | |
| 41 | 43 | mod settings; | |
| ⋯ | |||
| 124 | 126 | sealer: Option<Sealer>, | |
| 125 | 127 | /// Signs the toolkit's blob links (runtime.rs), from `ACTIONS_KEY`. | |
| 126 | 128 | blob_key: Option<String>, | |
| 129 | + | /// Log retention and free workspaces' caps, from wrangler vars (limits.rs). | |
| 130 | + | limits: limits::Limits, | |
| 127 | 131 | } | |
| 128 | 132 | ||
| 129 | 133 | impl Actions { | |
| ⋯ | |||
| 142 | 146 | cache: env.bucket("ACTIONS_CACHE").ok(), | |
| 143 | 147 | sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())), | |
| 144 | 148 | blob_key: env.secret("ACTIONS_KEY").ok().map(|key| runtime::blob_key(&key.to_string())), | |
| 149 | + | limits: limits::Limits::from_env(env), | |
| 145 | 150 | }) | |
| 146 | 151 | } | |
| 147 | 152 | ||
| 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 | + | } |
| 2844 | 2844 | worker::console_error!("actions: the blob parts' sweep failed: {error}"); | |
| 2845 | 2845 | } | |
| 2846 | 2846 | } | |
| 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 | + | } | |
| 2847 | 2853 | self.start_queued().await | |
| 2848 | 2854 | } | |
| 2849 | 2855 | } |
| 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 | + | } |
| 41 | 41 | "queues": { | |
| 42 | 42 | "consumers": [{ "queue": "g1t-events-actions", "max_batch_size": 10, "max_batch_timeout": 1, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }] | |
| 43 | 43 | }, | |
| 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 | + | }, | |
| 44 | 53 | // Schedules, jobs waiting for room, and jobs whose runner went quiet. | |
| 45 | 54 | "triggers": { "crons": ["* * * * *"] }, | |
| 46 | 55 | // Every log kept: few requests, and a failed job must be traceable. |
Comments
Sign in to comment.