[SYNPY-1912] Fix upsert_rows misreporting Table update responses - #1446
Open
andrewelamb wants to merge 13 commits into
Open
[SYNPY-1912] Fix upsert_rows misreporting Table update responses#1446andrewelamb wants to merge 13 commits into
andrewelamb wants to merge 13 commits into
Conversation
andrewelamb
marked this pull request as draft
August 17, 2026 19:08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The final branch of __post_init__ raised whenever both fields were given, even when they held the same value, which contradicted its own error message. It now raises only when the two values differ. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
andrewelamb
marked this pull request as ready for review
August 19, 2026 17:56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem:
JIRA: SYNPY-1912
Every successful
Table.upsert_rows()call logs a false failure claim. Reported by a user on 4.12, still present ondevelopat 4.13.0:All 5 updates and both inserts were applied. The two halves of the message contradict each other by construction: the failure count always equals the full number of updated rows, so it is never a real partial count.
Reproduction: store 5 rows in a Table, then
upsert_rows()those 5 keys with new values plus 2 new keys. A follow-up query confirms all 7 rows are correct, while the client has already logged "5 rows could not be updated."Root cause
TableUpdateTransaction.fill_from_dict()insynapseclient/models/table_components.pyrecognised only one response shape. It looked for anupdateResultskey and collectedentityIdvalues that carry nofailureCodeorfailureMessage.updateResultsis a view-only concept. A Table upsert sends anAppendableRowSetRequestfor the update half and anUploadToTableRequestfor the insert half. The server answers withRowReferenceSetResultsandUploadToTableResultrespectively. Neither carriesupdateResults, and neither carriesentityId, because table rows are not entities. Soentities_with_changes_appliedstayedNonefor every table upsert.The message logic in
synapseclient/models/mixins/table_components.pythen drew the wrong conclusion:total_row_count_actually_updated or total_row_count_to_updatefallback, so 0 was falsy and the correct count (5) was printed.The three defects masked each other, which is why the message was self-contradictory rather than simply wrong. All three had to change together, or the message would print 0.
The raw response recorded from production Synapse for the update half (syn76890550):
{ "concreteType": "org.sagebionetworks.repo.model.table.RowReferenceSetResults", "rowReferenceSet": { "tableId": "syn76890550", "etag": "5aac0c05-c0dc-4119-b284-4c394a6044aa", "rows": [ {"rowId": 1, "versionNumber": 2}, {"rowId": 2, "versionNumber": 2}, {"rowId": 3, "versionNumber": 2}, {"rowId": 4, "versionNumber": 2}, {"rowId": 5, "versionNumber": 2} ] } }Five row references, no
failureCodeand nofailureMessageanywhere. The insert half returnedUploadToTableResultwithrowsProcessed2.Secondary defects in the same code
row_update_resultswas assigned rather than accumulated inside the per-query-chunk loop. Withrows_per_querydefaulting to 50000, any upsert over 50k rows discarded all but the last chunk's results, so the count was wrong even on views, where the parsing did work.failureCodeandfailureMessagewere read and then discarded. A genuine failure gave the user a bare count and no diagnostic detail.wait_for_eventually_consistent_viewon, a non-empty tracking dict, and a final chunk that pushed nothing,_wait_for_eventually_consistent_changesiteratedNoneand raisedTypeError.Solution:
Nothing about how the upsert stores data changes. Only how the client accounts for what the server reported.
Model every
TableUpdateResponsetypeRather than adding shape-sniffing branches inline, every response type Synapse can return is now a dataclass, so the count comes off a typed attribute instead of a dict key. In
synapseclient/models/table_components.py:TableUpdateResponse— abstract base holdingconcrete_type, an abstractfill_from_dictclassmethod, and arows_changedproperty returningNoneby default.EntityUpdateResults—update_results: list[EntityUpdateResult];rows_changedcounts entities with no reported failure.RowReferenceSetResults—row_reference_set;rows_changedis the row count. This is the table update half.UploadToTableResult—rows_processed,etag;rows_changedisrows_processed. This is the table insert half.TableSchemaChangeResponseandTableSearchChangeResponse—rows_changedstaysNone, since neither applies rows.UnknownTableUpdateResponse— holds the raw response indata: dict. Returned when a response cannot be identified, so a response type added to Synapse after this release neither raises nor is miscounted.RowReference,RowReferenceSet,EntityUpdateResult(with asucceededproperty), and theEntityUpdateFailureCodeenum, which coerces an unrecognised code string toUNKNOWNrather than raising.table_update_response_from_dict()dispatches onconcreteType, falls back to a distinguishing key (rowReferenceSet,rowsProcessed,updateResults,schema,searchEnabled), then toUnknownTableUpdateResponse.TableUpdateTransactionnow derives three aggregates fromresultson each access and stores none of them:total_rows_changed— the sum ofrows_changedover every response that reports one, so table row updates, table inserts, and view entity updates all contribute.Nonebefore the transaction is sent,0when nothing changed.failed_entity_updates— the failures of everyEntityUpdateResultsinresults, with their retainedentity_id,failure_code, andfailure_message.entities_with_changes_applied— unchanged in meaning, now computed from the modelled responses instead of a second hand-rolled walk of the raw array. It is still consumed as dictionary keys for the eventually-consistent view wait, so it must hold entity IDs and must stayNonerather than[]when nothing succeeded.fill_from_dictalso had a latent defect: a response carrying"results": nullraisedTypeError. The guard is nowif synapse_response.get("results", None).Fix the reporting
In
synapseclient/models/mixins/table_components.py:row_update_resultsis now a list that is extended per query chunk instead of overwritten, so upserts overrows_per_queryreport correct totals. This also removes the latentTypeErrorabove._upsert_rows_asyncinto a new module-level_log_upsert_summary()._upsert_rows_asyncnow reads as query, update, report, insert.total_rows_changedover the accumulated transactions. Theor total_row_count_to_updatefallback is gone, replaced by an explicit branch on whether anything was pushed, so adry_run(or a live run where nothing matched) still reports the planned count, while a confirmed count of 0 now prints as 0.. 2 rows could not be updated: syn123 (NOT_FOUND); syn124 (UNKNOWN: detail). A count alone was not actionable.Found {n} rows to update and {m} rows to insertis byte-for-byte unchanged.For a Table,
failed_entity_updatesis always empty, which is correct rather than a gap: a rejected table update fails the async job and raises before the reporting runs. For a Table the honest report is a confirmed count, never a silent partial.Testing:
Unit tests
tests/unit/synapseclient/mixins/unit_test_table_components.py— seven new classes, 95 tests. Full unit module: 287 tests pass.TestTableUpdateResponseFromDict— dispatch: one case per known concrete type, one per distinguishing-key fallback, an unrecognised concrete type that still carries a known key, an unidentifiable response, an empty dict, and thatTableUpdateResponse()cannot be instantiated.TestTableUpdateResponseRowsChanged— a 12-case parametrized table overrows_changed, plus the non-count fields of each subclass. The cases pin0againstNone:rows: []androwsProcessed: 0give0, while an absentrowReferenceSetand an absentrowsProcessedgiveNone.TestEntityUpdateResult—succeededacross all four code/message combinations, coercion of every documented failure code and of an unrecognised one, message retention, the split intosuccessful_entity_idsandfailed_entity_updates, a success with noentityId, andupdate_resultsofNone.TestTableUpdateTransactionFillFromDict—total_rows_changedfor table and view responses and summed across three response types in one transaction, an unmodelled response contributing nothing, the failure detail per failure, failures flattened across responses,Noneversus0versus[]for each aggregate, andsnapshot_version_numberfilled alongside the modelled responses.TestTableUpdateRequest— a parametrized check that all four request classes are aTableUpdateRequest, which guards against a fifth type being added without the base; that the base cannot be instantiated; the search change payload includingsearch_enabled=Falsebeing sent asFalserather than dropped;UploadToTableRequest.entity_idreportingtable_id; a parametrized table over thetable_id/entity_idaliasing onUploadToTableRequest— either alone or both with the same value fill the other field, while neither given or two different values raiseValueError; and one transaction carrying all four changes in the order given.TestUpsertRowsResultReporting— 10 tests driving_upsert_rows_asyncdirectly with a minimal Table and View entity, patching_push_row_updates_to_synapseand asserting onclient.logger. This is the first credential-free coverage of the reported defect: the byte-for-byte success message with no failure clause, accumulation across three query chunks,dry_runreporting the planned count, a confirmed count of0printing as0with the gap logged at debug level, and a 5-case parametrized table over the failure-clause format.TestLogUpsertSummary— 12 tests on the extracted helper, using realRowReferenceSetResultsandEntityUpdateResultspayloads rather than mocks, so thetotal_rows_changedandfailed_entity_updatesproperties are actually exercised. Covers the dry-run path, summing across transactions, responses carrying no row count, a 4-case parametrized failure-clause table, failures flattened across transactions suppressing the debug gap, a shortfall with no failure logging the gap, and more rows confirmed than sent logging no gap (the<boundary).Integration tests
tests/integration/synapseclient/models/async/test_table_async.pycapture_client_logs()— module-level helper that attaches a handler directly tosyn.logger. Needed because the integrationsynfixture usesSILENT_LOGGER_NAME, which haspropagate: False, socaplogsees nothing.TestUpsertRows.test_upsert_reports_accurate_row_counts— stores 5 rows, upserts those 5 keys with new values plus 2 new keys, asserts the table holds all 7 correct rows, then asserts the logged message containsFound 5 rows to update and 2 rows to insertand does not containcould not be updated.rows_per_query:50000(single query chunk) and2(four query chunks). The response modelling fixes the first case; the accumulation fix is what fixes the second.