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;