Skip to content

Stop user-data errors leaking to Sentry from migrations#177

Open
premtsd-code wants to merge 5 commits intomainfrom
fix-migration-sentry-leak
Open

Stop user-data errors leaking to Sentry from migrations#177
premtsd-code wants to merge 5 commits intomainfrom
fix-migration-sentry-leak

Conversation

@premtsd-code
Copy link
Copy Markdown
Contributor

Summary

Per-resource user errors thrown by Destinations/Appwrite.php were leaking to Sentry through the migrations worker. This PR fixes the routing at the library layer.

  • Convert ~15 throw new Exception(...) user-error sites in create*() methods to setStatus + addError + return false. No exception bubbles for expected outcomes; the failure is recorded where detected.
  • Per-resource wrap at import() becomes a pure safety net: record + rethrow. Library bugs and infra failures bubble through; user errors stay in the migration report.
  • Row-flush block catches DuplicateException and StructureException from the database adapter and re-throws as Migration\Exception so the wrap classifies them as user-facing.
  • Critical context: Migration\Exception is now the library's marker for "user-facing migration error". The Appwrite worker uses instanceof Migration\Exception to decide Sentry routing.

Motivation

Errors like "Document already exists", "Attribute already exists", "Cannot set default value for array column", "Invalid document structure: Missing required attribute X" were being thrown with code 0 or PDO codes (e.g. 23000), and the migration worker's code === 0 || >= 500 heuristic re-published every one of them to Sentry. ~80% of the noise on the appwrite-queue-migrations Sentry stream came from this path.

After this PR, expected user-data conditions never throw; only bugs/infra do.

Test plan

  • Migration with a duplicate column → "Attribute already exists" appears in the migration report, no Sentry event
  • Migration with a duplicate row → "Document already exists" in the report only
  • Migration with malformed source data → "Invalid document structure" in the report only
  • Library bug (synthetic TypeError) → recorded in report and reaches Sentry with full trace

Two changes in Destinations/Appwrite.php:

1. Per-resource wrap rethrows non-Migration\Exception throwables. User
   errors still record on the migration document and the loop continues
   (existing behaviour). Library bugs and infra failures now bubble to
   the worker boundary so they can be routed to Sentry instead of being
   silently absorbed.

2. Row-flush match block catches DuplicateException and StructureException
   from the database adapter and rethrows them as Migration\Exception so
   the per-resource wrap above recognises them as user-facing failures.
   This keeps "Document already exists" / invalid structure messages in
   the migration report only.
Convert per-resource user-error throws into setStatus + addError + return false
in the create*() methods (createDatabase, createEntity, createField,
createIndex, createRecord). Failures are recorded for the migration report
where they're detected, and the import loop continues to the next resource —
matching the existing skip-from-cache pattern at line 1015 and the
documents-DB skip at line 535.

The per-resource wrap at import() simplifies to a pure safety net: record
for the user, then rethrow. By construction, anything reaching it is a
library bug or infra failure, which the worker boundary routes to Sentry.

Row-flush DuplicateException / StructureException are caught at the flush
site and recorded inline — same shape as the rest of the create*() methods.

Behaviour preserved: user errors continue to appear in the migration report,
the loop continues across resources, the migration ends with status=failed
when any resource failed. Only routing to Sentry changed.
DB adapters already normalise DuplicateException's message to
'Document already exists', so a multi-type catch with $e->getMessage()
collapses both branches into one without changing user-visible behaviour.

Trimmed the safety-net comment to a single line.
Same shape as the row-flush combine: multi-type catch with a small
ternary for the per-type message. Two pairs collapsed: the single
attribute path and the relationship two-way path (which differ only
in 'Attribute limit exceeded' vs 'Column limit exceeded').
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR converts user-facing error sites in Destinations/Appwrite.php from throw new Exception(...) to setStatus + addError + return false, so expected data errors stay in the migration report and never reach the worker's Sentry boundary. The import() catch block is changed from silently swallowing exceptions to rethrowing them, making infrastructure failures visible to the worker.

  • Behavioral break risk: throw $e in import() aborts the entire foreach loop — $callback($resources) is never called when any infra error occurs mid-batch. The worker must handle this exception and advance migration state; confirm the worker-side catch covers this.
  • Wrong document attributed in batch failures: DuplicateException/StructureException catches in the row-flush block record $resource->getId() (the last buffered document) even though the actual culprit may be any document in $this->rowBuffer.
  • createIndex() still wraps all DB failures as Migration\Exception: real adapter errors caught there propagate as Migration\Exception and will be suppressed from Sentry by the worker's instanceof check, undercutting the PR's Sentry-routing goal for infra failures.

