Skip to main content

rectilinear_core/linear/
pagination.rs

1use std::collections::{HashMap, HashSet};
2use std::future::Future;
3use std::hash::Hash;
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use thiserror::Error;
9
10const DEFAULT_COMPLEXITY_TARGET: usize = 7_000;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum LinearOperation {
14    Teams,
15    Labels,
16    Projects,
17    ProjectTeams,
18    ProjectMembers,
19    ProjectLabels,
20    ProjectMilestones,
21    Cycles,
22    Issues,
23    Comments,
24    Relations,
25}
26
27impl LinearOperation {
28    pub fn name(self) -> &'static str {
29        match self {
30            Self::Teams => "teams",
31            Self::Labels => "labels",
32            Self::Projects => "projects",
33            Self::ProjectTeams => "project teams",
34            Self::ProjectMembers => "project members",
35            Self::ProjectLabels => "project labels",
36            Self::ProjectMilestones => "project milestones",
37            Self::Cycles => "cycles",
38            Self::Issues => "issues",
39            Self::Comments => "comments",
40            Self::Relations => "issue relations",
41        }
42    }
43
44    fn environment_name(self) -> &'static str {
45        match self {
46            Self::Teams => "TEAMS",
47            Self::Labels => "LABELS",
48            Self::Projects => "PROJECTS",
49            Self::ProjectTeams => "PROJECT_TEAMS",
50            Self::ProjectMembers => "PROJECT_MEMBERS",
51            Self::ProjectLabels => "PROJECT_LABELS",
52            Self::ProjectMilestones => "PROJECT_MILESTONES",
53            Self::Cycles => "CYCLES",
54            Self::Issues => "ISSUES",
55            Self::Comments => "COMMENTS",
56            Self::Relations => "RELATIONS",
57        }
58    }
59
60    fn recommended_page_size(self) -> usize {
61        match self {
62            Self::Projects => 25,
63            Self::Issues => 50,
64            Self::ProjectMembers | Self::ProjectLabels => 50,
65            Self::ProjectTeams => 25,
66            Self::Comments | Self::Relations => 100,
67            Self::Teams | Self::Labels | Self::ProjectMilestones | Self::Cycles => 100,
68        }
69    }
70
71    fn estimated_complexity(self) -> (usize, usize) {
72        // (fixed request cost, conservative per-node cost). These are planning
73        // weights, not Linear's private scoring formula. They deliberately
74        // overestimate shallow scalar selections and nested reference fields.
75        match self {
76            Self::Projects => (100, 220),
77            Self::Issues => (100, 115),
78            Self::ProjectMembers | Self::ProjectLabels => (50, 80),
79            Self::ProjectTeams => (50, 120),
80            Self::Comments => (50, 45),
81            Self::Relations => (50, 55),
82            Self::ProjectMilestones => (50, 60),
83            Self::Cycles => (50, 45),
84            Self::Labels => (50, 35),
85            Self::Teams => (50, 30),
86        }
87    }
88
89    fn field_set(self) -> &'static str {
90        match self {
91            Self::Teams => "team identity fields",
92            Self::Labels => "label identity, color, and parent",
93            Self::Projects => "project scalar metadata, status, and lead",
94            Self::ProjectTeams => "project team identity fields",
95            Self::ProjectMembers => "project member identity fields",
96            Self::ProjectLabels => "project label metadata",
97            Self::ProjectMilestones => "milestone scalar metadata and project reference",
98            Self::Cycles => "cycle scalar metadata and team reference",
99            Self::Issues => {
100                "issue scalar metadata, labels, project, milestone, and cycle references"
101            }
102            Self::Comments => "comment body, author, timestamps, parent, and URL",
103            Self::Relations => "relation type and related issue identity",
104        }
105    }
106}
107
108#[derive(Debug, Clone)]
109pub struct SyncQueryConfig {
110    pub complexity_target: usize,
111    pub minimum_page_size: usize,
112    pub max_retry_attempts: usize,
113    pub retry_base_delay: Duration,
114    pub verbose: bool,
115    page_size_overrides: HashMap<LinearOperation, usize>,
116}
117
118impl Default for SyncQueryConfig {
119    fn default() -> Self {
120        Self {
121            complexity_target: DEFAULT_COMPLEXITY_TARGET,
122            minimum_page_size: 1,
123            max_retry_attempts: 3,
124            retry_base_delay: Duration::from_millis(250),
125            verbose: false,
126            page_size_overrides: HashMap::new(),
127        }
128    }
129}
130
131impl SyncQueryConfig {
132    pub fn from_environment() -> Self {
133        let mut config = Self::default();
134        if let Some(value) = env_usize("RECTILINEAR_LINEAR_COMPLEXITY_TARGET") {
135            config.complexity_target = value.clamp(100, 9_000);
136        }
137        if let Some(value) = env_usize("RECTILINEAR_LINEAR_MIN_PAGE_SIZE") {
138            config.minimum_page_size = value.max(1);
139        }
140        config.verbose = std::env::var("RECTILINEAR_LINEAR_VERBOSE")
141            .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
142        for operation in [
143            LinearOperation::Teams,
144            LinearOperation::Labels,
145            LinearOperation::Projects,
146            LinearOperation::ProjectTeams,
147            LinearOperation::ProjectMembers,
148            LinearOperation::ProjectLabels,
149            LinearOperation::ProjectMilestones,
150            LinearOperation::Cycles,
151            LinearOperation::Issues,
152            LinearOperation::Comments,
153            LinearOperation::Relations,
154        ] {
155            let key = format!(
156                "RECTILINEAR_LINEAR_{}_PAGE_SIZE",
157                operation.environment_name()
158            );
159            if let Some(value) = env_usize(&key) {
160                config.page_size_overrides.insert(operation, value.max(1));
161            }
162        }
163        config
164    }
165
166    pub fn with_page_size(mut self, operation: LinearOperation, page_size: usize) -> Self {
167        self.page_size_overrides.insert(operation, page_size.max(1));
168        self
169    }
170
171    pub fn page_size(&self, operation: LinearOperation) -> usize {
172        if let Some(page_size) = self.page_size_overrides.get(&operation) {
173            return (*page_size).max(self.minimum_page_size);
174        }
175        let (base_cost, per_node_cost) = operation.estimated_complexity();
176        let planned = self
177            .complexity_target
178            .saturating_sub(base_cost)
179            .checked_div(per_node_cost)
180            .unwrap_or(1)
181            .max(1);
182        operation
183            .recommended_page_size()
184            .min(planned)
185            .max(self.minimum_page_size)
186    }
187
188    pub fn estimated_request_complexity(
189        &self,
190        operation: LinearOperation,
191        page_size: usize,
192    ) -> usize {
193        let (base_cost, per_node_cost) = operation.estimated_complexity();
194        base_cost.saturating_add(per_node_cost.saturating_mul(page_size))
195    }
196}
197
198fn env_usize(key: &str) -> Option<usize> {
199    std::env::var(key).ok()?.parse().ok()
200}
201
202#[derive(Debug, Clone, Deserialize)]
203pub(crate) struct PageInfo {
204    #[serde(rename = "hasNextPage")]
205    pub(crate) has_next_page: bool,
206    #[serde(rename = "endCursor")]
207    pub(crate) end_cursor: Option<String>,
208}
209
210#[derive(Debug)]
211pub(crate) struct ConnectionPage<T> {
212    pub(crate) nodes: Vec<T>,
213    pub(crate) page_info: PageInfo,
214}
215
216#[derive(Debug, Clone)]
217pub(crate) struct PageRequest {
218    pub(crate) cursor: Option<String>,
219    pub(crate) page_size: usize,
220    pub(crate) page_number: usize,
221}
222
223#[derive(Debug, Clone)]
224pub(crate) struct PageContext {
225    pub(crate) page_size: usize,
226    pub(crate) cursor: Option<String>,
227}
228
229#[derive(Debug, Clone)]
230pub struct SyncEvent {
231    pub operation: &'static str,
232    pub parent: Option<String>,
233    pub page_number: usize,
234    pub nodes_received: usize,
235    pub page_size: usize,
236    pub adaptive_reduction: bool,
237    pub completed: bool,
238    pub failure: Option<String>,
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub(crate) struct PaginationStats {
243    pub(crate) pages: usize,
244    pub(crate) nodes: usize,
245    pub(crate) adaptive_reductions: usize,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum LinearErrorKind {
250    Authentication,
251    RateLimit,
252    Complexity,
253    Validation,
254    Transport,
255    Api,
256}
257
258#[derive(Debug, Error)]
259#[error("Linear {kind:?} error during {operation}{cursor_context}: {message}")]
260pub struct LinearOperationError {
261    pub kind: LinearErrorKind,
262    pub operation: String,
263    pub cursor: Option<String>,
264    pub message: String,
265    pub retry_after: Option<Duration>,
266    cursor_context: String,
267}
268
269impl LinearOperationError {
270    pub fn new(
271        kind: LinearErrorKind,
272        operation: impl Into<String>,
273        cursor: Option<&str>,
274        message: impl Into<String>,
275    ) -> Self {
276        let cursor = cursor.map(ToString::to_string);
277        let cursor_context = cursor
278            .as_deref()
279            .map(|value| format!(" at cursor '{value}'"))
280            .unwrap_or_default();
281        Self {
282            kind,
283            operation: operation.into(),
284            cursor,
285            message: message.into(),
286            retry_after: None,
287            cursor_context,
288        }
289    }
290
291    pub fn with_retry_after(mut self, retry_after: Option<Duration>) -> Self {
292        self.retry_after = retry_after;
293        self
294    }
295}
296
297pub(crate) fn operation_error(error: &anyhow::Error) -> Option<&LinearOperationError> {
298    error.downcast_ref::<LinearOperationError>()
299}
300
301pub(crate) async fn paginate<T, K, Fetch, FetchFuture, Persist, PersistFuture, KeyFn, Observe>(
302    config: &SyncQueryConfig,
303    operation: LinearOperation,
304    parent: Option<String>,
305    mut fetch: Fetch,
306    mut persist: Persist,
307    key_of: KeyFn,
308    mut observe: Observe,
309) -> Result<PaginationStats>
310where
311    K: Eq + Hash,
312    Fetch: FnMut(PageRequest) -> FetchFuture,
313    FetchFuture: Future<Output = Result<ConnectionPage<T>>>,
314    Persist: FnMut(Vec<T>, PageContext) -> PersistFuture,
315    PersistFuture: Future<Output = Result<()>>,
316    KeyFn: Fn(&T) -> K,
317    Observe: FnMut(SyncEvent),
318{
319    let mut cursor = None;
320    let mut page_size = config.page_size(operation);
321    let mut stats = PaginationStats::default();
322    let mut retry_attempts = 0;
323    let mut seen = HashSet::new();
324
325    loop {
326        let request = PageRequest {
327            cursor: cursor.clone(),
328            page_size,
329            page_number: stats.pages + 1,
330        };
331        let page = match fetch(request.clone()).await {
332            Ok(page) => {
333                retry_attempts = 0;
334                page
335            }
336            Err(error) => {
337                let classified = operation_error(&error);
338                if classified.is_some_and(|value| value.kind == LinearErrorKind::Complexity) {
339                    if page_size > config.minimum_page_size {
340                        page_size = (page_size / 2).max(config.minimum_page_size);
341                        stats.adaptive_reductions += 1;
342                        observe(SyncEvent {
343                            operation: operation.name(),
344                            parent: parent.clone(),
345                            page_number: request.page_number,
346                            nodes_received: 0,
347                            page_size,
348                            adaptive_reduction: true,
349                            completed: false,
350                            failure: None,
351                        });
352                        continue;
353                    }
354                    let diagnostic = if page_size == 1 {
355                        format!(
356                            "Linear rejected a one-node {} request as too complex at cursor {:?}; \
357                             split the operation or reduce the requested field set ({})",
358                            operation.name(),
359                            cursor,
360                            operation.field_set()
361                        )
362                    } else {
363                        format!(
364                            "Linear rejected the minimum configured page size ({page_size}) for {} \
365                             at cursor {:?}; lower RECTILINEAR_LINEAR_MIN_PAGE_SIZE or split the \
366                             requested field set ({})",
367                            operation.name(),
368                            cursor,
369                            operation.field_set()
370                        )
371                    };
372                    observe(SyncEvent {
373                        operation: operation.name(),
374                        parent: parent.clone(),
375                        page_number: request.page_number,
376                        nodes_received: 0,
377                        page_size,
378                        adaptive_reduction: stats.adaptive_reductions > 0,
379                        completed: false,
380                        failure: Some(diagnostic.clone()),
381                    });
382                    anyhow::bail!(diagnostic);
383                }
384
385                let retry_delay = classified.and_then(|value| match value.kind {
386                    LinearErrorKind::RateLimit | LinearErrorKind::Transport
387                        if retry_attempts < config.max_retry_attempts =>
388                    {
389                        Some(value.retry_after.unwrap_or_else(|| {
390                            config
391                                .retry_base_delay
392                                .saturating_mul(1_u32 << retry_attempts.min(10))
393                        }))
394                    }
395                    _ => None,
396                });
397                if let Some(delay) = retry_delay {
398                    retry_attempts += 1;
399                    if !delay.is_zero() {
400                        tokio::time::sleep(delay).await;
401                    }
402                    continue;
403                }
404
405                let failure = format!("{error:#}");
406                observe(SyncEvent {
407                    operation: operation.name(),
408                    parent: parent.clone(),
409                    page_number: request.page_number,
410                    nodes_received: 0,
411                    page_size,
412                    adaptive_reduction: stats.adaptive_reductions > 0,
413                    completed: false,
414                    failure: Some(failure),
415                });
416                return Err(error).with_context(|| {
417                    format!(
418                        "failed to paginate {} at cursor {:?}",
419                        operation.name(),
420                        cursor
421                    )
422                });
423            }
424        };
425
426        if page.page_info.has_next_page && page.page_info.end_cursor.is_none() {
427            anyhow::bail!(
428                "Malformed {} pagination response on page {}: hasNextPage was true but endCursor was missing",
429                operation.name(),
430                request.page_number
431            );
432        }
433        if page.page_info.has_next_page && page.page_info.end_cursor == cursor {
434            anyhow::bail!(
435                "Malformed {} pagination response on page {}: endCursor did not advance",
436                operation.name(),
437                request.page_number
438            );
439        }
440
441        let mut nodes = page.nodes;
442        nodes.retain(|node| seen.insert(key_of(node)));
443        let received = nodes.len();
444        persist(
445            nodes,
446            PageContext {
447                page_size,
448                cursor: cursor.clone(),
449            },
450        )
451        .await
452        .with_context(|| {
453            format!(
454                "failed to persist {} page {} at cursor {:?}",
455                operation.name(),
456                request.page_number,
457                cursor
458            )
459        })?;
460        stats.pages += 1;
461        stats.nodes += received;
462        let completed = !page.page_info.has_next_page;
463        observe(SyncEvent {
464            operation: operation.name(),
465            parent: parent.clone(),
466            page_number: request.page_number,
467            nodes_received: received,
468            page_size,
469            adaptive_reduction: stats.adaptive_reductions > 0,
470            completed,
471            failure: None,
472        });
473        if completed {
474            return Ok(stats);
475        }
476        cursor = page.page_info.end_cursor;
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use std::collections::VecDeque;
483    use std::future::ready;
484
485    use super::*;
486
487    fn page(
488        nodes: &[usize],
489        has_next_page: bool,
490        end_cursor: Option<&str>,
491    ) -> ConnectionPage<usize> {
492        ConnectionPage {
493            nodes: nodes.to_vec(),
494            page_info: PageInfo {
495                has_next_page,
496                end_cursor: end_cursor.map(ToString::to_string),
497            },
498        }
499    }
500
501    fn run<F>(future: F) -> F::Output
502    where
503        F: Future,
504    {
505        tokio::runtime::Builder::new_current_thread()
506            .enable_time()
507            .build()
508            .unwrap()
509            .block_on(future)
510    }
511
512    #[test]
513    fn single_page_connection_yields_each_node() {
514        let mut persisted = Vec::new();
515        let stats = run(paginate(
516            &SyncQueryConfig::default(),
517            LinearOperation::Issues,
518            None,
519            |_| ready(Ok(page(&[1, 2], false, None))),
520            |nodes, _| {
521                persisted.extend(nodes);
522                ready(Ok(()))
523            },
524            |node| *node,
525            |_| {},
526        ))
527        .unwrap();
528        assert_eq!(persisted, [1, 2]);
529        assert_eq!(stats.pages, 1);
530    }
531
532    #[test]
533    fn multiple_pages_traverse_cursors_and_remove_boundary_duplicates() {
534        let mut responses = VecDeque::from([
535            page(&[1, 2], true, Some("cursor-1")),
536            page(&[2, 3], false, None),
537        ]);
538        let mut requested = Vec::new();
539        let mut persisted = Vec::new();
540        let stats = run(paginate(
541            &SyncQueryConfig::default(),
542            LinearOperation::Issues,
543            None,
544            |request| {
545                requested.push(request.cursor);
546                ready(Ok(responses.pop_front().unwrap()))
547            },
548            |nodes, _| {
549                persisted.extend(nodes);
550                ready(Ok(()))
551            },
552            |node| *node,
553            |_| {},
554        ))
555        .unwrap();
556        assert_eq!(requested, [None, Some("cursor-1".into())]);
557        assert_eq!(persisted, [1, 2, 3]);
558        assert_eq!(stats.nodes, 3);
559    }
560
561    #[test]
562    fn empty_connection_persists_an_empty_page() {
563        let mut pages = 0;
564        let stats = run(paginate(
565            &SyncQueryConfig::default(),
566            LinearOperation::Comments,
567            None,
568            |_| ready(Ok(page(&[], false, None))),
569            |nodes, _| {
570                assert!(nodes.is_empty());
571                pages += 1;
572                ready(Ok(()))
573            },
574            |node| *node,
575            |_| {},
576        ))
577        .unwrap();
578        assert_eq!(pages, 1);
579        assert_eq!(stats.nodes, 0);
580    }
581
582    #[test]
583    fn malformed_pagination_metadata_is_rejected() {
584        let error = run(paginate(
585            &SyncQueryConfig::default(),
586            LinearOperation::Comments,
587            None,
588            |_| ready(Ok(page(&[1], true, None))),
589            |_, _| ready(Ok(())),
590            |node| *node,
591            |_| {},
592        ))
593        .unwrap_err();
594        assert!(error.to_string().contains("endCursor was missing"));
595    }
596
597    #[test]
598    fn transient_transport_failure_retries_same_cursor() {
599        let mut attempts = 0;
600        let config = SyncQueryConfig {
601            retry_base_delay: Duration::ZERO,
602            ..Default::default()
603        };
604        let stats = run(paginate(
605            &config,
606            LinearOperation::Issues,
607            None,
608            |request| {
609                attempts += 1;
610                if attempts == 1 {
611                    ready(Err(LinearOperationError::new(
612                        LinearErrorKind::Transport,
613                        "issues",
614                        request.cursor.as_deref(),
615                        "connection reset",
616                    )
617                    .into()))
618                } else {
619                    ready(Ok(page(&[1], false, None)))
620                }
621            },
622            |_, _| ready(Ok(())),
623            |node| *node,
624            |_| {},
625        ))
626        .unwrap();
627        assert_eq!(attempts, 2);
628        assert_eq!(stats.nodes, 1);
629    }
630
631    #[test]
632    fn rate_limit_retries_without_advancing_cursor() {
633        let mut cursors = Vec::new();
634        let config = SyncQueryConfig {
635            retry_base_delay: Duration::ZERO,
636            ..Default::default()
637        };
638        run(paginate(
639            &config,
640            LinearOperation::Comments,
641            None,
642            |request| {
643                cursors.push(request.cursor.clone());
644                if cursors.len() == 1 {
645                    ready(Err(LinearOperationError::new(
646                        LinearErrorKind::RateLimit,
647                        "comments",
648                        request.cursor.as_deref(),
649                        "too many requests",
650                    )
651                    .into()))
652                } else {
653                    ready(Ok(page(&[], false, None)))
654                }
655            },
656            |_, _| ready(Ok(())),
657            |node| *node,
658            |_| {},
659        ))
660        .unwrap();
661        assert_eq!(cursors, [None, None]);
662    }
663
664    #[test]
665    fn complexity_rejection_reduces_page_size_at_same_cursor() {
666        let config = SyncQueryConfig::default().with_page_size(LinearOperation::Issues, 40);
667        let mut sizes = Vec::new();
668        let stats = run(paginate(
669            &config,
670            LinearOperation::Issues,
671            None,
672            |request| {
673                sizes.push(request.page_size);
674                if request.page_size > 10 {
675                    ready(Err(LinearOperationError::new(
676                        LinearErrorKind::Complexity,
677                        "issues",
678                        request.cursor.as_deref(),
679                        "Query complexity exceeds maximum allowed complexity",
680                    )
681                    .into()))
682                } else {
683                    ready(Ok(page(&[1], false, None)))
684                }
685            },
686            |_, _| ready(Ok(())),
687            |node| *node,
688            |_| {},
689        ))
690        .unwrap();
691        assert_eq!(sizes, [40, 20, 10]);
692        assert_eq!(stats.adaptive_reductions, 2);
693    }
694
695    #[test]
696    fn repeated_complexity_rejection_at_minimum_is_actionable() {
697        let config = SyncQueryConfig::default().with_page_size(LinearOperation::Projects, 4);
698        let error = run(paginate::<usize, usize, _, _, _, _, _, _>(
699            &config,
700            LinearOperation::Projects,
701            None,
702            |request| {
703                ready(Err(LinearOperationError::new(
704                    LinearErrorKind::Complexity,
705                    "projects",
706                    request.cursor.as_deref(),
707                    "too complex",
708                )
709                .into()))
710            },
711            |_, _| ready(Ok(())),
712            |node| *node,
713            |_| {},
714        ))
715        .unwrap_err();
716        assert!(error.to_string().contains("one-node projects request"));
717        assert!(error.to_string().contains("field set"));
718    }
719
720    #[test]
721    fn repeated_complexity_rejection_stops_at_configured_minimum() {
722        let mut sizes = Vec::new();
723        let config = SyncQueryConfig {
724            minimum_page_size: 5,
725            ..SyncQueryConfig::default().with_page_size(LinearOperation::Issues, 20)
726        };
727        let error = run(paginate::<usize, usize, _, _, _, _, _, _>(
728            &config,
729            LinearOperation::Issues,
730            None,
731            |request| {
732                sizes.push(request.page_size);
733                ready(Err(LinearOperationError::new(
734                    LinearErrorKind::Complexity,
735                    "issues",
736                    request.cursor.as_deref(),
737                    "too complex",
738                )
739                .into()))
740            },
741            |_, _| ready(Ok(())),
742            |node| *node,
743            |_| {},
744        ))
745        .unwrap_err();
746        assert_eq!(sizes, [20, 10, 5]);
747        assert!(error
748            .to_string()
749            .contains("minimum configured page size (5)"));
750    }
751
752    #[test]
753    fn simulated_large_workspace_stays_under_target() {
754        let config = SyncQueryConfig::default();
755        let old_workspace_query_complexity = 72_400;
756        assert!(old_workspace_query_complexity > 10_000);
757        let issue_count: usize = 1_200;
758        let issue_page_size = config.page_size(LinearOperation::Issues);
759        let issue_request_count = issue_count.div_ceil(issue_page_size);
760        assert_eq!(issue_page_size, 50);
761        assert_eq!(issue_request_count, 24);
762        for operation in [
763            LinearOperation::Teams,
764            LinearOperation::Labels,
765            LinearOperation::Projects,
766            LinearOperation::ProjectTeams,
767            LinearOperation::ProjectMembers,
768            LinearOperation::ProjectLabels,
769            LinearOperation::ProjectMilestones,
770            LinearOperation::Cycles,
771            LinearOperation::Issues,
772            LinearOperation::Comments,
773            LinearOperation::Relations,
774        ] {
775            let size = config.page_size(operation);
776            assert!(
777                config.estimated_request_complexity(operation, size) <= config.complexity_target,
778                "{} planned above target",
779                operation.name()
780            );
781        }
782    }
783}