Catch up on work.migrated pages that never arrived
Search, the inbox, integrations and security rewrite old issue numbers when they hear work.migrated. A page that went to the dead-letter queue was lost; each now also pages legacy_item_map once the move is done, applies the same rewrite and remembers where it got to.
- G1T-Session:
- ses_01m56adwzvenhby748vvbn96c5
12 files+210−140/12 viewed
| 632 | 632 | Ok(true) | |
| 633 | 633 | } | |
| 634 | 634 | } | |
| 635 | + | ||
| 636 | + | /// Catching up on `work.migrated` (issues became work items, g1t #57). | |
| 637 | + | /// A service that rewrites what it kept by issue number when it hears | |
| 638 | + | /// `work.migrated` also pages through the work service's | |
| 639 | + | /// `legacy_item_map` on its cron, applying each page the same way, so a | |
| 640 | + | /// page that never arrived (one left in the dead-letter queue) is not lost. | |
| 641 | + | /// Where it got to is kept in the service's `work_catch_up` table (made by | |
| 642 | + | /// its migration, one row named `work.migrated`), and once the last page is | |
| 643 | + | /// applied it does nothing more. Every rewrite leaves what it already | |
| 644 | + | /// rewrote alone, so a page applied twice changes nothing. | |
| 645 | + | pub mod work_migrated { | |
| 646 | + | use std::future::Future; | |
| 647 | + | ||
| 648 | + | use g1t_contracts::events::WorkMigrated; | |
| 649 | + | use g1t_contracts::time::rfc3339; | |
| 650 | + | use g1t_contracts::work::{LegacyItemMapArgs, LegacyItemPage}; | |
| 651 | + | use serde::Deserialize; | |
| 652 | + | use worker::wasm_bindgen::JsValue; | |
| 653 | + | use worker::{D1Database, Fetcher, Result}; | |
| 654 | + | ||
| 655 | + | /// Moves asked for at a time, and pages applied in one run. | |
| 656 | + | pub const PAGE: u32 = 250; | |
| 657 | + | pub const PAGES_PER_RUN: u32 = 20; | |
| 658 | + | ||
| 659 | + | #[derive(Deserialize)] | |
| 660 | + | struct Row { | |
| 661 | + | #[serde(default)] | |
| 662 | + | cursor: Option<String>, | |
| 663 | + | page: u32, | |
| 664 | + | #[serde(default)] | |
| 665 | + | done_at: Option<String>, | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | /// One more stretch of the catch-up: each page read is handed to | |
| 669 | + | /// `apply` as the `work.migrated` it would have been. Whether it is done. | |
| 670 | + | /// It waits while the work service is still moving issues. | |
| 671 | + | pub async fn catch_up<F, Fut>(db: &D1Database, work: &Fetcher, mut apply: F) -> Result<bool> | |
| 672 | + | where | |
| 673 | + | F: FnMut(WorkMigrated) -> Fut, | |
| 674 | + | Fut: Future<Output = Result<()>>, | |
| 675 | + | { | |
| 676 | + | let row = db | |
| 677 | + | .prepare("SELECT cursor, page, done_at FROM work_catch_up WHERE name = 'work.migrated'") | |
| 678 | + | .first::<Row>(None) | |
| 679 | + | .await?; | |
| 680 | + | let Some(row) = row else { return Ok(true) }; | |
| 681 | + | if row.done_at.is_some() { | |
| 682 | + | return Ok(true); | |
| 683 | + | } | |
| 684 | + | let (mut cursor, mut page) = (row.cursor, row.page); | |
| 685 | + | for _ in 0..PAGES_PER_RUN { | |
| 686 | + | let found: LegacyItemPage = | |
| 687 | + | crate::call(work, "legacy_item_map", &LegacyItemMapArgs { after: cursor.clone(), limit: PAGE }).await?; | |
| 688 | + | if !found.moved { | |
| 689 | + | return Ok(false); | |
| 690 | + | } | |
| 691 | + | page += 1; | |
| 692 | + | let done = found.next.is_none(); | |
| 693 | + | if !found.items.is_empty() { | |
| 694 | + | apply(WorkMigrated { page, items: found.items, done }).await?; | |
| 695 | + | } | |
| 696 | + | cursor = found.next; | |
| 697 | + | let done_at = if done { JsValue::from(rfc3339(crate::now_ms())) } else { JsValue::NULL }; | |
| 698 | + | db.prepare("UPDATE work_catch_up SET cursor = ?1, page = ?2, done_at = ?3 WHERE name = 'work.migrated'") | |
| 699 | + | .bind(&[cursor.as_deref().map_or(JsValue::NULL, JsValue::from), page.into(), done_at])? | |
| 700 | + | .run() | |
| 701 | + | .await?; | |
| 702 | + | if done { | |
| 703 | + | return Ok(true); | |
| 704 | + | } | |
| 705 | + | } | |
| 706 | + | Ok(false) | |
| 707 | + | } | |
| 708 | + | } |
| 1 | + | -- Where catching up on `work.migrated` got to (g1t_kit::work_migrated): the | |
| 2 | + | -- cron pages through the work service's `legacy_item_map` and applies each | |
| 3 | + | -- page as the event would have, until the last. | |
| 4 | + | CREATE TABLE work_catch_up ( | |
| 5 | + | name TEXT PRIMARY KEY, | |
| 6 | + | cursor TEXT, | |
| 7 | + | page INTEGER NOT NULL DEFAULT 0, | |
| 8 | + | done_at TEXT | |
| 9 | + | ); | |
| 10 | + | INSERT INTO work_catch_up (name) VALUES ('work.migrated'); |
| 388 | 388 | /// any workspace keeps (`AUDIT_MAX_DAYS`, 400 days), then each workspace's | |
| 389 | 389 | /// entries older than its own plan keeps, as billing says (7 days free, 90 | |
| 390 | 390 | /// on the plan, or what staff set). Workspaces with nothing older than the | |
| 391 | − | /// shortest (`AUDIT_MIN_DAYS`, 7) are left alone. | |
| 391 | + | /// shortest (`AUDIT_MIN_DAYS`, 7) are left alone. Each cron also catches up | |
| 392 | + | /// on pages of `work.migrated` that never arrived (work_items.rs). | |
| 392 | 393 | #[event(scheduled)] | |
| 393 | 394 | async fn scheduled(event: worker::ScheduledEvent, env: Env, _ctx: worker::ScheduleContext) { | |
| 395 | + | if let (Ok(db), Ok(work)) = (env.d1("DB"), env.service("WORK")) { | |
| 396 | + | work_items::catch_up(&db, &work).await; | |
| 397 | + | } | |
| 394 | 398 | // The morning cron sends the security digests that are due, and nothing else. | |
| 395 | 399 | if event.cron() == security_digest::DIGEST_CRON { | |
| 396 | 400 | let (Ok(db), Ok(identity)) = (env.d1("DB"), env.service("IDENTITY")) else { return }; |
| 14 | 14 | //! and pull request events) name the work item and its number. | |
| 15 | 15 | //! | |
| 16 | 16 | //! Every statement leaves what it already rewrote alone, so a page | |
| 17 | − | //! delivered again, or out of order, changes nothing more. | |
| 17 | + | //! delivered again, or out of order, changes nothing more. The cron | |
| 18 | + | //! catches up on pages that never arrived ([`catch_up`]). | |
| 18 | 19 | ||
| 19 | 20 | use g1t_contracts::events::{Event, WorkMigrated}; | |
| 20 | − | use worker::{D1Database, D1PreparedStatement, Result}; | |
| 21 | + | use worker::{D1Database, D1PreparedStatement, Fetcher, Result}; | |
| 21 | 22 | ||
| 22 | 23 | /// The issue events of the log, by their old names. | |
| 23 | 24 | pub const ISSUE_EVENTS: [&str; 9] = [ | |
| ⋯ | |||
| 128 | 129 | if event.data["truncated"].as_bool() == Some(true) { | |
| 129 | 130 | worker::console_error!("work.migrated {} (page {}) was cut short: some issue numbers are left as they were", event.id, page.page); | |
| 130 | 131 | } | |
| 132 | + | page_statements(db, &event.id, &page) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// The statements for one page, recorded as `id`'s. | |
| 136 | + | fn page_statements(db: &D1Database, id: &str, page: &WorkMigrated) -> Result<Vec<D1PreparedStatement>> { | |
| 131 | 137 | if page.items.is_empty() { | |
| 132 | 138 | return Ok(Vec::new()); | |
| 133 | 139 | } | |
| 134 | 140 | let items = serde_json::to_string(&page.items)?; | |
| 135 | 141 | let issue_events = serde_json::to_string(&ISSUE_EVENTS)?; | |
| 136 | − | let values: [worker::wasm_bindgen::JsValue; 3] = [event.id.as_str().into(), items.as_str().into(), issue_events.as_str().into()]; | |
| 142 | + | let values: [worker::wasm_bindgen::JsValue; 3] = [id.into(), items.as_str().into(), issue_events.as_str().into()]; | |
| 137 | 143 | MOVED.iter().map(|sql| db.prepare(*sql).bind(&values[..bound(sql)])).collect() | |
| 138 | 144 | } | |
| 139 | 145 | ||
| 146 | + | /// The cron: pages of `work.migrated` that never arrived, read from the | |
| 147 | + | /// work service (`g1t_kit::work_migrated`) and applied as the event's | |
| 148 | + | /// would have been, each recorded as `catch-up:<page>`. | |
| 149 | + | pub async fn catch_up(db: &D1Database, work: &Fetcher) { | |
| 150 | + | let caught = g1t_kit::work_migrated::catch_up(db, work, |page| async move { | |
| 151 | + | let statements = page_statements(db, &format!("catch-up:{}", page.page), &page)?; | |
| 152 | + | if !statements.is_empty() { | |
| 153 | + | db.batch(statements).await?; | |
| 154 | + | } | |
| 155 | + | Ok(()) | |
| 156 | + | }) | |
| 157 | + | .await; | |
| 158 | + | if let Err(error) = caught { | |
| 159 | + | worker::console_error!("catching up on work.migrated: {error}"); | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 140 | 163 | #[cfg(test)] | |
| 141 | 164 | mod tests { | |
| 142 | 165 | use super::*; | |
| ⋯ | |||
| 247 | 270 | let first = items(&db); | |
| 248 | 271 | run(&db, "evt_9", &page()); | |
| 249 | 272 | assert_eq!(items(&db), first); | |
| 273 | + | // And again as the cron's catch-up reads it: nothing more either. | |
| 274 | + | let log = |db: &Connection| -> Vec<(String, serde_json::Value)> { | |
| 275 | + | ["evt_1", "evt_2", "evt_3", "evt_5", "evt_6", "evt_7"].iter().map(|id| logged(db, id)).collect() | |
| 276 | + | }; | |
| 277 | + | let logged_once = log(&db); | |
| 278 | + | run(&db, "catch-up:1", &page()); | |
| 279 | + | assert_eq!(items(&db), first); | |
| 280 | + | assert_eq!(log(&db), logged_once); | |
| 250 | 281 | assert_eq!( | |
| 251 | 282 | first, | |
| 252 | 283 | vec![ | |
| 1 | + | -- Where catching up on `work.migrated` got to (g1t_kit::work_migrated): the | |
| 2 | + | -- cron pages through the work service's `legacy_item_map` and applies each | |
| 3 | + | -- page as the event would have, until the last. | |
| 4 | + | CREATE TABLE work_catch_up ( | |
| 5 | + | name TEXT PRIMARY KEY, | |
| 6 | + | cursor TEXT, | |
| 7 | + | page INTEGER NOT NULL DEFAULT 0, | |
| 8 | + | done_at TEXT | |
| 9 | + | ); | |
| 10 | + | INSERT INTO work_catch_up (name) VALUES ('work.migrated'); |
| 1126 | 1126 | } | |
| 1127 | 1127 | ||
| 1128 | 1128 | /// Every minute: the sessions started on workspaces' Anthropic accounts | |
| 1129 | − | /// that are still going are followed (agent_sessions.rs). | |
| 1129 | + | /// that are still going are followed (agent_sessions.rs), and pages of | |
| 1130 | + | /// `work.migrated` that never arrived are caught up on (work_migrated.rs). | |
| 1130 | 1131 | #[event(scheduled)] | |
| 1131 | 1132 | async fn scheduled(_event: worker::ScheduledEvent, env: Env, _ctx: worker::ScheduleContext) { | |
| 1132 | 1133 | if let Ok(service) = Integrations::new(&env) { | |
| 1133 | 1134 | agent_sessions::scheduled(&service).await; | |
| 1134 | 1135 | summon::forget_old(&service).await; | |
| 1136 | + | work_migrated::catch_up(&service.db, &service.work).await; | |
| 1135 | 1137 | } | |
| 1136 | 1138 | } | |
| 1137 | 1139 |
| 3 | 3 | //! (`legacy_repo_id`, `legacy_number`, from migrations/0013_work_items.sql) | |
| 4 | 4 | //! take the work item's id, workspace and number, and lose the old pair. | |
| 5 | 5 | //! Each page is applied on its own, and a page seen again changes nothing. | |
| 6 | + | //! The cron catches up on pages that never arrived ([`catch_up`]). | |
| 6 | 7 | ||
| 7 | 8 | use g1t_contracts::events::{Event, WorkMigrated}; | |
| 8 | − | use worker::{D1Database, Result}; | |
| 9 | + | use g1t_contracts::work::MigratedItem; | |
| 10 | + | use worker::{D1Database, Fetcher, Result}; | |
| 9 | 11 | ||
| 10 | 12 | /// The page's moves, from `?1` (its `items` as JSON), as rows. | |
| 11 | 13 | const MOVES: &str = "SELECT json_extract(value, '$.repoId') AS repo_id, json_extract(value, '$.legacyNumber') AS legacy_number, | |
| ⋯ | |||
| 37 | 39 | let Ok(page) = serde_json::from_value::<WorkMigrated>(event.data.clone()) else { | |
| 38 | 40 | return Ok(true); | |
| 39 | 41 | }; | |
| 40 | − | if page.items.is_empty() { | |
| 41 | − | return Ok(true); | |
| 42 | + | apply(db, &page.items).await?; | |
| 43 | + | Ok(true) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Applies one page's moves. | |
| 47 | + | async fn apply(db: &D1Database, moves: &[MigratedItem]) -> Result<()> { | |
| 48 | + | if moves.is_empty() { | |
| 49 | + | return Ok(()); | |
| 42 | 50 | } | |
| 43 | − | let items = serde_json::to_string(&page.items)?; | |
| 51 | + | let items = serde_json::to_string(moves)?; | |
| 44 | 52 | let mut batch = Vec::new(); | |
| 45 | 53 | for sql in statements() { | |
| 46 | 54 | batch.push(db.prepare(&sql).bind(&[items.as_str().into()])?); | |
| 47 | 55 | } | |
| 48 | 56 | db.batch(batch).await?; | |
| 49 | − | Ok(true) | |
| 57 | + | Ok(()) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// The cron: pages of `work.migrated` that never arrived, read from the | |
| 61 | + | /// work service (`g1t_kit::work_migrated`), until the last. | |
| 62 | + | pub async fn catch_up(db: &D1Database, work: &Fetcher) { | |
| 63 | + | if let Err(error) = g1t_kit::work_migrated::catch_up(db, work, |page| async move { apply(db, &page.items).await }).await { | |
| 64 | + | worker::console_error!("catching up on work.migrated: {error}"); | |
| 65 | + | } | |
| 50 | 66 | } | |
| 51 | 67 | ||
| 52 | 68 | #[cfg(test)] | |
| ⋯ | |||
| 123 | 139 | MigratedItem { repo_id: "rep_api".into(), legacy_number: 4, workspace: "acme".into(), number: 32, item_id: "iss_api4".into() }, | |
| 124 | 140 | MigratedItem { repo_id: "rep_web".into(), legacy_number: 5, workspace: "acme".into(), number: 33, item_id: "iss_web5".into() }, | |
| 125 | 141 | ]; | |
| 126 | − | // Twice: a page seen again changes nothing. | |
| 127 | − | for _ in 0..2 { | |
| 142 | + | // Part of it as the event's page, then all of it twice as the | |
| 143 | + | // catch-up's pages: a move applied again changes nothing. | |
| 144 | + | for moves in [&moved[..1], &moved[..], &moved[..]] { | |
| 128 | 145 | for sql in statements() { | |
| 129 | − | db.execute(&sql, params![page(&moved)]).unwrap(); | |
| 146 | + | db.execute(&sql, params![page(moves)]).unwrap(); | |
| 130 | 147 | } | |
| 131 | 148 | } | |
| 132 | 149 | let link = |id: &str| -> (String, Option<String>, Option<u32>, Option<String>, Option<u32>) { | |
| ⋯ | |||
| 150 | 167 | ) | |
| 151 | 168 | .unwrap(); | |
| 152 | 169 | assert_eq!(left, 0); | |
| 170 | + | // Where the catch-up starts. | |
| 171 | + | let start: (Option<String>, u32, Option<String>) = db | |
| 172 | + | .query_row("SELECT cursor, page, done_at FROM work_catch_up WHERE name = 'work.migrated'", [], |row| { | |
| 173 | + | Ok((row.get(0)?, row.get(1)?, row.get(2)?)) | |
| 174 | + | }) | |
| 175 | + | .unwrap(); | |
| 176 | + | assert_eq!(start, (None, 0, None)); | |
| 153 | 177 | } | |
| 154 | 178 | } | |
| 1 | + | -- Where catching up on `work.migrated` got to (g1t_kit::work_migrated): the | |
| 2 | + | -- cron pages through the work service's `legacy_item_map` and applies each | |
| 3 | + | -- page as the event would have, until the last. | |
| 4 | + | CREATE TABLE work_catch_up ( | |
| 5 | + | name TEXT PRIMARY KEY, | |
| 6 | + | cursor TEXT, | |
| 7 | + | page INTEGER NOT NULL DEFAULT 0, | |
| 8 | + | done_at TEXT | |
| 9 | + | ); | |
| 10 | + | INSERT INTO work_catch_up (name) VALUES ('work.migrated'); |
| 280 | 280 | g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: repo_id.to_owned() }).await | |
| 281 | 281 | } | |
| 282 | 282 | ||
| 283 | + | /// Pages of `work.migrated` that never arrived, read from the work | |
| 284 | + | /// service (`g1t_kit::work_migrated`): each page's work items are read | |
| 285 | + | /// again, as the event's would have been. Whether it is done. | |
| 286 | + | pub async fn catch_up_on_moves(&self) -> Result<bool> { | |
| 287 | + | g1t_kit::work_migrated::catch_up(&self.db, &self.work, |page| async move { self.enqueue(migrated_jobs(&page)).await }).await | |
| 288 | + | } | |
| 289 | + | ||
| 283 | 290 | pub async fn enqueue(&self, jobs: Vec<Job>) -> Result<()> { | |
| 284 | 291 | let queue = self.env.queue("JOBS")?; | |
| 285 | 292 | for batch in jobs.chunks(100) { |
| 122 | 122 | if let Err(error) = search.ensure_backfill().await { | |
| 123 | 123 | worker::console_error!("search: could not start the backfill: {error}"); | |
| 124 | 124 | } | |
| 125 | − | // Pages parked while paused, looked for at most every few minutes. | |
| 125 | + | // Pages parked while paused, looked for at most every few minutes, | |
| 126 | + | // and pages of `work.migrated` that never arrived. | |
| 126 | 127 | if resume_due(g1t_kit::now_ms()) { | |
| 127 | 128 | match search.resume_parked().await { | |
| 128 | 129 | Ok(0) => {} | |
| 129 | 130 | Ok(found) => worker::console_log!("search: resumed {found} parked backfill pages"), | |
| 130 | 131 | Err(error) => worker::console_error!("search: could not resume parked backfill pages: {error}"), | |
| 131 | 132 | } | |
| 133 | + | if let Err(error) = search.catch_up_on_moves().await { | |
| 134 | + | worker::console_error!("search: catching up on work.migrated failed: {error}"); | |
| 135 | + | } | |
| 132 | 136 | } | |
| 133 | 137 | } | |
| 134 | 138 | for message in batch.messages()? { |
| 1 | + | -- Where catching up on `work.migrated` got to (g1t_kit::work_migrated): the | |
| 2 | + | -- cron pages through the work service's `legacy_item_map` and applies each | |
| 3 | + | -- page as the event would have, until the last. | |
| 4 | + | CREATE TABLE work_catch_up ( | |
| 5 | + | name TEXT PRIMARY KEY, | |
| 6 | + | cursor TEXT, | |
| 7 | + | page INTEGER NOT NULL DEFAULT 0, | |
| 8 | + | done_at TEXT | |
| 9 | + | ); | |
| 10 | + | INSERT INTO work_catch_up (name) VALUES ('work.migrated'); |
| 655 | 655 | Ok(()) | |
| 656 | 656 | } | |
| 657 | 657 | ||
| 658 | + | /// Pages of `work.migrated` that never arrived, read from the work | |
| 659 | + | /// service (`g1t_kit::work_migrated`), until the last. Whether it is done. | |
| 660 | + | async fn catch_up_on_moves(&self) -> Result<bool> { | |
| 661 | + | let store = &self.store; | |
| 662 | + | g1t_kit::work_migrated::catch_up(&store.db, &self.work, |page| async move { store.work_migrated(&page.items).await }).await | |
| 663 | + | } | |
| 664 | + | ||
| 658 | 665 | /// The sweep: continues history scans and scans of pushes that landed | |
| 659 | 666 | /// unscanned, catches security updates whose sandbox never pushed, and | |
| 660 | 667 | /// reads dependencies that have not been read for a day. | |
| ⋯ | |||
| 804 | 811 | if let Err(error) = security.sweep().await { | |
| 805 | 812 | worker::console_error!("security: the sweep failed: {error}"); | |
| 806 | 813 | } | |
| 814 | + | if let Err(error) = security.catch_up_on_moves().await { | |
| 815 | + | worker::console_error!("security: catching up on work.migrated failed: {error}"); | |
| 816 | + | } | |
| 807 | 817 | } | |
| 808 | 818 | Err(error) => worker::console_error!("security: could not start: {error}"), | |
| 809 | 819 | } | |
Comments
Sign in to comment.