Confidence Score: 3/5

Not safe to merge without confirming worker-side exception handling for the new rethrow path.

The goal and the bulk of the changes are correct, but the throw $e behavioral change (P1) is not verifiable from this library alone — it requires the Appwrite worker to correctly handle an exception from Destination::run() that previously never escaped. Two additional P2s (batch error attribution, createIndex Sentry gap) lower confidence further.

src/Migration/Destinations/Appwrite.php — specifically the import() catch block (throw $e) and createIndex() inner catch.

Important Files Changed

Filename Overview
src/Migration/Destinations/Appwrite.php Converts ~15 user-error throw sites to setStatus+addError+return false, and changes import()'s catch to rethrow (throw $e) instead of swallowing; introduces batch-attribution ambiguity and unverified callback-abort risk for infra errors.

Comments Outside Diff (1)

  1. src/Migration/Destinations/Appwrite.php, line 979-994 (link)

    P2 createIndex() still wraps infra failures as Migration\Exception — they will be silenced by the worker

    The inner catch (lines 986–994) catches any \Throwable (including real DB/adapter failures) and re-throws it as a Migration\Exception. With the new throw $e in import(), this Migration\Exception now propagates to the worker. If the worker uses instanceof Migration\Exception to suppress Sentry routing, genuine infrastructure failures during index creation will never appear in Sentry — contradicting the PR's stated goal of routing "library bugs and infra failures" to Sentry. Consider catching only DuplicateException/LimitException as user errors here and letting other \Throwable propagate raw, or documenting why index-creation failures are intentionally treated as user-facing.

Reviews (1): Last reviewed commit: "Revert catch combines; keep typed catche..." | Re-trigger Greptile

Comment on lines 335 to +349
@@ -344,7 +346,7 @@ protected function import(array $resources, callable $callback): void
previous: $e
));

$responseResource = $resource;
throw $e;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 throw $e aborts the entire batch — callback never called

The new throw $e exits the foreach loop immediately, so any resources after the failing index are never processed and $callback($resources) at line 357 is never invoked. Before this PR the old $responseResource = $resource path swallowed the exception and let the loop (and callback) complete normally.

If the worker-side run() caller doesn't have a catch block that also advances migration state, marks remaining resources, and updates the progress counter, those resources will be stuck in whatever pre-loop status they had. Verify the Appwrite worker wraps Destination::run() in a catch and performs the necessary cleanup before assuming this is safe to merge.

Comment on lines +1133 to +1152
} catch (DuplicateException $e) {
$resource->setStatus(Resource::STATUS_ERROR, 'Document already exists');
$this->addError(new Exception(
resourceName: $resource->getName(),
resourceGroup: $resource->getGroup(),
resourceId: $resource->getId(),
message: 'Document already exists',
previous: $e,
));
return false;
} catch (StructureException $e) {
$resource->setStatus(Resource::STATUS_ERROR, $e->getMessage());
$this->addError(new Exception(
resourceName: $resource->getName(),
resourceGroup: $resource->getGroup(),
resourceId: $resource->getId(),
message: $e->getMessage(),
previous: $e,
));
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Error is attributed to the wrong document in batch-flush failures

$resource here is the last document in the $this->rowBuffer batch (the one with $isLast = true), but createDocuments/upsertDocuments operates on the entire buffer. When DuplicateException or StructureException is thrown, the actual culprit could be any document in the buffer. Recording resourceId: $resource->getId() in the error report will mislead users into thinking the last document is the duplicate/malformed one when it could be one of the many earlier buffered rows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant