1use std::future::ready;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use chrono::{DateTime, Utc};
6use serde::Deserialize;
7use sha2::{Digest, Sha256};
8use uuid::Uuid;
9
10use crate::db::{
11 self, comment_refresh_cutoff, recent_cutoff, Database, HydrationMode, HydrationPolicy,
12 HydrationResource, HydrationStatus, IndexUpsertOutcome, IssueIndexEntry,
13};
14
15use super::pagination::{operation_error, LinearErrorKind};
16use super::{
17 paginate, ConnectionPage, LinearClient, LinearIssue, LinearOperation, PageInfo, SingleIssueData,
18};
19
20const INDEX_OVERLAP_SECONDS: i64 = 300;
21const INDEX_SAFETY_LAG_SECONDS: i64 = 2;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SyncProgressPhase {
25 IndexingIssues,
26 IndexComplete,
27 HydratingIssueDetails,
28 HydratingLabels,
29 HydratingRelations,
30 HydratingComments,
31 WaitingForRateLimitRetry,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct SyncProgressUpdate {
36 pub phase: SyncProgressPhase,
37 pub completed: usize,
38 pub total: Option<usize>,
39 pub issue_id: Option<String>,
40}
41
42pub type SyncProgressCallback<'a> = dyn Fn(SyncProgressUpdate) + Send + Sync + 'a;
43
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct IssueIndexSyncResult {
46 pub indexed: usize,
47 pub inserted: usize,
48 pub updated: usize,
49 pub unchanged: usize,
50 pub queued_for_hydration: usize,
51 pub committed_checkpoint: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct IssueHydrationResult {
56 pub issue_id: String,
57 pub status: HydrationStatus,
58 pub hydrated_resources: usize,
59 pub retryable_failures: usize,
60 pub permanent_failures: usize,
61 pub rate_limited: bool,
62 pub resources: Vec<db::HydrationResourceState>,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct HydrationBatchResult {
67 pub requested: usize,
68 pub hydrated: usize,
69 pub partial: usize,
70 pub deferred: usize,
71 pub retryable_failures: usize,
72 pub permanent_failures: usize,
73 pub required_failures: usize,
74 pub comment_failures: usize,
75 pub rate_limited: bool,
76}
77
78#[derive(Debug, Deserialize)]
79struct IndexIssuesData {
80 issues: IndexIssueConnection,
81}
82
83#[derive(Debug, Deserialize)]
84struct IndexIssueConnection {
85 nodes: Vec<LinearIssueIndex>,
86 #[serde(rename = "pageInfo")]
87 page_info: PageInfo,
88}
89
90#[derive(Debug, Deserialize)]
91struct LinearIssueIndex {
92 id: String,
93 identifier: String,
94 title: String,
95 url: String,
96 team: super::LinearTeam,
97 state: super::LinearState,
98 #[serde(rename = "createdAt")]
99 created_at: String,
100 #[serde(rename = "updatedAt")]
101 updated_at: String,
102 #[serde(rename = "archivedAt", default)]
103 archived_at: Option<String>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107enum ResourceRun {
108 Hydrated,
109 Retryable { rate_limited: bool },
110 Permanent,
111}
112
113impl LinearClient {
114 pub async fn sync_team_index(
118 &self,
119 db: &Database,
120 team_key: &str,
121 workspace_id: &str,
122 full: bool,
123 progress: Option<&SyncProgressCallback<'_>>,
124 ) -> Result<IssueIndexSyncResult> {
125 let upper = Utc::now() - chrono::Duration::seconds(INDEX_SAFETY_LAG_SECONDS);
126 self.sync_team_index_window(db, team_key, workspace_id, full, upper, progress)
127 .await
128 }
129
130 pub(crate) async fn sync_team_index_window(
131 &self,
132 db: &Database,
133 team_key: &str,
134 workspace_id: &str,
135 full: bool,
136 upper: DateTime<Utc>,
137 progress: Option<&SyncProgressCallback<'_>>,
138 ) -> Result<IssueIndexSyncResult> {
139 let committed = db.get_synced_through_at(workspace_id, team_key)?;
140 let lower = if full {
141 None
142 } else {
143 committed
144 .as_deref()
145 .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
146 .map(|value| {
147 (value.with_timezone(&Utc) - chrono::Duration::seconds(INDEX_OVERLAP_SECONDS))
148 .to_rfc3339()
149 })
150 };
151 let upper = upper.to_rfc3339();
152 let sync_token = Uuid::new_v4().to_string();
153 let mut result = IssueIndexSyncResult {
154 committed_checkpoint: upper.clone(),
155 ..Default::default()
156 };
157
158 db.mark_sync_family_running(
159 workspace_id,
160 team_key,
161 "issue index",
162 None,
163 Some(self.sync_query_config().page_size(LinearOperation::Issues)),
164 &sync_token,
165 )?;
166
167 let pagination = paginate(
168 self.sync_query_config(),
169 LinearOperation::Issues,
170 Some(team_key.to_string()),
171 |request| {
172 let lower = lower.clone();
173 let upper = upper.clone();
174 async move {
175 self.fetch_issue_index_page(
176 team_key,
177 request.cursor.as_deref(),
178 lower.as_deref(),
179 &upper,
180 request.page_size,
181 )
182 .await
183 }
184 },
185 |issues, context| {
186 let persisted = (|| {
187 for issue in issues {
188 match db.upsert_issue_index(
189 &IssueIndexEntry {
190 id: issue.id,
191 identifier: issue.identifier,
192 team_key: issue.team.key,
193 title: issue.title,
194 state_name: issue.state.name,
195 state_type: issue.state.state_type,
196 created_at: issue.created_at,
197 updated_at: issue.updated_at,
198 archived_at: issue.archived_at,
199 url: issue.url,
200 },
201 workspace_id,
202 &sync_token,
203 )? {
204 IndexUpsertOutcome::Inserted => result.inserted += 1,
205 IndexUpsertOutcome::Updated => result.updated += 1,
206 IndexUpsertOutcome::Unchanged => result.unchanged += 1,
207 }
208 result.indexed += 1;
209 }
210 result.queued_for_hydration = result.inserted + result.updated;
211 db.mark_sync_family_running(
212 workspace_id,
213 team_key,
214 "issue index",
215 context.cursor.as_deref(),
216 Some(context.page_size),
217 &sync_token,
218 )?;
219 if let Some(callback) = progress {
220 callback(SyncProgressUpdate {
221 phase: SyncProgressPhase::IndexingIssues,
222 completed: result.indexed,
223 total: None,
224 issue_id: None,
225 });
226 }
227 Ok(())
228 })();
229 ready(persisted)
230 },
231 |issue| issue.id.clone(),
232 |event| self.observe_sync_event(event),
233 )
234 .await;
235
236 if let Err(error) = pagination {
237 let message = self.redacted_error_message(&error);
238 db.mark_sync_family_failed(
239 workspace_id,
240 team_key,
241 "issue index",
242 &sync_token,
243 &message,
244 )?;
245 return Err(error);
246 }
247
248 if full {
249 db.reconcile_full_issue_index(workspace_id, team_key, &sync_token, &upper)?;
250 }
251 db.mark_sync_family_complete(
252 workspace_id,
253 team_key,
254 "issue index",
255 Some(self.sync_query_config().page_size(LinearOperation::Issues)),
256 &sync_token,
257 )?;
258 db.set_sync_cursor(workspace_id, team_key, &upper)?;
261 if let Some(callback) = progress {
262 callback(SyncProgressUpdate {
263 phase: SyncProgressPhase::IndexComplete,
264 completed: result.indexed,
265 total: Some(result.indexed),
266 issue_id: None,
267 });
268 }
269 Ok(result)
270 }
271
272 async fn fetch_issue_index_page(
273 &self,
274 team_key: &str,
275 cursor: Option<&str>,
276 lower: Option<&str>,
277 upper: &str,
278 page_size: usize,
279 ) -> Result<ConnectionPage<LinearIssueIndex>> {
280 let updated_filter = if lower.is_some() {
281 "updatedAt: { gte: $lower, lte: $upper }"
282 } else {
283 "updatedAt: { lte: $upper }"
284 };
285 let lower_declaration = if lower.is_some() {
286 ", $lower: DateTimeOrDuration"
287 } else {
288 ""
289 };
290 let query = format!(
291 r#"query($first: Int!, $after: String, $teamKey: String!{lower_declaration}, $upper: DateTimeOrDuration!) {{
292 issues(
293 first: $first,
294 after: $after,
295 filter: {{ team: {{ key: {{ eq: $teamKey }} }}, {updated_filter} }},
296 includeArchived: true,
297 orderBy: updatedAt
298 ) {{
299 nodes {{
300 id identifier title url createdAt updatedAt archivedAt
301 team {{ key }}
302 state {{ name type }}
303 }}
304 pageInfo {{ hasNextPage endCursor }}
305 }}
306 }}"#
307 );
308 let mut variables = serde_json::json!({
309 "first": page_size,
310 "after": cursor,
311 "teamKey": team_key,
312 "upper": upper,
313 });
314 if let Some(lower) = lower {
315 variables["lower"] = serde_json::Value::String(lower.to_string());
316 }
317 let data: IndexIssuesData = self
318 .query_operation("issue index", cursor, &query, variables)
319 .await?;
320 Ok(ConnectionPage {
321 nodes: data.issues.nodes,
322 page_info: data.issues.page_info,
323 })
324 }
325
326 pub async fn hydrate_issue(
328 &self,
329 db: &Database,
330 issue_id: &str,
331 workspace_id: &str,
332 progress: Option<&SyncProgressCallback<'_>>,
333 ) -> Result<IssueHydrationResult> {
334 self.hydrate_issue_with_mode(
335 db,
336 issue_id,
337 workspace_id,
338 HydrationMode::ForceRefresh,
339 progress,
340 )
341 .await
342 }
343
344 pub async fn hydrate_issue_with_mode(
346 &self,
347 db: &Database,
348 issue_id: &str,
349 workspace_id: &str,
350 mode: HydrationMode,
351 progress: Option<&SyncProgressCallback<'_>>,
352 ) -> Result<IssueHydrationResult> {
353 let issue = db
354 .get_issue(issue_id)?
355 .with_context(|| format!("issue '{issue_id}' is not present in the local index"))?;
356 if issue.workspace_id != workspace_id {
357 anyhow::bail!("issue '{issue_id}' is not in workspace '{workspace_id}'");
358 }
359 db.ensure_hydration_state_for_issue(workspace_id, &issue, "selected")?;
360 match mode {
361 HydrationMode::IfNeeded => {
362 db.queue_stale_issue_comment_hydration(
363 workspace_id,
364 &issue.id,
365 &comment_refresh_cutoff(Utc::now()),
366 )?;
367 }
368 HydrationMode::ForceRefresh => {
369 db.requeue_issue_hydration(workspace_id, &issue.id, "explicit_force")?;
370 }
371 }
372 self.hydrate_one(db, &issue.id, workspace_id, progress)
373 .await
374 }
375
376 pub async fn hydrate_pending_issues(
380 &self,
381 db: &Database,
382 team_key: &str,
383 workspace_id: &str,
384 limit: usize,
385 policy: HydrationPolicy,
386 progress: Option<&SyncProgressCallback<'_>>,
387 ) -> Result<HydrationBatchResult> {
388 if limit == 0 {
389 return Ok(HydrationBatchResult::default());
390 }
391 let now = Utc::now();
392 let now_text = now.to_rfc3339();
393 let recent_after = recent_cutoff(now);
394 let stale_comments = comment_refresh_cutoff(now);
395 db.queue_stale_comment_hydration(
396 workspace_id,
397 team_key,
398 policy,
399 &stale_comments,
400 &recent_after,
401 )?;
402 let candidates = db.list_hydration_candidates(
403 workspace_id,
404 team_key,
405 limit,
406 policy,
407 &now_text,
408 &recent_after,
409 )?;
410 let mut batch = HydrationBatchResult {
411 requested: candidates.len(),
412 ..Default::default()
413 };
414 for (index, candidate) in candidates.iter().enumerate() {
415 let result = self
416 .hydrate_one(db, &candidate.id, workspace_id, progress)
417 .await?;
418 batch.retryable_failures += result.retryable_failures;
419 batch.permanent_failures += result.permanent_failures;
420 for resource in &result.resources {
421 if resource.status != HydrationStatus::Hydrated {
422 if resource.resource == HydrationResource::Comments {
423 batch.comment_failures += 1;
424 } else {
425 batch.required_failures += 1;
426 }
427 }
428 }
429 match result.status {
430 HydrationStatus::Hydrated => batch.hydrated += 1,
431 _ => batch.partial += 1,
432 }
433 if result.rate_limited {
434 batch.rate_limited = true;
435 batch.deferred = candidates.len().saturating_sub(index + 1);
436 break;
437 }
438 }
439 Ok(batch)
440 }
441
442 async fn hydrate_one(
443 &self,
444 db: &Database,
445 issue_id: &str,
446 workspace_id: &str,
447 progress: Option<&SyncProgressCallback<'_>>,
448 ) -> Result<IssueHydrationResult> {
449 let mut source_updated_at = db
450 .get_issue(issue_id)?
451 .with_context(|| format!("issue '{issue_id}' disappeared during hydration"))?
452 .updated_at;
453 let initial = db.get_issue_hydration_state(workspace_id, issue_id)?;
454 let now = Utc::now();
455 let mut hydrated_resources = 0;
456 let mut retryable_failures = 0;
457 let mut permanent_failures = 0;
458 let mut rate_limited = false;
459
460 for resource_state in initial.resources {
461 let should_attempt = match resource_state.status {
462 HydrationStatus::Pending => true,
463 HydrationStatus::Retryable => match resource_state.next_retry_at.as_deref() {
464 None => true,
465 Some(value) => DateTime::parse_from_rfc3339(value)
466 .is_ok_and(|retry_at| retry_at.with_timezone(&Utc) <= now),
467 },
468 _ => false,
469 };
470 if !should_attempt {
471 continue;
472 }
473 let phase = match resource_state.resource {
474 HydrationResource::Details => SyncProgressPhase::HydratingIssueDetails,
475 HydrationResource::Labels => SyncProgressPhase::HydratingLabels,
476 HydrationResource::Relations => SyncProgressPhase::HydratingRelations,
477 HydrationResource::Comments => SyncProgressPhase::HydratingComments,
478 };
479 if let Some(callback) = progress {
480 callback(SyncProgressUpdate {
481 phase,
482 completed: 0,
483 total: Some(1),
484 issue_id: Some(issue_id.to_string()),
485 });
486 }
487 let attempted_at = Utc::now().to_rfc3339();
488 let attempts = db.mark_hydration_running(
489 workspace_id,
490 issue_id,
491 resource_state.resource,
492 &attempted_at,
493 )?;
494 let operation = match resource_state.resource {
495 HydrationResource::Details => match self.fetch_issue_details_only(issue_id).await {
496 Ok(mut issue) => {
497 issue.workspace_id = workspace_id.to_string();
498 source_updated_at = issue.updated_at.clone();
499 db.upsert_issue_preserving_labels(&issue).map(|_| 1)
500 }
501 Err(error) => Err(error),
502 },
503 HydrationResource::Labels => {
504 self.sync_issue_labels_in_workspace(db, issue_id, workspace_id)
505 .await
506 }
507 HydrationResource::Relations => self.sync_issue_relations(db, issue_id).await,
508 HydrationResource::Comments => {
509 self.sync_issue_comments(db, issue_id, workspace_id).await
510 }
511 };
512 let run = match operation {
513 Ok(_) => {
514 db.mark_hydration_complete(
515 workspace_id,
516 issue_id,
517 resource_state.resource,
518 &source_updated_at,
519 &Utc::now().to_rfc3339(),
520 )?;
521 hydrated_resources += 1;
522 ResourceRun::Hydrated
523 }
524 Err(error) => {
525 let classified = operation_error(&error);
526 let (status, next_retry, is_rate_limit) = match classified.map(|e| e.kind) {
527 Some(
528 LinearErrorKind::RateLimit
529 | LinearErrorKind::Transport
530 | LinearErrorKind::Transient,
531 ) => {
532 let delay = retry_delay(
533 issue_id,
534 resource_state.resource,
535 attempts,
536 classified,
537 );
538 (
539 HydrationStatus::Retryable,
540 Some(
541 (Utc::now() + chrono::Duration::from_std(delay)?).to_rfc3339(),
542 ),
543 classified.is_some_and(|e| e.kind == LinearErrorKind::RateLimit),
544 )
545 }
546 Some(LinearErrorKind::Authentication) => {
547 (HydrationStatus::PermissionDenied, None, false)
548 }
549 _ => (HydrationStatus::Unavailable, None, false),
550 };
551 let message = self.redacted_error_message(&error);
552 db.mark_hydration_failed(
553 workspace_id,
554 issue_id,
555 resource_state.resource,
556 status,
557 next_retry.as_deref(),
558 &message,
559 )?;
560 if status == HydrationStatus::Retryable {
561 retryable_failures += 1;
562 if is_rate_limit {
563 rate_limited = true;
564 if let Some(callback) = progress {
565 callback(SyncProgressUpdate {
566 phase: SyncProgressPhase::WaitingForRateLimitRetry,
567 completed: 0,
568 total: None,
569 issue_id: Some(issue_id.to_string()),
570 });
571 }
572 }
573 ResourceRun::Retryable {
574 rate_limited: is_rate_limit,
575 }
576 } else {
577 permanent_failures += 1;
578 ResourceRun::Permanent
579 }
580 }
581 };
582 if let Some(callback) = progress {
583 callback(SyncProgressUpdate {
584 phase,
585 completed: usize::from(run == ResourceRun::Hydrated),
586 total: Some(1),
587 issue_id: Some(issue_id.to_string()),
588 });
589 }
590 if matches!(run, ResourceRun::Retryable { rate_limited: true }) {
591 break;
592 }
593 }
594 let state = db.get_issue_hydration_state(workspace_id, issue_id)?;
595 Ok(IssueHydrationResult {
596 issue_id: issue_id.to_string(),
597 status: state.status,
598 hydrated_resources,
599 retryable_failures,
600 permanent_failures,
601 rate_limited,
602 resources: state.resources,
603 })
604 }
605
606 async fn fetch_issue_details_only(&self, issue_id: &str) -> Result<db::Issue> {
607 let query = r#"
608 query($id: String!) {
609 issue(id: $id) {
610 id identifier url title description priority branchName
611 createdAt updatedAt archivedAt
612 state { name type }
613 team { key }
614 assignee { name }
615 project { id name }
616 projectMilestone { id name }
617 cycle { id name number }
618 }
619 }
620 "#;
621 let data: SingleIssueData = self
622 .query_operation(
623 "issue details",
624 None,
625 query,
626 serde_json::json!({ "id": issue_id }),
627 )
628 .await?;
629 Ok(Self::convert_linear_issue(data.issue).0)
630 }
631}
632
633fn retry_delay(
634 issue_id: &str,
635 resource: HydrationResource,
636 attempts: u32,
637 classified: Option<&super::LinearOperationError>,
638) -> Duration {
639 if let Some(delay) = classified.and_then(|error| error.retry_after) {
640 return delay.min(Duration::from_secs(6 * 60 * 60));
641 }
642 let exponent = attempts.saturating_sub(1).min(10);
643 let base = Duration::from_secs(30_u64.saturating_mul(1_u64 << exponent))
644 .min(Duration::from_secs(6 * 60 * 60));
645 let mut hasher = Sha256::new();
646 hasher.update(issue_id.as_bytes());
647 hasher.update(resource.as_str().as_bytes());
648 hasher.update(attempts.to_le_bytes());
649 let digest = hasher.finalize();
650 let jitter_basis = u16::from_le_bytes([digest[0], digest[1]]) as u64;
651 let jitter_max = (base.as_secs() / 4).max(1);
652 base.saturating_add(Duration::from_secs(jitter_basis % jitter_max))
653}
654
655#[allow(dead_code)]
656fn _assert_linear_issue_is_reachable(_: LinearIssue) {}