-
Notifications
You must be signed in to change notification settings - Fork 0
fix: stop the Leantime sync halting silently on a bad row #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,7 @@ public function __invoke(UpsertIssueMessage $message): void | |
| try { | ||
| $this->logger->info('Upserting issue: '.$message->issueData->name); | ||
| $this->dataProviderService->upsertIssue($message->issueData); | ||
| } catch (\Exception $e) { | ||
| } catch (\Throwable $e) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Eight handlers carry a byte-identical catch body, so Not blocking. Follow-up: a Messenger middleware or a shared trait. |
||
| $this->logger->error($e->getMessage()); | ||
| throw new UnrecoverableMessageHandlingException($e->getMessage()); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,9 @@ class LeantimeApiService implements DataProviderInterface | |
| public const TIMESHEETS = 'timesheets'; | ||
| public const WORKERS = 'workers'; | ||
| private const LIMIT = 100; | ||
| // Placeholder for a null name; the name columns are not nullable, and dropping the row would | ||
| // lose real data — for issues it would make their worklogs unstorable. | ||
| private const NAME_MISSING = '(no name)'; | ||
| private const QUEUE_ASYNC = 'async'; | ||
| private const QUEUE_SYNC = 'sync'; | ||
|
|
||
|
|
@@ -116,7 +119,9 @@ public function deleteAsJob(int $dataProviderId, bool $asyncJobQueue = false, ?\ | |
|
|
||
| $params = [ | ||
| 'types' => $types, | ||
| 'deletedAfter' => $deletedAfter?->getTimestamp(), | ||
| // The plugin reads 'deleted'; anything else is discarded and the whole deletion | ||
| // history is returned, which /deleted does not paginate. | ||
| 'deleted' => $deletedAfter?->getTimestamp(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — and the only change here without a test. The client is mocked with Pin it with |
||
| ]; | ||
|
|
||
| // Get data from Leantime. | ||
|
|
@@ -137,6 +142,13 @@ public function deleteAsJob(int $dataProviderId, bool $asyncJobQueue = false, ?\ | |
| }; | ||
|
|
||
| foreach ($results->{$type} as $result) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This loop got the null-id guard but no It used to self-heal because Same per-row |
||
| // Nothing identifies the entity to remove, and this loop has no other guard. | ||
| if (null === $result->id) { | ||
| $this->logger->warning(sprintf('Skipping deleted %s entry with no id', $type)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
One level for both paths. |
||
|
|
||
| continue; | ||
| } | ||
|
|
||
| $projectTrackerId = $result->id; | ||
| $deletedDate = $this->getLeanDateTime($result->deletedDate); | ||
|
|
||
|
|
@@ -208,6 +220,11 @@ public function updateAsJob(string $className, int $startId, int $limit, int $da | |
|
|
||
| private function dispatchUpsertMessage(string $className, object $data, int $dataProviderId, \DateTimeInterface $fetchDate, bool $asyncJobQueue = false, ?string $dataProviderUrl = null, bool $disableModifiedAtCheck = false): void | ||
| { | ||
| // Catch \Throwable, not \Exception: a nullable source field mapped onto a non-nullable | ||
| // constructor argument raises a TypeError, which extends Error. Uncaught, it escapes the | ||
| // row loop in updateAsJob() before the next page is queued, halting the sync silently. | ||
| // The dispatch belongs inside the try for the same reason: on the sync transport the | ||
| // handler runs inline here, so its failures surface as part of this call. | ||
| try { | ||
| $message = match ($className) { | ||
| Project::class => new UpsertProjectMessage($this->getProjectUpsertFromResult($data, $dataProviderId, $fetchDate, $dataProviderUrl, $disableModifiedAtCheck)), | ||
|
|
@@ -217,17 +234,15 @@ private function dispatchUpsertMessage(string $className, object $data, int $dat | |
| Worker::class => new UpsertWorkerMessage($this->getWorkerUpsertFromResult($data, $dataProviderId, $fetchDate)), | ||
| default => null, | ||
| }; | ||
| } catch (\Exception $e) { | ||
| $this->logger->error($e->getMessage()); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| if (null !== $message) { | ||
| $this->messageBus->dispatch( | ||
| $message, | ||
| [new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)], | ||
| ); | ||
| if (null !== $message) { | ||
| $this->messageBus->dispatch( | ||
| $message, | ||
| [new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)], | ||
| ); | ||
| } | ||
| } catch (\Throwable $e) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Catch |
||
| $this->logger->error(sprintf('Skipping %s id %s: %s', $className, $data->id ?? '?', $e->getMessage())); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -237,7 +252,7 @@ private function getProjectUpsertFromResult(object $result, int $dataProviderId, | |
|
|
||
| return new DataProviderProjectData( | ||
| $dataProviderId, | ||
| $result->name, | ||
| $result->name ?? self::NAME_MISSING, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two nameless projects both become Storing instead of failing is right — make it unique: |
||
| $projectTrackerId, | ||
| $this->linkToProject($projectTrackerId, $dataProviderUrl), | ||
| $fetchDate, | ||
|
|
@@ -248,9 +263,14 @@ private function getProjectUpsertFromResult(object $result, int $dataProviderId, | |
|
|
||
| private function getVersionUpsertFromResult(object $result, int $dataProviderId, \DateTimeInterface $fetchDate, bool $disableModifiedAtCheck = false): DataProviderVersionData | ||
| { | ||
| // A version cannot exist without a project; Version::$project is not nullable. | ||
| if (null === $result->projectId) { | ||
| throw new NotAcceptableException('Version upsert not acceptable: projectId is null'); | ||
| } | ||
|
|
||
| return new DataProviderVersionData( | ||
| $dataProviderId, | ||
| $result->name, | ||
| $result->name ?? self::NAME_MISSING, | ||
| (string) $result->id, | ||
| (string) $result->projectId, | ||
| $fetchDate, | ||
|
|
@@ -267,7 +287,7 @@ private function getIssueUpsertFromResult(object $result, int $dataProviderId, \ | |
| $projectTrackerId, | ||
| $dataProviderId, | ||
| (string) $result->projectId, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The worklog fixture (id 101) already has |
||
| $result->name, | ||
| $result->name ?? self::NAME_MISSING, | ||
| $result->tags, | ||
| $result->plannedHours, | ||
| $result->remainingHours, | ||
|
|
@@ -291,13 +311,20 @@ private function getWorklogUpsertFromResult(object $result, int $dataProviderId, | |
| throw new NotAcceptableException('Worklog upsert not acceptable: startedDate is null'); | ||
| } | ||
|
|
||
| // A worklog cannot exist without an issue; Worklog::$issue is not nullable. | ||
| if (null === $result->ticketId) { | ||
| throw new NotAcceptableException('Worklog upsert not acceptable: ticketId is null'); | ||
| } | ||
|
|
||
| return new DataProviderWorklogData( | ||
| $result->id, | ||
| $dataProviderId, | ||
| (string) $result->ticketId, | ||
| $result->description, | ||
| $startedDate, | ||
| $result->username, | ||
| // A null username means the join found no user row, as Leantime never stores a null | ||
| // one. The hours are still real, so keep the worklog and name the departed user. | ||
| $result->username ?? 'deleted-user-'.($result->userId ?? 'unknown'), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This overwrites a correct value. Only apply the fallback when |
||
| $result->hours, | ||
| $result->kind, | ||
| $fetchDate, | ||
|
|
@@ -343,7 +370,7 @@ private function getLeanDateTime(?string $dateString): ?\DateTimeInterface | |
| return new \DateTime($dateString, new \DateTimeZone('UTC')); | ||
| } | ||
|
|
||
| private function convertStatusToEnum(string $statusString): IssueStatusEnum | ||
| private function convertStatusToEnum(?string $statusString): IssueStatusEnum | ||
| { | ||
| return match ($statusString) { | ||
| 'NEW' => IssueStatusEnum::NEW, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unrelated to the sync fix. Flips the default docker wrapper to
docker composefor every developer, and line 150 switches fixtures from hautelook to doctrine — no CHANGELOG entry, shipping under a PR titled as a sync fix.Own PR.