1use async_trait::async_trait;
4use futures::{StreamExt, future::BoxFuture, stream};
5use tracing::{info, warn};
6
7use crate::{
8 context::SharedContext,
9 engine::AsyncEngine,
10 error::CommitHashError,
11 polling::{
12 branch::BranchInfo,
13 error::{PollingError, handle_polling_error},
14 },
15 repository::{RepositoryError, branch::BranchRepository, trigger::TriggerRepository},
16};
17
18mod branch;
19mod error;
20pub mod git;
21
22pub struct PollingEngine {
25 pub ctx: SharedContext,
27}
28
29#[async_trait]
30impl AsyncEngine for PollingEngine {
31 async fn run(&self) {
32 polling_loop(self.ctx.clone()).await;
33 }
34}
35
36async fn polling_loop(ctx: SharedContext) {
38 loop {
39 tokio::select! {
40 res = poll_branches(&ctx) => {followup_poll(res, &ctx).await}
41 _ = ctx.token.cancelled() => break,
42 }
43 }
44 info!("Gracefully shutting down polling engine");
45}
46
47async fn poll_branches(ctx: &SharedContext) -> Result<(), PollingError> {
54 let updated_branches = gather_updated_branches(ctx).await?;
55 if updated_branches.is_empty() {
56 return Ok(());
57 }
58
59 let repository = ctx.repository.clone();
60 let shared_branches = std::sync::Arc::new(updated_branches);
61 ctx.repository
62 .run_in_transaction(|tx| {
63 execute_branch_updates(repository.clone(), shared_branches.clone(), tx)
64 })
65 .await?;
66
67 Ok(())
68}
69
70async fn gather_updated_branches(ctx: &SharedContext) -> Result<Vec<BranchInfo>, sqlx::Error> {
72 let branches = BranchRepository::get_all(ctx.repository.as_ref())
73 .await
74 .map_err(|e| match e {
75 crate::repository::RepositoryError::Database(e) => e,
76 _ => sqlx::Error::RowNotFound,
77 })?;
78
79 let branch_results = stream::iter(branches)
80 .map(|b| BranchInfo::new(b, ctx.git_fetcher.as_ref()))
81 .buffer_unordered(ctx.config.database.polling_db_buffer_size)
82 .collect::<Vec<Result<BranchInfo, CommitHashError>>>()
83 .await;
84
85 let errs = branch_results.iter().filter_map(|res| res.as_ref().err());
86 for e in errs {
87 warn!("{e}");
88 }
89
90 let updated_branches = branch_results
91 .into_iter()
92 .filter_map(|res| res.ok())
93 .filter(BranchInfo::has_updated)
94 .collect();
95 Ok(updated_branches)
96}
97
98fn execute_branch_updates<'a>(
100 repository: std::sync::Arc<crate::repository::SqliteRepository>,
101 shared_branches: std::sync::Arc<Vec<branch::BranchInfo>>,
102 tx: &'a mut sqlx::SqliteConnection,
103) -> BoxFuture<'a, Result<(), RepositoryError>> {
104 Box::pin(process_branches(repository, shared_branches, tx))
105}
106
107async fn process_branches(
109 repo: std::sync::Arc<crate::repository::SqliteRepository>,
110 shared_branches: std::sync::Arc<Vec<branch::BranchInfo>>,
111 tx: &mut sqlx::SqliteConnection,
112) -> Result<(), RepositoryError> {
113 let branches = std::sync::Arc::clone(&shared_branches);
114 for branch_info in branches.iter() {
115 repo.update_last_commit_hash_in_tx(branch_info.branch.id, &branch_info.latest_hash, tx)
116 .await?;
117
118 info!(
119 "New commit detected for branch {}. Hash: {}",
120 branch_info.branch.name, branch_info.latest_hash
121 );
122
123 repo.queue_triggers_for_branch(branch_info.branch.id, &branch_info.latest_hash, tx)
124 .await?;
125 }
126 Ok(())
127}
128
129async fn followup_poll(res: Result<(), PollingError>, ctx: &SharedContext) {
131 match res {
132 Ok(_) => tokio::select! {
133 _ = tokio::time::sleep(ctx.config.engine.polling_sleep) => {}
134 _ = ctx.token.cancelled() => {}
135 },
136 Err(e) => handle_polling_error(e, ctx).await,
137 }
138}
139
140#[cfg(test)]
141#[allow(
142 clippy::panic,
143 clippy::expect_used,
144 clippy::todo,
145 clippy::unimplemented,
146 clippy::indexing_slicing
147)]
148mod tests {
149 use crate::context::SharedContext;
150 use crate::domain::CommitHash;
151 use crate::polling::poll_branches;
152 use crate::test_utils::MockGitFetcher;
153 use std::sync::Arc;
154 use tokio_util::sync::CancellationToken;
155
156 #[tokio::test]
157 async fn test_poll_branches_updates_db_and_queues_trigger() {
158 let pool = crate::test_utils::create_test_db().await;
159
160 let hash = "a".repeat(40);
162 sqlx::query!(
163 "INSERT INTO branches (repo_url, name, last_commit_hash) VALUES (?, ?, ?)",
164 "https://github.com/owner/repo",
165 "main",
166 hash
167 )
168 .execute(&pool)
169 .await
170 .unwrap();
171 sqlx::query!("INSERT INTO subscriptions (branch_id, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?)",
173 1,
174 "org/target",
175 "dispatch",
176 1
177 )
178 .execute(&pool)
179 .await
180 .unwrap();
181
182 let mock_fetcher = Arc::new(MockGitFetcher {
183 hash: CommitHash::new("b".repeat(40)).unwrap(),
184 });
185
186 let ctx = SharedContext {
187 config: crate::test_utils::create_test_config(),
188 repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
189 db_pool: pool.clone(),
190 git_fetcher: mock_fetcher,
191 token: CancellationToken::new(),
192 };
193
194 poll_branches(&ctx).await.unwrap();
195
196 let branch = sqlx::query!("SELECT last_commit_hash FROM branches WHERE name = 'main'")
198 .fetch_one(&pool)
199 .await
200 .unwrap();
201 assert_eq!(branch.last_commit_hash, Some("b".repeat(40)));
202
203 let queued_event = sqlx::query!("SELECT branch_id, new_hash FROM trigger_queue")
205 .fetch_one(&pool)
206 .await
207 .unwrap();
208
209 assert_eq!(queued_event.branch_id, Some(1));
210 assert_eq!(queued_event.new_hash, Some("b".repeat(40)));
211 }
212
213 #[tokio::test]
214 async fn test_coalescing_of_trigger_events() {
215 let pool = crate::test_utils::create_test_db().await;
216
217 let hash = "a".repeat(40);
219 sqlx::query!(
220 "INSERT INTO branches (repo_url, name, last_commit_hash) VALUES (?, ?, ?)",
221 "https://github.com/owner/repo",
222 "main",
223 hash
224 )
225 .execute(&pool)
226 .await
227 .unwrap();
228 sqlx::query!("INSERT INTO subscriptions (branch_id, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?)",
230 1,
231 "org/target",
232 "dispatch",
233 1
234 )
235 .execute(&pool)
236 .await
237 .unwrap();
238
239 let ctx = SharedContext {
240 config: crate::test_utils::create_test_config(),
241 repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
242 db_pool: pool.clone(),
243 git_fetcher: Arc::new(crate::test_utils::MockGitFetcher {
244 hash: CommitHash::new("b".repeat(40)).unwrap(),
245 }),
246 token: CancellationToken::new(),
247 };
248
249 poll_branches(&ctx).await.unwrap();
251
252 let mock_fetcher = Arc::new(crate::test_utils::MockGitFetcher {
255 hash: CommitHash::new("c".repeat(40)).unwrap(),
256 });
257 let ctx = SharedContext {
258 config: ctx.config,
259 repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
260 db_pool: pool.clone(),
261 git_fetcher: mock_fetcher,
262 token: ctx.token,
263 };
264 poll_branches(&ctx).await.unwrap();
265
266 let queued_events = sqlx::query!("SELECT branch_id, new_hash FROM trigger_queue")
268 .fetch_all(&pool)
269 .await
270 .unwrap();
271
272 assert_eq!(queued_events.len(), 1);
273 assert_eq!(queued_events[0].branch_id, Some(1));
274 assert_eq!(queued_events[0].new_hash, Some("c".repeat(40)));
275 }
276}