Skip to main content

rectilinear_core/linear/
progressive.rs

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    /// Synchronize only the authoritative issue list fields. Relay cursors are
115    /// used only inside this fixed timestamp window and are never persisted as
116    /// the durable checkpoint.
117    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        // This is deliberately the final durable write. Any fetch or page
259        // persistence error above leaves the previously committed value intact.
260        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    /// Compatibility wrapper preserving the original explicit-refresh behavior.
327    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    /// Hydrate one selected issue according to an explicit refresh mode.
345    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    /// Hydrate a deterministic bounded batch. Execution is intentionally
377    /// sequential (concurrency bound of one) to avoid task fan-out and to stop
378    /// immediately when Linear asks the client to back off.
379    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) {}