Skip to content

fix(projects): bound project query connections to avoid complexity limit - #284

Merged
iamfj merged 2 commits into
linearis-oss:nextfrom
jackhutson:fix/project-query-complexity
Aug 6, 2026
Merged

fix(projects): bound project query connections to avoid complexity limit#284
iamfj merged 2 commits into
linearis-oss:nextfrom
jackhutson:fix/project-query-complexity

Conversation

@jackhutson

Copy link
Copy Markdown

What does this PR do?

Fixes the "Query too complex" failures on the projects commands by bounding the nested connections Linear's complexity estimator charges at default page size (50) per parent row — the query cost was determined by page-size math, not actual data, which is why even a fresh one-project workspace failed (#276).

Closes #276
Closes #283

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Tests
  • Build / CI

Changes

  • ProjectListFields: teams(first: 25), labels(first: 25). Measured against the live API: the default projects list (first: 100) priced at 13,950 vs the 10,000 budget; with these bounds it prices at ~7,450 (works up to --limit 134). Each connection node with a 3-field selection costs 1.3; unbounded is charged identically to first: 50.
  • ProjectDetailFields / ...WithDefaultConnections: members, initiatives, projectMilestones bounded at 25 for the same reason (these ride along in every project mutation payload).
  • projects read issues default 50 → 25: each returned issue prices at ~260 (CompleteIssueFields carries four connections charged at 50 each), so the old default read exceeded the budget on real workspaces (empirically passes at --issues-first 35, fails at 40). I deliberately did not touch CompleteIssueFields itself to keep this fix scoped to projects — bounding its inner connections would roughly halve per-issue cost across the whole issues domain if you'd prefer that direction instead; happy to follow up.
  • update --label-mode add|remove: the pre-read fetched the full project detail (milestones + default issues) just to read current label IDs — that pre-read, not the mutation payload, is what actually made label updates fail (projects update hits "Query too complex" with no possible workaround flag (mutation payload selects five unbounded connections) #283 originally blamed the payload; correction noted there). Replaced with a lean GetProjectLabelIds query per the resolver/service layer rules.

Checklist

  • npm run check:ci passes (lint + format)
  • npx tsc --noEmit passes (type check)
  • npm test passes (unit tests)
  • New code has tests (happy path + primary error case)
  • Commit messages follow Conventional Commits

Testing

  • 894/894 unit tests, knip clean; new getProjectLabelIds service tests (happy path + not-found), label-mode command tests updated to the lean call.
  • Live against a real workspace (9 projects, ~200 issues): projects list (default limit) and projects read <p> (default milestones/issues) both previously returned {"error":"Query too complex"} and now succeed; projects read --issues-first 35 passes / 40 fails, confirming the ~260/issue pricing.
  • Mutation payload verified under budget via a nil-UUID projectUpdate probe (fails with "Entity not found" after passing complexity validation, so nothing mutates).

Notes for reviewers

  • Bounds of 25 are a trade-off: projects with more than 25 teams/labels/members/initiatives/milestones get truncated rows (no nested pagination exists today). Unbounded was already an implicit 50-row truncation, so the delta only affects entities with 26–50 of one of those — rare, but flagging it.
  • The --issues-first default change (50 → 25) is user-visible; --issues-first <n> still overrides.
  • Complexity numbers come from the error's extensions.userPresentableMessage (e.g. "Complexity: 13119.99… Maximum allowed complexity: 10000"), reproducible with any workspace since the estimator ignores actual row counts.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

✅ knip — no dead code

No unused files, exports, types, or dependencies detected.

@iamfj iamfj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jackhutson — this is excellent work, and the investigation is the part I want to dwell on.

The insight carrying the whole fix is that Linear's estimator prices page-size math, not rows. That's precisely why #276 reproduced on a one-project workspace and why nobody could pin it on data volume. I re-derived your numbers and they're exactly consistent: (2 × 50 × 1.3 + 9.5) × 100 = 13,950, (2 × 25 × 1.3 + 9.5) × 100 = 7,450, and 10,000/74.5 = 134 matches your observed --limit ceiling — as does 4 × 50 × 1.3 ≈ 260 against your pass-at-35 / fail-at-40 boundary. Numbers with a stated method that reproduce under someone else's arithmetic are rare in a bug report; thank you for that.

You also didn't stop at the reported symptom. #283 pointed at the mutation payload, you measured it, found the real culprit was the full-detail pre-read in --label-mode add|remove, and corrected the issue rather than quietly fixing something adjacent. The nil-UUID projectUpdate probe — confirming the payload clears complexity validation by failing afterwards on "Entity not found", so nothing mutates — is a genuinely clever way to test a mutation you don't want to run.

On layering: I checked getProjectLabelIds against the invariants and services is the right home, so please don't let anyone move it. It takes a UUID and returns UUIDs — there is no human identifier to resolve, which is what the resolver layer is defined for — and putting it in resolvers would additionally need an ARCHITECTURAL EXCEPTION docstring it doesn't warrant. Your PR body undersells this as "per the layer rules"; it's the correct call for a specific reason.

One ask before merge: a getFragment-based assertion locking the first: 25 bounds (inline). Right now the fix for two P0 bugs is the only part of the diff without a test, and the helper is already sitting in that file. Three other notes inline — the pageInfo truncation signal, the first: 250 ceiling, and the question of whether issues list still carries this defect at its default limit. That last one I've split out as #286 rather than leave it in your PR description, so it isn't scope creep on you here.

On my own earlier instinct to ask for a --limit clamp: I talked myself out of it. parseLimit is shared across ten command families and the safe ceiling is a property of the fragment, not the domain — and silently clamping an explicit -l 200 would misreport the page size to a JSON consumer, which is worse than an error. The better follow-up is that src/client/graphql-client.ts:22 types GraphQL errors as { message: string } and never reads extensions, so the userPresentableMessage you mined the complexity figures from is thrown away before the user ever sees it. That's a global fix, and firmly out of scope here.

The truncation trade-offs in your "Notes for reviewers" are the right ones, and flagging the user-visible --issues-first default change plainly is exactly what I want to see. Approving.

Comment thread src/services/project-service.ts
Comment thread tests/unit/services/project-service.test.ts
Comment thread graphql/queries/projects.graphql
Comment thread graphql/queries/projects.graphql
jackhutson pushed a commit to jackhutson/linearis that referenced this pull request Aug 6, 2026
… in tests

Review follow-ups for linearis-oss#284:

- assert in tests that every bounded fragment connection carries a
  literal first: argument, so a future tidy-up that unbounds them
  fails CI instead of reintroducing linearis-oss#276
- select pageInfo.hasNextPage on the five bounded connections in the
  detail fragment so truncated pages are detectable in read and
  mutation responses; measured live, pageInfo prices at ~23 complexity
  per parent row, so the signal lives only where the parent count is
  one — selecting it in ProjectListFields put the default list back
  over budget (12120) and is deliberately omitted there
- getProjectLabelIds now selects hasNextPage on its first: 250 read
  (Linear's per-connection maximum) and throws on truncation instead
  of letting a partial label set be written back as complete via the
  full-replacement labelIds input

Refs linearis-oss#284
@jackhutson

Copy link
Copy Markdown
Author

Thanks for the thorough review — all three asks are in as of 141ab05, with one measured deviation and one answered question:

Bounds test (the merge blocker): getFragment-based assertions now lock a literal first: argument on all five connections (plus the lean label lookup); deleting any bound fails CI with a message pointing back at the complexity budget.

pageInfo — implemented, but not where either of us expected. Your "costs almost nothing" turned out to be measurably false at list scale: pageInfo { hasNextPage } prices at ~23 complexity per parent row per connection. On ProjectListFields at the default first: 100 that's 12,120 — straight back over the budget this PR exists to get under. So the signal lives in ProjectDetailFields instead, as teams(first: 25) / labels(first: 25) re-selections that merge with the list fragment's selections (identical arguments), plus direct selections on members/initiatives/projectMilestones. Net effect: every detail read and mutation response — your agent-overwrite scenario — carries truncation signals on all five connections; list rows stay bounded-but-silent, which the fragment comment now documents. Verified live: default projects list passes, projects read returns the pageInfo objects, nil-UUID projectUpdate still clears complexity validation.

getProjectLabelIds throws on truncation ("refusing to modify labels from a truncated label set") rather than commenting "don't lower this" — agreed that failing loudly beats a silent five-label deletion, and the test covers the throw.

Your issues list question — it succeeds, and your second hypothesis is right with a twist. Live against the same workspace, issues list with no flags passes, and so does -l 250 (the API max) with full CompleteIssueFields. The estimator prices per schema field, not per structure: an identical labels { nodes { id name color } } selection under 250 parents passes under the issues root but prices at 16,525 under the projects root. So the cost model in this PR is the Project-field weight table, not a universal rule — I'll treat that as the footnote you asked for. The remaining puzzle belongs to #286: inside project(id), full-CIF issues fail at 40 but pass at 35 (~260/issue), yet every CIF component I probed piecewise (labels, children, relations + relatedIssue, inverseRelations) passes individually at those counts — whatever carries the weight under Project.issues, I couldn't isolate it with the probes I was willing to spend; happy to pick that up in #286 if useful.

And thanks for the layering confirmation on getProjectLabelIds — the "no human identifier to resolve" framing is a cleaner justification than the one I gave.

@iamfj iamfj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving on 141ab05 so the approval sits on the current head.

Checked the full checklist locally: tsc clean, check:ci clean, 903 tests passing, knip clean. I also mutation-tested the new bounds assertions rather than trusting them, and they fail the way they should when a bound or a pageInfo goes missing. The measured pushback on my pageInfo suggestion was the right response to a suggestion I hadn't costed.

Two small things left, both in threads and neither blocking: projects read still can't signal truncation on projectMilestones and issues, and the Project-specific caveat on the cost model didn't make it into the comment at project-service.ts:100. Merge whenever you're happy.

Separately, I'd be glad if you picked up #286. I've put your measurements in there and rescoped it, but you're the one who understands the estimator's behaviour now, and it'd be a shame for that to sit in a closed PR thread. No obligation.

Jack Hutson added 2 commits August 6, 2026 19:53
Linear's complexity estimator charges an unbounded connection at its
default page size (50) per parent row. ProjectListFields selected
unbounded teams and labels connections, pricing the default
`projects list` (first: 100) at ~13950 against a budget of 10000 —
so default-limit lists failed with "Query too complex" even on
near-empty workspaces, since the estimator prices page size, not data.

- bound teams/labels (25) in ProjectListFields, members/initiatives
  (25) in ProjectDetailFields, and projectMilestones (25) in the
  default-connections fragment; the same list query now prices ~7450
- lower the projects read issues default from 50 to 25: each issue in
  the response costs ~260 (CompleteIssueFields carries four unbounded
  connections), so the old default read exceeded the budget on real
  workspaces
- replace the update --label-mode add|remove pre-read (full project
  detail including milestones and issues) with a lean label-IDs-only
  query

Closes linearis-oss#276
Closes linearis-oss#283
… in tests

Review follow-ups for linearis-oss#284:

- assert in tests that every bounded fragment connection carries a
  literal first: argument, so a future tidy-up that unbounds them
  fails CI instead of reintroducing linearis-oss#276
- select pageInfo.hasNextPage on the five bounded connections in the
  detail fragment so truncated pages are detectable in read and
  mutation responses; measured live, pageInfo prices at ~23 complexity
  per parent row, so the signal lives only where the parent count is
  one — selecting it in ProjectListFields put the default list back
  over budget (12120) and is deliberately omitted there
- getProjectLabelIds now selects hasNextPage on its first: 250 read
  (Linear's per-connection maximum) and throws on truncation instead
  of letting a partial label set be written back as complete via the
  full-replacement labelIds input

Refs linearis-oss#284
@iamfj
iamfj force-pushed the fix/project-query-complexity branch from 141ab05 to a3145c1 Compare August 6, 2026 17:55
@iamfj

iamfj commented Aug 6, 2026

Copy link
Copy Markdown
Member

Heads up, I rebased this onto current next and force-pushed. 141ab05 is now a3145c1.

You were 34 commits behind, including the graphql v17 major and the new cli-errors layer from #281. Better to hit any fallout now than right after merge.

Your changes came through untouched. One thing did need fixing: biome on current next formats your it.each(BOUNDED_CONNECTIONS) block differently, so check:ci failed on the rebase. I ran the formatter and folded it into your commit rather than stacking a style commit on top. Indentation only, and you're still the author.

The checklist passes on the new head: generate, tsc, check:ci, 935 tests, knip.

If you have local work in flight, git fetch && git reset --hard jackhutson/fix/project-query-complexity will put you back in sync. The old tip is 141ab05 if you need anything off it.

We're holding the merge to next for now. GitHub Actions is in the middle of an incident (https://www.githubstatus.com/incidents/qcvjkzcs7j74), workflow runs are failing or slow to start, and nothing has been queued for the new head yet. I'd rather wait for CI to actually run than merge on checks from a commit that no longer exists. Once it clears and the build is green, this goes in.

@jackhutson

Copy link
Copy Markdown
Author

Thanks for handling the rebase — synced clean, nothing lost, and the biome fold-in looks right.

Since Actions is down, here's the verification your CI can't run right now, from a clean npm install on a3145c1 (so graphql v17 + cli-errors included): all five local gates pass (935 tests), and live against a real workspace — default projects list ✓, default projects read with the truncation signals present ✓, issues list ✓, and the nil-UUID projectUpdate probe still clears complexity validation with the v17-printed document ✓. No fallout from the major bump on the API side.

@iamfj
iamfj merged commit 1498764 into linearis-oss:next Aug 6, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants