Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/VersionHistory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/content/tutorials/bulk-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down
8 changes: 4 additions & 4 deletions src/SingleStoreConnector/Logging/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 22 additions & 6 deletions src/SingleStoreConnector/SingleStoreBulkUpdate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="SingleStoreBulkUpdate"/> instance is reused.
/// </summary>
private SingleStoreBulkUpdateResult CreateResult(int rowsStaged, int? rowsMatched, int rowsAffected) =>
private SingleStoreBulkUpdateResult CreateResult(int rowsStaged, long? rowsMatched, long rowsAffected) =>
new(new List<SingleStoreError>(m_warnings), rowsStaged, rowsMatched, rowsAffected);

private void ValidateColumnMappings()
Expand Down Expand Up @@ -742,7 +742,7 @@ private static ValueTask<SingleStoreBulkCopyResult> StageWithBulkCopyAsync(Singl
/// staging table is session-scoped.
/// </para>
/// </remarks>
private async Task<int?> ComputeMatchedRowsAsync(BulkUpdatePlan plan, string tempTableName, IOBehavior ioBehavior, CancellationToken cancellationToken)
private async Task<long?> ComputeMatchedRowsAsync(BulkUpdatePlan plan, string tempTableName, IOBehavior ioBehavior, CancellationToken cancellationToken)
{
if (!ComputeRowsMatched)
return null;
Expand All @@ -761,8 +761,9 @@ private static ValueTask<SingleStoreBulkCopyResult> 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);

Expand Down Expand Up @@ -791,12 +792,17 @@ private static ValueTask<SingleStoreBulkCopyResult> StageWithBulkCopyAsync(Singl
/// of rows whose values actually changed.
/// </para>
/// <para>
/// The count is read from <see cref="SingleStoreDataReader.RealRecordsAffected"/> rather than
/// <see cref="SingleStoreCommand.ExecuteNonQuery"/>, because the latter clamps the result to <see cref="int.MaxValue"/>
/// and a bulk update can legitimately affect more rows than that.
/// </para>
/// <para>
/// Warnings raised while executing the statement (for example truncation or conversion warnings) are
/// collected via the connection's <see cref="SingleStoreConnection.InfoMessage"/> event and surfaced on the
/// operation result.
/// </para>
/// </remarks>
private async Task<int> ExecuteUpdateAsync(BulkUpdatePlan plan, string tempTableName, IOBehavior ioBehavior, CancellationToken cancellationToken)
private async Task<long> 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(
Expand All @@ -819,7 +825,17 @@ private async Task<int> 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);

Expand Down
12 changes: 7 additions & 5 deletions src/SingleStoreConnector/SingleStoreBulkUpdateResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,24 @@ public sealed class SingleStoreBulkUpdateResult
/// <remarks>This never exceeds <see cref="RowsStaged"/>; <c>RowsStaged - RowsMatched</c> is the number of staged
/// rows that matched no destination row. Note that <see cref="RowsAffected"/> can still exceed this when the
/// destination key columns are not unique, because a single staged row can update several destination rows.</remarks>
public int? RowsMatched { get; }
public long? RowsMatched { get; }

/// <summary>
/// The number of rows affected by the <c>UPDATE</c>, as reported by the server.
/// </summary>
/// <remarks>The exact meaning depends on the connection's <see cref="SingleStoreConnectionStringBuilder.UseAffectedRows"/>
/// setting. With the default (<c>UseAffectedRows=false</c>) this is the number of rows <em>matched</em> by the update —
/// including rows that already held the new values — and therefore typically equals <see cref="RowsMatched"/>. With
/// <c>UseAffectedRows=true</c> it is the number of rows whose values actually <em>changed</em>.</remarks>
public int RowsAffected { get; }
/// <c>UseAffectedRows=true</c> it is the number of rows whose values actually <em>changed</em>.
/// <para>Unlike <see cref="SingleStoreCommand.ExecuteNonQuery"/>, this value is not clamped to <see cref="int.MaxValue"/>;
/// it is the exact count reported by the server.</para></remarks>
public long RowsAffected { get; }

internal SingleStoreBulkUpdateResult(
IReadOnlyList<SingleStoreError> warnings,
int rowsStaged,
int? rowsMatched,
int rowsAffected)
long? rowsMatched,
long rowsAffected)
{
Warnings = warnings;
RowsStaged = rowsStaged;
Expand Down
Loading