fix: stop Shared Drive-scoped change entries from poisoning the Google content-sync queue - #1617
Conversation
…ning the sync queue
The prod alarm aistudio-prod-google-content-sync-oldest-message fired because
two connectors (b1429b0b-..., 1f870c92-...) had synced nothing since
2026-08-06 ~12:00 UTC and 904 messages had accumulated in the DLQ.
Root cause
----------
Google's Drive Changes feed (GET /drive/v3/changes) is not exclusively
file-scoped. Entries with changeType "drive" describe the Shared Drive itself
- rename, membership change, restriction change - and carry NO fileId.
driveChangeSchema in lib/repositories/google-drive/drive-client.ts declared
fileId as a required z.string(), so changesListSchema.parse() threw a ZodError
inside listChanges the moment such an entry appeared in a page. The throw
happened before reconcileChanges could persist the cursor, so every SQS
redelivery re-fetched the identical page and failed identically. The message
never drained, its age breached the 30-minute alarm threshold, and after
maxReceiveCount it landed in the DLQ - repeatedly.
Changes
-------
lib/repositories/google-drive/drive-client.ts
- driveChangeSchema.fileId is now z.string().optional().
- Added changeType: z.string().optional(). Deliberately a plain string and
NOT a z.enum: an enum would re-poison the queue the first time Google
ships a changeType value we did not anticipate.
- listChanges now requests changeType in its fields projection, so the API
actually returns the discriminator the guard reads. Without this the
projection would silently strip it and every entry would look untyped.
infra/lambdas/google-content-sync/changes.ts (new)
- isFileScopedDriveChange(): the single place that decides whether a change
entry names a file the sync path can act on. Returns false for
changeType === "drive" and for a missing/empty fileId; a type predicate so
the caller gets fileId narrowed to string. Extracted into its own module
because index.ts is not importable from the root Jest gate.
infra/lambdas/google-content-sync/index.ts
- processGoogleDriveChange() now guards FIRST: a non-file-scoped entry is
logged and skipped with return false, before markSourceMissing() or any
other side effect can run. Returning false leaves requiresSelectionSnapshot
untouched, so reconcileChanges still persists the cursor for that page and
the loop advances.
- Drive deletion and access loss remain covered by the existing 403/404 ->
markConnectorAccessLost path; nothing about that behaviour changes.
- Extracted the catch-block outcome recording into recordDriveChangeFailure()
- behaviour-identical, and it brings the function back under the
complexity-15 lint ceiling that the new guard pushed it over.
Tests
-----
- tests/unit/lib/repositories/google-drive-changes-feed.test.ts: a mixed page
containing a changeType "drive" entry with no fileId plus a normal file
entry now parses and returns both values; an unknown future changeType
parses; and the fields projection is asserted to request changeType.
- tests/unit/google-content-sync-change-scope.test.ts: full truth table for
isFileScopedDriveChange (drive-scope wins even if a fileId is present,
empty/missing fileId skipped, unknown changeType with a fileId processed),
the fileId type narrowing, plus source-level assertions that the guard
precedes the first side effect in processGoogleDriveChange and that
reconcileChanges still persists the cursor on the skip path.
Operational notes
-----------------
No alarm thresholds changed. Existing DLQ messages are intentionally left to
expire under the 14-day retention rather than being redriven or purged. The
affected connectors self-heal on their next webhook or scheduled tick once the
Lambda is deployed.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e159156357
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code ReviewReviewed the diff, the full surrounding context of Strengths
Minor notes (non-blocking)
Security / PerformanceNo concerns — this is a parsing/control-flow fix internal to the Lambda's Drive Changes handling, no new external input surface, no new IAM/network access, and no change to hot-path performance characteristics (the guard is O(1) per change entry). Test coverageGood — both the schema-level parsing behavior and the handler-level control flow are covered, plus the regression scenario (mixed page with a Nice fix — clear root-cause writeup, targeted change, and the cursor-persistence invariant (the actual thing that was broken) is explicitly tested. |
…s removed Review follow-up (Codex P1 on PR #1617). The first commit skipped every non-file-scoped change entry unconditionally. That is correct for the metadata noise the outage was actually about - Shared Drive rename, membership change, restriction change - but it also silently consumed the one drive-scoped entry that carries real meaning: removed: true, which Google emits when the Shared Drive is deleted or the account loses access to it. Consuming that is a data-correctness bug. Sources already imported from that drive become unreachable, and the connector gets no other signal: - A connector scoped to a shared drive would eventually surface the loss, because its next listChanges/watch call against that driveId returns 403/404 and the existing markConnectorAccessLost path fires. - A connector reading the global changes feed would NOT. Its subsequent listChanges and watch calls keep succeeding, so the stale content stays active indefinitely with nothing to retire it. Fix --- infra/lambdas/google-content-sync/changes.ts - New isDriveRemovalChange(): true when an entry is not file-scoped and reports removed. Deliberately NOT keyed on changeType === "drive" - the legacy "teamDrive" value and any future scope Google introduces must take this path too. What identifies the case is "removed, and not about a specific file", which the caller has already established. - DriveChangeIdentity gained the optional removed flag. infra/lambdas/google-content-sync/index.ts - processGoogleDriveChange() now returns isDriveRemovalChange(change) from the non-file-scoped branch rather than a bare false. Returning true sets requiresSelectionSnapshot, so reconcileChanges runs reconcileSelectionSnapshot -> markUnseenSourcesMissing and retires whatever is no longer reachable; a hard access loss still surfaces through the existing 403/404 -> markConnectorAccessLost path. - The cursor advances on both branches. Skips take the per-page persistSyncCursor; removals take the post-snapshot persistSyncCursor. The invariant the outage broke stays intact either way. - The skip/removal log line now also carries driveId and time, so a spike in either can be correlated against Drive audit logs. Tests ----- tests/unit/google-content-sync-change-scope.test.ts - Truth table for isDriveRemovalChange: drive-scoped removal flagged; "teamDrive", an unknown future scope, and a changeType-less removal all covered; rename/membership noise (removed false or absent) ignored; a file-scoped removal explicitly NOT claimed, since that is the file path's job via markSourceMissing; a drive-scoped entry that also carries a fileId still treated as drive-scoped. - Wiring assertions that the removal signal is returned rather than swallowed, and that reconcileChanges persists the cursor on BOTH branches (asserted as exactly two persistSyncCursor call sites in the loop). Verification: eslint --max-warnings 0 clean; 573 suites / 5434 tests passing (the single failing suite, tests/unit/agent-workspace-identity.test.ts, fails identically on the clean tree - @aws-sdk/client-ecs is absent from package.json and bun.lock entirely).
ReviewSolid, well-scoped fix. The root cause diagnosis matches the code: Correctness
Nit (non-blocking): brittle meta-tests Security: none — purely internal Lambda parsing/control-flow logic, no new external input surface, no auth/IAM changes. Performance: negligible — one extra string comparison per change entry; Test coverage: thorough — truth table for Nothing else stood out — looks correct and ready to merge. |
Round 2 — Codex P1 addressed
Agreed, and fixed in bf5378c. The first commit skipped every non-file-scoped entry unconditionally, which silently consumed the one drive-scoped entry that carries real meaning.
The predicate is Other review notes, and what was deliberately left aloneTwo further items came out of internal review. Both are pre-existing weaknesses that this PR does not introduce and does not make worse, and both would widen a prod hotfix well past its root cause, so they are flagged rather than fixed here:
A third note — that Verification at bf5378c
The single failing unit suite ( |
|
🤖 pr-fix routine round 1 complete — no code changes needed. I re-read every piece of open feedback against the tree at
Two further items the author flagged in the round-2 comment — per-entry resilience in
This PR looks ready to merge. Nothing is blocking it but a human clicking merge. Labeling Generated by Claude Code |
Summary
Fixes the prod alarm
aistudio-prod-google-content-sync-oldest-message. Two connectors (b1429b0b-…,1f870c92-…) had synced nothing since 2026-08-06 ~12:00 UTC, and 904 messages had accumulated in the DLQ.Root cause
Google's Drive Changes feed (
GET /drive/v3/changes) is not exclusively file-scoped. Entries withchangeType: "drive"describe the Shared Drive itself — rename, membership change, restriction change — and carry nofileId.driveChangeSchemainlib/repositories/google-drive/drive-client.tsdeclaredfileIdas a requiredz.string(), sochangesListSchema.parse()threw aZodErrorinsidelistChangesthe moment such an entry appeared in a page. The throw happened beforereconcileChangescould persist the cursor, so every SQS redelivery re-fetched the identical page and failed identically. The message never drained, its age breached the 30-minute alarm threshold, and aftermaxReceiveCountit landed in the DLQ — repeatedly.Changes
lib/repositories/google-drive/drive-client.tsdriveChangeSchema.fileIdis nowz.string().optional().changeType: z.string().optional()— deliberately a plain string and not az.enum. An enum would re-poison the queue the first time Google ships achangeTypevalue we did not anticipate.listChangesnow requestschangeTypein its fields projection, so the API actually returns the discriminator the guard reads. Without this the projection would silently strip it.infra/lambdas/google-content-sync/changes.ts(new)isFileScopedDriveChange()— decides whether a change entry names a file the sync path can act on. ReturnsfalseforchangeType === "drive"and for a missing/emptyfileId; it is a type predicate, so the caller getsfileIdnarrowed tostring.isDriveRemovalChange()— true when a non-file-scoped entry reportsremoved. Deliberately not keyed onchangeType === "drive", so the legacy"teamDrive"value and any future scope take this path too.index.tsis not importable from the root Jest gate.infra/lambdas/google-content-sync/index.tsprocessGoogleDriveChange()now guards first, beforemarkSourceMissing()or any other side effect can run:return false.requiresSelectionSnapshotstays untouched, soreconcileChangesstill persists the cursor for that page.removed: true) →return true, requesting a selection snapshot. See below.markConnectorAccessLostpath; that behaviour is unchanged.recordDriveChangeFailure()— behaviour-identical, and it brings the function back under the complexity-15 lint ceiling that the new guard pushed it over.connectorId,changeType,driveIdandtime, so a spike in either can be correlated against Drive audit logs.Review follow-up: don't swallow drive removals (Codex P1)
The first commit skipped every non-file-scoped entry unconditionally. That is right for the metadata noise the outage was about, but it also silently consumed
removed: true— the entry Google emits when a Shared Drive is deleted or the account loses access.That would have been a data-correctness bug. Sources imported from that drive become unreachable, and:
listChanges/watch call against thatdriveIdreturns 403/404 andmarkConnectorAccessLostfires;Returning
truesetsrequiresSelectionSnapshot, soreconcileChangesrunsreconcileSelectionSnapshot→markUnseenSourcesMissingand retires whatever is no longer reachable. This reuses the same mechanism the pre-existing folder-mimeType path already uses rather than inventing a new one. The cursor advances on both branches — skips take the per-pagepersistSyncCursor, removals take the post-snapshot one — so the invariant the outage broke stays intact either way.Verification
eslint . --max-warnings 0): clean, zero warningsinfra/lambdas/agent-router/index.tsreproduces identically on the clean tree (verified viagit stash) — unrelated AWS SDK type duplicationbun run test:ci): 573 suites / 5434 tests passing. One pre-existing failure,tests/unit/agent-workspace-identity.test.ts, fails identically on the clean tree:@aws-sdk/client-ecsis imported byagent-router/index.tsbut is absent frompackage.jsonandbun.lockentirely. Not touched by this PRdecision-semantic-search,nexus-projects— both unrelated)New tests
tests/unit/lib/repositories/google-drive-changes-feed.test.tschangeType: "drive"entry with nofileIdplus a normal file entry now parses and returns both values (the exact shape that caused the outage).changeTypeparses rather than throwing.changeType.tests/unit/google-content-sync-change-scope.test.tsisFileScopedDriveChange: drive-scope wins even when afileIdis present; empty/missingfileIdskipped; unknownchangeTypewith afileIdprocessed;fileIdtype narrowing.isDriveRemovalChange: drive-scoped removal flagged;"teamDrive", an unknown future scope and achangeType-less removal all covered; rename/membership noise ignored; a file-scoped removal explicitly not claimed, since that is the file path's job viamarkSourceMissing.reconcileChangespersists the cursor on both branches.Evidence
N/A — no UI surface. This is a backend Lambda + API-client path, not reachable via Playwright; covered by the unit/contract tests above.
Operational notes