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 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}