Repository navigation
Keep task errors when exception details exceed the database column #557
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: main
Are you sure you want to change the base?
Changes from all 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 | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,6 +15,11 @@ | |||||||||||||||
|
|
||||||||||||||||
| MAX_EVENT_MESSAGE_LENGHT = 1024 | ||||||||||||||||
|
|
||||||||||||||||
| # MySQL TEXT holds at most 65,535 bytes. utf8mb4 can use 4 bytes per | ||||||||||||||||
| # character, so this is the longest exception_details value guaranteed | ||||||||||||||||
| # to fit in the column. | ||||||||||||||||
| MAX_EXCEPTION_DETAILS_LENGTH = 65535 // 4 | ||||||||||||||||
|
Comment on lines
+18
to
+21
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. Medium · confirmed · The cap uses 4 bytes per character on a utf8 column. The diff sets Why The Added
Documentation
Evidence
Proposed fix (version 1 of 1, accepted)This version sets the cap to Suggested change (accepted)
Suggested change
Discussion with the review agentVersion 1 was run. model: |
||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class TaskEvent(BASE, models.TimestampMixin, models.SoftDeleteMixin, models.ModelBase): | ||||||||||||||||
| __tablename__ = 'task_event' | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -880,6 +880,83 @@ def test_set_execution_status_update_action_status(self): | |||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(self.valid_transfer.last_execution_status, new_status) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def test_set_task_status_stores_short_exception_details(self): | ||||||||||||||||||||||||||||||||||||||||||||||||
| task = self._new_task() | ||||||||||||||||||||||||||||||||||||||||||||||||
| details = "Failed to install required packages." | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| api.set_task_status(self.context, task.id, constants.TASK_STATUS_ERROR, details) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| stored = api.get_task(self.context, task.id) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(constants.TASK_STATUS_ERROR, stored.status) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(details, stored.exception_details) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def test_set_task_status_records_status_when_exception_details_are_too_long(self): | ||||||||||||||||||||||||||||||||||||||||||||||||
| task = self._new_task() | ||||||||||||||||||||||||||||||||||||||||||||||||
| max_length = models.MAX_EXCEPTION_DETAILS_LENGTH | ||||||||||||||||||||||||||||||||||||||||||||||||
| details = "start-" + ("x" * (max_length * 3)) + "-end" | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| api.set_task_status(self.context, task.id, constants.TASK_STATUS_ERROR, details) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| stored = api.get_task(self.context, task.id) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(constants.TASK_STATUS_ERROR, stored.status) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertLessEqual(len(stored.exception_details), max_length) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertLessEqual(len(stored.exception_details.encode("utf-8")), 65535) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertTrue(stored.exception_details.startswith("start-")) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertTrue(stored.exception_details.endswith("-end")) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertIn("characters truncated", stored.exception_details) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def _new_task(self): | ||||||||||||||||||||||||||||||||||||||||||||||||
| task = models.Task() | ||||||||||||||||||||||||||||||||||||||||||||||||
| task.id = str(uuid.uuid4()) | ||||||||||||||||||||||||||||||||||||||||||||||||
| task.execution = self.valid_tasks_execution | ||||||||||||||||||||||||||||||||||||||||||||||||
| task.instance = DEFAULT_INSTANCE | ||||||||||||||||||||||||||||||||||||||||||||||||
| task.status = constants.TASK_STATUS_RUNNING | ||||||||||||||||||||||||||||||||||||||||||||||||
| task.task_type = constants.TASK_TYPE_VALIDATE_TRANSFER_SOURCE_INPUTS | ||||||||||||||||||||||||||||||||||||||||||||||||
| task.index = 1000 + len(self.valid_tasks_execution.tasks) | ||||||||||||||||||||||||||||||||||||||||||||||||
| task.on_error = False | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.session.add(task) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.session.commit() | ||||||||||||||||||||||||||||||||||||||||||||||||
| return task | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| class ExceptionDetailsFitTestCase(test_base.CoriolisBaseTestCase): | ||||||||||||||||||||||||||||||||||||||||||||||||
| def test_fit_exception_details_leaves_short_values_unchanged(self): | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertIsNone(api._fit_exception_details("task-id", None)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual("", api._fit_exception_details("task-id", "")) | ||||||||||||||||||||||||||||||||||||||||||||||||
| details = "a" * models.MAX_EXCEPTION_DETAILS_LENGTH | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(details, api._fit_exception_details("task-id", details)) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @mock.patch.object(api.models, "MAX_EXCEPTION_DETAILS_LENGTH", 80) | ||||||||||||||||||||||||||||||||||||||||||||||||
| def test_fit_exception_details_keeps_head_and_tail(self): | ||||||||||||||||||||||||||||||||||||||||||||||||
| details = ("HEAD-" * 20) + ("x" * 200) + ("-TAIL" * 20) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| fitted = api._fit_exception_details("task-id", details) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertLessEqual(len(fitted), 80) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertTrue(fitted.startswith("HEAD-")) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertTrue(fitted.endswith("-TAIL")) | ||||||||||||||||||||||||||||||||||||||||||||||||
| marker_start = fitted.index("\n... (") | ||||||||||||||||||||||||||||||||||||||||||||||||
| marker_end = fitted.index(") ...\n") + len(") ...\n") | ||||||||||||||||||||||||||||||||||||||||||||||||
| marker = fitted[marker_start:marker_end] | ||||||||||||||||||||||||||||||||||||||||||||||||
| head, tail = fitted.split(marker) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(details[: len(head)], head) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(details[-len(tail) :], tail) | ||||||||||||||||||||||||||||||||||||||||||||||||
| omitted = len(details) - len(head) - len(tail) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertIn(f"({omitted} characters truncated)", marker) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertLessEqual(len(fitted.encode("utf-8")), 65535) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def test_fit_exception_details_stays_within_mysql_text_for_multibyte_text(self): | ||||||||||||||||||||||||||||||||||||||||||||||||
| # U+10000 encodes to 4 bytes in UTF-8, the utf8mb4 worst case. | ||||||||||||||||||||||||||||||||||||||||||||||||
| four_byte_char = "\U00010000" | ||||||||||||||||||||||||||||||||||||||||||||||||
| details = four_byte_char * (models.MAX_EXCEPTION_DETAILS_LENGTH + 500) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| fitted = api._fit_exception_details("task-id", details) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertLessEqual(len(fitted), models.MAX_EXCEPTION_DETAILS_LENGTH) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertLessEqual(len(fitted.encode("utf-8")), 65535) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertTrue(fitted.startswith(four_byte_char)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertTrue(fitted.endswith(four_byte_char)) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+948
to
+958
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. Medium · confirmed · The multibyte test keeps a supplementary character. The test builds the value from U+10000 and expects that character at both ends. That treats Why The test must follow the Added
Documentation
Evidence
Proposed fix (version 1 of 1, accepted)This version uses U+4F60 for the long value and expects one U+10000 character to become Suggested change (accepted)
Suggested change
Discussion with the review agentVersion 1 was run. model: |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| class TransferSchedulesDBAPITestCase(BaseDBAPITestCase): | ||||||||||||||||||||||||||||||||||||||||||||||||
| @classmethod | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
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.
Medium · confirmed · A character above U+FFFF is returned unchanged.
_fit_exception_detailsreturns the original text when its length is within the cap. Ten U+10000 characters came back unchanged. Charsetutf8does not include that character. A MariaDB or MySQL run was not done, so a rejected status update stays suspected.Why
A character above U+FFFF is outside this
utf8column, so the status update can still fail.Added
?before the length check.Documentation
utf8is an alias forutf8mb3, and no supplementary characters are stored.Evidence
api.py:915returnsexception_detailswhenoriginal_lengthis withinMAX_EXCEPTION_DETAILS_LENGTH. With the committed cap, ten U+10000 characters were returned unchanged. After this change they became ten?characters. The truncation loop is unchanged. The server was not run.Proposed fix (version 1 of 1, accepted)
This version replaces a character above U+FFFF with
?before the length check.Suggested change (accepted)
Discussion with the review agent
Version 1 was run.
ruff formatleft the file unchanged,ruff checkpassed, and thetest_apiunit tests passed. No later version was written.model:
Grok 4.7