From 41d70cfe009b1878f4b17a8fa75f0e71d5e8ab2d Mon Sep 17 00:00:00 2001 From: Olha Kramarenko Date: Mon, 26 Oct 2026 16:07:11 +0200 Subject: [PATCH] Widen SingleStoreBulkUpdateResult row counts to long --- docs/VersionHistory.md | 6 ++++ .../RowsAffected.md | 4 ++- .../RowsMatched.md | 2 +- docs/content/tutorials/bulk-update.md | 2 +- src/SingleStoreConnector/Logging/Log.cs | 8 +++--- .../SingleStoreBulkUpdate.cs | 28 +++++++++++++++---- .../SingleStoreBulkUpdateResult.cs | 12 ++++---- 7 files changed, 44 insertions(+), 18 deletions(-) diff --git a/docs/VersionHistory.md b/docs/VersionHistory.md index 115fcd656..5344a2fdb 100644 --- a/docs/VersionHistory.md +++ b/docs/VersionHistory.md @@ -2,6 +2,12 @@ ## Release Notes +### Unreleased + +* **Breaking** (experimental API) `SingleStoreBulkUpdateResult.RowsMatched` is now `long?` and `SingleStoreBulkUpdateResult.RowsAffected` is now `long`. + Previously, a bulk update whose match count exceeded `int.MaxValue` failed with an `OverflowException`, and the affected-row + count could not represent values above `int.MaxValue`. Both counts are now reported exactly. + ### 1.4.1 * Add `SingleStoreBulkUpdate` for bulk `UPDATE` operations: [#20](https://github.com/memsql/SingleStoreNETConnector/pull/20) diff --git a/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsAffected.md b/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsAffected.md index 987ae5571..d2f9f8273 100644 --- a/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsAffected.md +++ b/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsAffected.md @@ -3,13 +3,15 @@ The number of rows affected by the `UPDATE`, as reported by the server. ```csharp -public int RowsAffected { get; } +public long RowsAffected { get; } ``` ## Remarks The exact meaning depends on the connection's [`UseAffectedRows`](../SingleStoreConnectionStringBuilder/UseAffectedRows.md) setting. With the default (`UseAffectedRows=false`) this is the number of rows matched by the update — including rows that already held the new values — and therefore typically equals [`RowsMatched`](./RowsMatched.md). With `UseAffectedRows=true` it is the number of rows whose values actually changed. +Unlike [`ExecuteNonQuery`](../SingleStoreCommand/ExecuteNonQuery.md), this value is not clamped to int.MaxValue; it is the exact count reported by the server. + ## See Also * class [SingleStoreBulkUpdateResult](../SingleStoreBulkUpdateResult.md) diff --git a/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsMatched.md b/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsMatched.md index 7c76223a5..58d77afa6 100644 --- a/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsMatched.md +++ b/docs/content/api/SingleStoreConnector/SingleStoreBulkUpdateResult/RowsMatched.md @@ -3,7 +3,7 @@ The number of destination rows matched by the join on the key columns, or `null` when [`ComputeRowsMatched`](../SingleStoreBulkUpdate/ComputeRowsMatched.md) was set to `false` and the count was not computed. ```csharp -public int? RowsMatched { get; } +public long? RowsMatched { get; } ``` ## Remarks diff --git a/docs/content/tutorials/bulk-update.md b/docs/content/tutorials/bulk-update.md index e2669136a..c00597617 100644 --- a/docs/content/tutorials/bulk-update.md +++ b/docs/content/tutorials/bulk-update.md @@ -134,7 +134,7 @@ This API is experimental. Several of the limitations above are deliberate scope * **`KeyColumns` is required.** A future version could auto-detect the destination table's primary key when `KeyColumns` is not set. * **Expression column mappings are rejected.** Because the source is staged into a real temporary table, a `SingleStoreBulkCopyColumnMapping.Expression` (which relies on user variables during `LOAD DATA`) is not supported; a future version could apply expressions when populating the staging table. * **Generated (computed) columns cannot be mapped.** This could be supported for key columns if the staging table learned to reproduce a generated column's underlying type. -* **Row counts are `int`.** `RowsStaged`, `RowsMatched`, and `RowsAffected` are `int` for consistency with `SingleStoreBulkCopyResult`; they could widen to `long` if very large updates need it. +* **Server-reported row counts are `long`.** `RowsMatched` and `RowsAffected` are `long` so that updates touching more than `int.MaxValue` rows are reported exactly (unlike `SingleStoreCommand.ExecuteNonQuery`, which clamps to `int.MaxValue`). `RowsStaged` stays `int`, since it is counted client-side and mirrors `SingleStoreBulkCopy.RowsCopied`. * **Update only.** There is no upsert (insert-or-update) mode; a future version could add one. * **Reference tables are not supported as the destination.** The `UPDATE ... JOIN` this API generates fails on a reference-table target joined to a (sharded) staging table (SingleStore error 1706). This is a scope decision, not a hard limit — a future version could support it by staging into a reference table or by using an upsert, per the [documented workarounds](https://docs.singlestore.com/cloud/reference/troubleshooting-reference/query-errors/error-1706-hy-000-feature-multi-table-update-delete-with-a-reference-table-as-target-table-is-not-supported-by-memsql/). diff --git a/src/SingleStoreConnector/Logging/Log.cs b/src/SingleStoreConnector/Logging/Log.cs index d01af6c23..864c37ac5 100644 --- a/src/SingleStoreConnector/Logging/Log.cs +++ b/src/SingleStoreConnector/Logging/Log.cs @@ -393,19 +393,19 @@ internal static partial class Log public static partial void StagedDataForBulkUpdate(ILogger logger, int rowsStaged, int warningCount); [LoggerMessage(EventIds.QueriedMatchCountForBulkUpdate, LogLevel.Debug, "Queried match count for bulk update: RowsMatched={RowsMatched}")] - public static partial void QueriedMatchCountForBulkUpdate(ILogger logger, int rowsMatched); + public static partial void QueriedMatchCountForBulkUpdate(ILogger logger, long rowsMatched); [LoggerMessage(EventIds.ExecutedBulkUpdate, LogLevel.Debug, "Executed bulk update: RowsUpdated={RowsUpdated}")] - public static partial void ExecutedBulkUpdate(ILogger logger, int rowsUpdated); + public static partial void ExecutedBulkUpdate(ILogger logger, long rowsUpdated); [LoggerMessage(EventIds.CompletedBulkUpdate, LogLevel.Information, "Completed bulk update: Staged={Staged}, Matched={Matched}, Updated={Updated}, Duration={Duration}ms")] - public static partial void CompletedBulkUpdate(ILogger logger, int staged, int matched, int updated, long duration); + public static partial void CompletedBulkUpdate(ILogger logger, int staged, long matched, long updated, long duration); [LoggerMessage(EventIds.ShardKeyMismatchForBulkUpdate, LogLevel.Warning, "Shard key mismatch for bulk update: Staging=[{StagingKey}], Target=[{TargetKey}]")] public static partial void ShardKeyMismatchForBulkUpdate(ILogger logger, string stagingKey, string targetKey); [LoggerMessage(EventIds.LargeUnmatchedCountForBulkUpdate, LogLevel.Warning, "Large unmatched count for bulk update: Staged={Staged}, Matched={Matched}, Unmatched={Unmatched}")] - public static partial void LargeUnmatchedCountForBulkUpdate(ILogger logger, int staged, int matched, int unmatched); + public static partial void LargeUnmatchedCountForBulkUpdate(ILogger logger, int staged, long matched, long unmatched); [LoggerMessage(EventIds.FailedToDropStagingTableForBulkUpdate, LogLevel.Warning, "Failed to drop staging table {TempTableName} for bulk update: {ExceptionMessage}")] public static partial void FailedToDropStagingTableForBulkUpdate(ILogger logger, Exception exception, string tempTableName, string exceptionMessage); diff --git a/src/SingleStoreConnector/SingleStoreBulkUpdate.cs b/src/SingleStoreConnector/SingleStoreBulkUpdate.cs index a70e18e53..b881331fe 100644 --- a/src/SingleStoreConnector/SingleStoreBulkUpdate.cs +++ b/src/SingleStoreConnector/SingleStoreBulkUpdate.cs @@ -352,7 +352,7 @@ private static int GetRowCount(object source) => /// Builds the operation result, snapshotting the warnings collected so far into a new list so that a result /// returned from one call is not mutated when the same instance is reused. /// - private SingleStoreBulkUpdateResult CreateResult(int rowsStaged, int? rowsMatched, int rowsAffected) => + private SingleStoreBulkUpdateResult CreateResult(int rowsStaged, long? rowsMatched, long rowsAffected) => new(new List(m_warnings), rowsStaged, rowsMatched, rowsAffected); private void ValidateColumnMappings() @@ -742,7 +742,7 @@ private static ValueTask StageWithBulkCopyAsync(Singl /// staging table is session-scoped. /// /// - private async Task ComputeMatchedRowsAsync(BulkUpdatePlan plan, string tempTableName, IOBehavior ioBehavior, CancellationToken cancellationToken) + private async Task ComputeMatchedRowsAsync(BulkUpdatePlan plan, string tempTableName, IOBehavior ioBehavior, CancellationToken cancellationToken) { if (!ComputeRowsMatched) return null; @@ -761,8 +761,9 @@ private static ValueTask StageWithBulkCopyAsync(Singl var scalar = await cmd.ExecuteScalarAsync(ioBehavior, cancellationToken).ConfigureAwait(false); - // COUNT(*) comes back as a long; convert rather than cast so the boxed type is handled correctly. - var rowsMatched = scalar is null or DBNull ? 0 : Convert.ToInt32(scalar, CultureInfo.InvariantCulture); + // COUNT(*) comes back as a long; convert rather than cast so the boxed type is handled correctly. Keep it as a + // long so that very large match counts (above int.MaxValue) are reported exactly instead of overflowing. + var rowsMatched = scalar is null or DBNull ? 0L : Convert.ToInt64(scalar, CultureInfo.InvariantCulture); Log.QueriedMatchCountForBulkUpdate(m_logger, rowsMatched); @@ -791,12 +792,17 @@ private static ValueTask StageWithBulkCopyAsync(Singl /// of rows whose values actually changed. /// /// + /// The count is read from rather than + /// , because the latter clamps the result to + /// and a bulk update can legitimately affect more rows than that. + /// + /// /// Warnings raised while executing the statement (for example truncation or conversion warnings) are /// collected via the connection's event and surfaced on the /// operation result. /// /// - private async Task ExecuteUpdateAsync(BulkUpdatePlan plan, string tempTableName, IOBehavior ioBehavior, CancellationToken cancellationToken) + private async Task ExecuteUpdateAsync(BulkUpdatePlan plan, string tempTableName, IOBehavior ioBehavior, CancellationToken cancellationToken) { // Assign each non-key mapped column from the staging row: t.`c1` = s.`c1`, t.`c2` = s.`c2` ... var setClause = string.Join( @@ -819,7 +825,17 @@ private async Task ExecuteUpdateAsync(BulkUpdatePlan plan, string tempTable m_connection.InfoMessage += OnInfoMessage; try { - var rowsUpdated = await cmd.ExecuteNonQueryAsync(ioBehavior, cancellationToken).ConfigureAwait(false); + // Drain the reader the same way ExecuteNonQueryAsync does, then read the unclamped affected-row count. + // RealRecordsAffected is accumulated across result sets, so every result set must be consumed first. + using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.Default, ioBehavior, cancellationToken).ConfigureAwait(false); + do + { + while (await reader.ReadAsync(ioBehavior, cancellationToken).ConfigureAwait(false)) + { + } + } while (await reader.NextResultAsync(ioBehavior, cancellationToken).ConfigureAwait(false)); + + var rowsUpdated = reader.RealRecordsAffected is ulong realRecordsAffected ? (long) realRecordsAffected : 0L; Log.ExecutedBulkUpdate(m_logger, rowsUpdated); diff --git a/src/SingleStoreConnector/SingleStoreBulkUpdateResult.cs b/src/SingleStoreConnector/SingleStoreBulkUpdateResult.cs index a1b90ce0d..5892a7720 100644 --- a/src/SingleStoreConnector/SingleStoreBulkUpdateResult.cs +++ b/src/SingleStoreConnector/SingleStoreBulkUpdateResult.cs @@ -24,7 +24,7 @@ public sealed class SingleStoreBulkUpdateResult /// This never exceeds ; RowsStaged - RowsMatched is the number of staged /// rows that matched no destination row. Note that can still exceed this when the /// destination key columns are not unique, because a single staged row can update several destination rows. - public int? RowsMatched { get; } + public long? RowsMatched { get; } /// /// The number of rows affected by the UPDATE, as reported by the server. @@ -32,14 +32,16 @@ public sealed class SingleStoreBulkUpdateResult /// The exact meaning depends on the connection's /// setting. With the default (UseAffectedRows=false) this is the number of rows matched by the update — /// including rows that already held the new values — and therefore typically equals . With - /// UseAffectedRows=true it is the number of rows whose values actually changed. - public int RowsAffected { get; } + /// UseAffectedRows=true it is the number of rows whose values actually changed. + /// Unlike , this value is not clamped to ; + /// it is the exact count reported by the server. + public long RowsAffected { get; } internal SingleStoreBulkUpdateResult( IReadOnlyList warnings, int rowsStaged, - int? rowsMatched, - int rowsAffected) + long? rowsMatched, + long rowsAffected) { Warnings = warnings; RowsStaged = rowsStaged;