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 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 Transient,
256 Api,
257}
258
259#[derive(Debug, Error)]
260#[error("Linear {kind:?} error during {operation}{cursor_context}: {message}")]
261pub struct LinearOperationError {
262 pub kind: LinearErrorKind,
263 pub operation: String,
264 pub cursor: Option<String>,
265 pub message: String,
266 pub retry_after: Option<Duration>,
267 cursor_context: String,
268}
269
270impl LinearOperationError {
271 pub fn new(
272 kind: LinearErrorKind,
273 operation: impl Into<String>,
274 cursor: Option<&str>,
275 message: impl Into<String>,
276 ) -> Self {
277 let cursor = cursor.map(ToString::to_string);
278 let cursor_context = cursor
279 .as_deref()
280 .map(|value| format!(" at cursor '{value}'"))
281 .unwrap_or_default();
282 Self {
283 kind,
284 operation: operation.into(),
285 cursor,
286 message: message.into(),
287 retry_after: None,
288 cursor_context,
289 }
290 }
291
292 pub fn with_retry_after(mut self, retry_after: Option<Duration>) -> Self {
293 self.retry_after = retry_after;
294 self
295 }
296}
297
298pub(crate) fn operation_error(error: &anyhow::Error) -> Option<&LinearOperationError> {
299 error.downcast_ref::<LinearOperationError>()
300}
301
302pub(crate) async fn paginate<T, K, Fetch, FetchFuture, Persist, PersistFuture, KeyFn, Observe>(
303 config: &SyncQueryConfig,
304 operation: LinearOperation,
305 parent: Option<String>,
306 mut fetch: Fetch,
307 mut persist: Persist,
308 key_of: KeyFn,
309 mut observe: Observe,
310) -> Result<PaginationStats>
311where
312 K: Eq + Hash,
313 Fetch: FnMut(PageRequest) -> FetchFuture,
314 FetchFuture: Future<Output = Result<ConnectionPage<T>>>,
315 Persist: FnMut(Vec<T>, PageContext) -> PersistFuture,
316 PersistFuture: Future<Output = Result<()>>,
317 KeyFn: Fn(&T) -> K,
318 Observe: FnMut(SyncEvent),
319{
320 let mut cursor = None;
321 let mut page_size = config.page_size(operation);
322 let mut stats = PaginationStats::default();
323 let mut retry_attempts = 0;
324 let mut seen = HashSet::new();
325
326 loop {
327 let request = PageRequest {
328 cursor: cursor.clone(),
329 page_size,
330 page_number: stats.pages + 1,
331 };
332 let page = match fetch(request.clone()).await {
333 Ok(page) => {
334 retry_attempts = 0;
335 page
336 }
337 Err(error) => {
338 let classified = operation_error(&error);
339 if classified.is_some_and(|value| value.kind == LinearErrorKind::Complexity) {
340 if page_size > config.minimum_page_size {
341 page_size = (page_size / 2).max(config.minimum_page_size);
342 stats.adaptive_reductions += 1;
343 observe(SyncEvent {
344 operation: operation.name(),
345 parent: parent.clone(),
346 page_number: request.page_number,
347 nodes_received: 0,
348 page_size,
349 adaptive_reduction: true,
350 completed: false,
351 failure: None,
352 });
353 continue;
354 }
355 let diagnostic = if page_size == 1 {
356 format!(
357 "Linear rejected a one-node {} request as too complex at cursor {:?}; \
358 split the operation or reduce the requested field set ({})",
359 operation.name(),
360 cursor,
361 operation.field_set()
362 )
363 } else {
364 format!(
365 "Linear rejected the minimum configured page size ({page_size}) for {} \
366 at cursor {:?}; lower RECTILINEAR_LINEAR_MIN_PAGE_SIZE or split the \
367 requested field set ({})",
368 operation.name(),
369 cursor,
370 operation.field_set()
371 )
372 };
373 observe(SyncEvent {
374 operation: operation.name(),
375 parent: parent.clone(),
376 page_number: request.page_number,
377 nodes_received: 0,
378 page_size,
379 adaptive_reduction: stats.adaptive_reductions > 0,
380 completed: false,
381 failure: Some(diagnostic.clone()),
382 });
383 anyhow::bail!(diagnostic);
384 }
385
386 let retry_delay = classified.and_then(|value| match value.kind {
387 LinearErrorKind::RateLimit
388 | LinearErrorKind::Transport
389 | LinearErrorKind::Transient
390 if retry_attempts < config.max_retry_attempts =>
391 {
392 Some(value.retry_after.unwrap_or_else(|| {
393 config
394 .retry_base_delay
395 .saturating_mul(1_u32 << retry_attempts.min(10))
396 }))
397 }
398 _ => None,
399 });
400 if let Some(delay) = retry_delay {
401 retry_attempts += 1;
402 observe(SyncEvent {
403 operation: operation.name(),
404 parent: parent.clone(),
405 page_number: request.page_number,
406 nodes_received: 0,
407 page_size,
408 adaptive_reduction: stats.adaptive_reductions > 0,
409 completed: false,
410 failure: Some(format!(
411 "retrying attempt {retry_attempts}/{} after {delay:?} following a {:?} error",
412 config.max_retry_attempts,
413 classified.expect("retryable errors are classified").kind
414 )),
415 });
416 if !delay.is_zero() {
417 tokio::time::sleep(delay).await;
418 }
419 continue;
420 }
421
422 let failure = format!("{error:#}");
423 observe(SyncEvent {
424 operation: operation.name(),
425 parent: parent.clone(),
426 page_number: request.page_number,
427 nodes_received: 0,
428 page_size,
429 adaptive_reduction: stats.adaptive_reductions > 0,
430 completed: false,
431 failure: Some(failure),
432 });
433 return Err(error).with_context(|| {
434 format!(
435 "failed to paginate {} at cursor {:?}",
436 operation.name(),
437 cursor
438 )
439 });
440 }
441 };
442
443 if page.page_info.has_next_page && page.page_info.end_cursor.is_none() {
444 anyhow::bail!(
445 "Malformed {} pagination response on page {}: hasNextPage was true but endCursor was missing",
446 operation.name(),
447 request.page_number
448 );
449 }
450 if page.page_info.has_next_page && page.page_info.end_cursor == cursor {
451 anyhow::bail!(
452 "Malformed {} pagination response on page {}: endCursor did not advance",
453 operation.name(),
454 request.page_number
455 );
456 }
457
458 let mut nodes = page.nodes;
459 nodes.retain(|node| seen.insert(key_of(node)));
460 let received = nodes.len();
461 persist(
462 nodes,
463 PageContext {
464 page_size,
465 cursor: cursor.clone(),
466 },
467 )
468 .await
469 .with_context(|| {
470 format!(
471 "failed to persist {} page {} at cursor {:?}",
472 operation.name(),
473 request.page_number,
474 cursor
475 )
476 })?;
477 stats.pages += 1;
478 stats.nodes += received;
479 let completed = !page.page_info.has_next_page;
480 observe(SyncEvent {
481 operation: operation.name(),
482 parent: parent.clone(),
483 page_number: request.page_number,
484 nodes_received: received,
485 page_size,
486 adaptive_reduction: stats.adaptive_reductions > 0,
487 completed,
488 failure: None,
489 });
490 if completed {
491 return Ok(stats);
492 }
493 cursor = page.page_info.end_cursor;
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use std::collections::VecDeque;
500 use std::future::ready;
501
502 use super::*;
503
504 fn page(
505 nodes: &[usize],
506 has_next_page: bool,
507 end_cursor: Option<&str>,
508 ) -> ConnectionPage<usize> {
509 ConnectionPage {
510 nodes: nodes.to_vec(),
511 page_info: PageInfo {
512 has_next_page,
513 end_cursor: end_cursor.map(ToString::to_string),
514 },
515 }
516 }
517
518 fn run<F>(future: F) -> F::Output
519 where
520 F: Future,
521 {
522 tokio::runtime::Builder::new_current_thread()
523 .enable_time()
524 .build()
525 .unwrap()
526 .block_on(future)
527 }
528
529 #[test]
530 fn single_page_connection_yields_each_node() {
531 let mut persisted = Vec::new();
532 let stats = run(paginate(
533 &SyncQueryConfig::default(),
534 LinearOperation::Issues,
535 None,
536 |_| ready(Ok(page(&[1, 2], false, None))),
537 |nodes, _| {
538 persisted.extend(nodes);
539 ready(Ok(()))
540 },
541 |node| *node,
542 |_| {},
543 ))
544 .unwrap();
545 assert_eq!(persisted, [1, 2]);
546 assert_eq!(stats.pages, 1);
547 }
548
549 #[test]
550 fn multiple_pages_traverse_cursors_and_remove_boundary_duplicates() {
551 let mut responses = VecDeque::from([
552 page(&[1, 2], true, Some("cursor-1")),
553 page(&[2, 3], false, None),
554 ]);
555 let mut requested = Vec::new();
556 let mut persisted = Vec::new();
557 let stats = run(paginate(
558 &SyncQueryConfig::default(),
559 LinearOperation::Issues,
560 None,
561 |request| {
562 requested.push(request.cursor);
563 ready(Ok(responses.pop_front().unwrap()))
564 },
565 |nodes, _| {
566 persisted.extend(nodes);
567 ready(Ok(()))
568 },
569 |node| *node,
570 |_| {},
571 ))
572 .unwrap();
573 assert_eq!(requested, [None, Some("cursor-1".into())]);
574 assert_eq!(persisted, [1, 2, 3]);
575 assert_eq!(stats.nodes, 3);
576 }
577
578 #[test]
579 fn empty_connection_persists_an_empty_page() {
580 let mut pages = 0;
581 let stats = run(paginate(
582 &SyncQueryConfig::default(),
583 LinearOperation::Comments,
584 None,
585 |_| ready(Ok(page(&[], false, None))),
586 |nodes, _| {
587 assert!(nodes.is_empty());
588 pages += 1;
589 ready(Ok(()))
590 },
591 |node| *node,
592 |_| {},
593 ))
594 .unwrap();
595 assert_eq!(pages, 1);
596 assert_eq!(stats.nodes, 0);
597 }
598
599 #[test]
600 fn malformed_pagination_metadata_is_rejected() {
601 let error = run(paginate(
602 &SyncQueryConfig::default(),
603 LinearOperation::Comments,
604 None,
605 |_| ready(Ok(page(&[1], true, None))),
606 |_, _| ready(Ok(())),
607 |node| *node,
608 |_| {},
609 ))
610 .unwrap_err();
611 assert!(error.to_string().contains("endCursor was missing"));
612 }
613
614 #[test]
615 fn transient_transport_failure_retries_same_cursor() {
616 let mut attempts = 0;
617 let mut events = Vec::new();
618 let config = SyncQueryConfig {
619 retry_base_delay: Duration::ZERO,
620 ..Default::default()
621 };
622 let stats = run(paginate(
623 &config,
624 LinearOperation::Issues,
625 None,
626 |request| {
627 attempts += 1;
628 if attempts == 1 {
629 ready(Err(LinearOperationError::new(
630 LinearErrorKind::Transport,
631 "issues",
632 request.cursor.as_deref(),
633 "connection reset",
634 )
635 .into()))
636 } else {
637 ready(Ok(page(&[1], false, None)))
638 }
639 },
640 |_, _| ready(Ok(())),
641 |node| *node,
642 |event| events.push(event),
643 ))
644 .unwrap();
645 assert_eq!(attempts, 2);
646 assert_eq!(stats.nodes, 1);
647 assert!(events.iter().any(|event| {
648 event
649 .failure
650 .as_deref()
651 .is_some_and(|failure| failure.starts_with("retrying attempt 1/"))
652 }));
653 }
654
655 #[test]
656 fn rate_limit_retries_without_advancing_cursor() {
657 let mut cursors = Vec::new();
658 let config = SyncQueryConfig {
659 retry_base_delay: Duration::ZERO,
660 ..Default::default()
661 };
662 run(paginate(
663 &config,
664 LinearOperation::Comments,
665 None,
666 |request| {
667 cursors.push(request.cursor.clone());
668 if cursors.len() == 1 {
669 ready(Err(LinearOperationError::new(
670 LinearErrorKind::RateLimit,
671 "comments",
672 request.cursor.as_deref(),
673 "too many requests",
674 )
675 .into()))
676 } else {
677 ready(Ok(page(&[], false, None)))
678 }
679 },
680 |_, _| ready(Ok(())),
681 |node| *node,
682 |_| {},
683 ))
684 .unwrap();
685 assert_eq!(cursors, [None, None]);
686 }
687
688 #[test]
689 fn complexity_rejection_reduces_page_size_at_same_cursor() {
690 let config = SyncQueryConfig::default().with_page_size(LinearOperation::Issues, 40);
691 let mut sizes = Vec::new();
692 let stats = run(paginate(
693 &config,
694 LinearOperation::Issues,
695 None,
696 |request| {
697 sizes.push(request.page_size);
698 if request.page_size > 10 {
699 ready(Err(LinearOperationError::new(
700 LinearErrorKind::Complexity,
701 "issues",
702 request.cursor.as_deref(),
703 "Query complexity exceeds maximum allowed complexity",
704 )
705 .into()))
706 } else {
707 ready(Ok(page(&[1], false, None)))
708 }
709 },
710 |_, _| ready(Ok(())),
711 |node| *node,
712 |_| {},
713 ))
714 .unwrap();
715 assert_eq!(sizes, [40, 20, 10]);
716 assert_eq!(stats.adaptive_reductions, 2);
717 }
718
719 #[test]
720 fn repeated_complexity_rejection_at_minimum_is_actionable() {
721 let config = SyncQueryConfig::default().with_page_size(LinearOperation::Projects, 4);
722 let error = run(paginate::<usize, usize, _, _, _, _, _, _>(
723 &config,
724 LinearOperation::Projects,
725 None,
726 |request| {
727 ready(Err(LinearOperationError::new(
728 LinearErrorKind::Complexity,
729 "projects",
730 request.cursor.as_deref(),
731 "too complex",
732 )
733 .into()))
734 },
735 |_, _| ready(Ok(())),
736 |node| *node,
737 |_| {},
738 ))
739 .unwrap_err();
740 assert!(error.to_string().contains("one-node projects request"));
741 assert!(error.to_string().contains("field set"));
742 }
743
744 #[test]
745 fn repeated_complexity_rejection_stops_at_configured_minimum() {
746 let mut sizes = Vec::new();
747 let config = SyncQueryConfig {
748 minimum_page_size: 5,
749 ..SyncQueryConfig::default().with_page_size(LinearOperation::Issues, 20)
750 };
751 let error = run(paginate::<usize, usize, _, _, _, _, _, _>(
752 &config,
753 LinearOperation::Issues,
754 None,
755 |request| {
756 sizes.push(request.page_size);
757 ready(Err(LinearOperationError::new(
758 LinearErrorKind::Complexity,
759 "issues",
760 request.cursor.as_deref(),
761 "too complex",
762 )
763 .into()))
764 },
765 |_, _| ready(Ok(())),
766 |node| *node,
767 |_| {},
768 ))
769 .unwrap_err();
770 assert_eq!(sizes, [20, 10, 5]);
771 assert!(error
772 .to_string()
773 .contains("minimum configured page size (5)"));
774 }
775
776 #[test]
777 fn simulated_large_workspace_stays_under_target() {
778 let config = SyncQueryConfig::default();
779 let old_workspace_query_complexity = 72_400;
780 assert!(old_workspace_query_complexity > 10_000);
781 let issue_count: usize = 1_200;
782 let issue_page_size = config.page_size(LinearOperation::Issues);
783 let issue_request_count = issue_count.div_ceil(issue_page_size);
784 assert_eq!(issue_page_size, 50);
785 assert_eq!(issue_request_count, 24);
786 for operation in [
787 LinearOperation::Teams,
788 LinearOperation::Labels,
789 LinearOperation::Projects,
790 LinearOperation::ProjectTeams,
791 LinearOperation::ProjectMembers,
792 LinearOperation::ProjectLabels,
793 LinearOperation::ProjectMilestones,
794 LinearOperation::Cycles,
795 LinearOperation::Issues,
796 LinearOperation::Comments,
797 LinearOperation::Relations,
798 ] {
799 let size = config.page_size(operation);
800 assert!(
801 config.estimated_request_complexity(operation, size) <= config.complexity_target,
802 "{} planned above target",
803 operation.name()
804 );
805 }
806 }
807}