Implement BulkInsertWithVersionAsync — OverwriteIfVersionMatches semantics (#48)#62
Merged
Merged
Conversation
…ntics (#48) Closes polecat#48 — the final row in the JasperFx#218 database-manipulation dedup audit and the only piece of feature work the audit identified (everything else was pure consolidation onto Weasel.Core / JasperFx). ## What Three overloads of a sibling method on `AdvancedOperations`: ```csharp BulkInsertWithVersionAsync<T>(IReadOnlyCollection<(T Document, long ExpectedVersion)> documents, ...) ``` mirroring the existing `BulkInsertAsync` overload set but accepting per-document expected versions for optimistic-concurrency bulk updates. Semantics: - **Row exists + stored version == expected version** → UPDATE, version bumped to `target.version + 1`. - **Row does not exist** → INSERT at version 1; the expected version is irrelevant on inserts. - **Row exists + stored version != expected version** → MERGE is a no-op, and after the batch's reader is drained the per-row check throws `JasperFx.ConcurrencyException(typeof(T), id)`. Matches the per-row pattern used by `UpdateOperation` / `UpsertOperation` rather than the aggregate-throw pattern. Implementation notes: - Uses SQL Server MERGE with HOLDLOCK; the expected_version is part of the USING projection rather than a free-standing parameter so the SQL Server optimizer can see it as a column on the source rowset (matters once this is extended to set-based MERGEs). - Each MERGE in the batch carries `OUTPUT inserted.id`; each output is its own result set, so the reader iterates with `NextResultAsync`. The collected ids are diff'd against the input ids and the first missing one surfaces a `ConcurrencyException`. - The versionless `BulkInsertAsync(BulkInsertMode.OverwriteIfVersionMatches)` path no longer throws the polecat#48-pointer `NotSupportedException` — it now throws `InvalidOperationException` pointing callers at the new sibling, since #48 is what they were waiting on. ## Test coverage `bulk_insert_with_version_check.cs`: - `missing_id_is_inserted` — fresh row goes through the NOT MATCHED branch. - `matching_version_updates_and_bumps_version` — happy path: row at v1, expected=1, ends up updated at v2. - `mismatched_version_throws_concurrency_exception` — row at v1, expected=999, throws and the original row is untouched. - `mixed_batch_partial_success_throws_on_first_mismatch` — documents that the throw is best-effort (matched-and-updated rows in the batch commit; the exception is the signal rather than a transactional rollback). - `versionless_overload_with_version_mode_throws_helpful_invalidoperation` — pins the new error message that replaces the NotSupportedException stub. - `empty_collection_does_nothing` — guard. Closes #48. Closes the BulkInsertMode chain row in JasperFx#218 (database-manipulation dedup slice).
This was referenced May 12, 2026
jeremydmiller
added a commit
that referenced
this pull request
May 12, 2026
Closes #51. Bookkeeping tracker for the Polecat 4 migration narrative, filed as part of the Critter Stack 2026 release wave. ## What's covered - **Foundation pin bumps** — the JasperFx 1.x → 2.0 / JasperFx.Events 1.x → 2.0 / Weasel 8.x → 9.0 transitive bumps, with a before/after table. - **Dedup audit relocations** (jasperfx#218 / #224): - `Polecat.BulkInsertMode` → `Weasel.Core.BulkInsertMode` (PR #50, weasel#264) - `Polecat.Storage.CascadeAction` → `Weasel.Core.CascadeAction` (PR #47 / #61) - `Polecat.Metadata.ITenanted` now extends `JasperFx.MultiTenancy.IHasTenantId` (silent during Polecat 4 dev; documented for third-party consumers) - `Polecat.Exceptions.UnknownTenantException` → `JasperFx.MultiTenancy.UnknownTenantIdException` (with the new `TenantId` property surfaced) - **Event-sourcing API changes** — `IInlineProjection.ApplyAsync` widening to `IEnumerable<StreamAction>` (jasperfx-events#4306), plus the `IJasperFxAggregateGrouper.Group` `IReadOnlyList<IEvent>` tightening (jasperfx#201) flagged for the rare case a Polecat application implements a custom grouper. - **New behavior** — `BulkInsertWithVersionAsync` (#48 / #62, the optimistic-concurrency bulk-insert path), and the FlatTableProjection case-sensitivity fix (#49 / Weasel-side). - **AOT/codegen posture** — Polecat 4 is `PublishAot`-supported; `IsAotCompatible=true` lands with the per-project AOT audit (jasperfx#213). - **Dependency lockstep** — explicit pairing table; mixing major versions across products in this wave is unsupported. - **No obsolete API removals** — verified via grep; the audit row in #51 is closed as a no-op. ## Sidebar wiring Added a top-level "Migration Guide" entry under the existing sections in `docs/.vitepress/config.mts`, matching the same style other top-level docs use (Diagnostics, Schema). The page is reachable from any page in the sidebar tree.
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.
Summary
Closes #48 — the final row in the jasperfx#218 dedup audit (database-manipulation slice) and the only piece of feature work the audit identified. Everything else under #218 was pure consolidation onto `Weasel.Core` / `JasperFx`.
Adds three overloads of `AdvancedOperations.BulkInsertWithVersionAsync`:
```csharp
public Task BulkInsertWithVersionAsync(
IReadOnlyCollection<(T Document, long ExpectedVersion)> documents,
CancellationToken token = default);
public Task BulkInsertWithVersionAsync(
IReadOnlyCollection<(T Document, long ExpectedVersion)> documents,
int batchSize,
CancellationToken token = default);
public Task BulkInsertWithVersionAsync(
IReadOnlyCollection<(T Document, long ExpectedVersion)> documents,
int batchSize,
string tenantId,
CancellationToken token = default);
```
The shape mirrors the existing `BulkInsertAsync` overload set, with `(T, long)` pairs in place of bare `T` for the input collection.
Design decisions
Per the three open questions in #48's body:
API surface — sibling method, not convention-discovered. A separate `BulkInsertWithVersionAsync` rather than introducing a version-property convention on `T`. Less coupling, no Polecat-side metadata-discovery mechanism required. The audit body's first option.
SQL — version-guarded MERGE with `OUTPUT inserted.id`. Per-row MERGE statements concatenated into the batched command (matches the existing OverwriteExisting batching). Each MERGE has `OUTPUT inserted.id` so the reader can later determine which incoming rows the MERGE actually touched. Each output is its own result set; the reader iterates with `NextResultAsync`.
Failure semantics — per-row `JasperFx.ConcurrencyException`. When an incoming id is absent from the collected output set, the WHEN MATCHED AND ... predicate failed (the row exists at a different version than expected). After the batch's reader drains, the first missing id surfaces `new ConcurrencyException(typeof(T), id)`. Matches `UpdateOperation` / `UpsertOperation`. Audit body's second option.
Stop-sign in the docs
The throw is best-effort, not transactional. Matched-and-updated rows in the batch commit before the throw fires. One of the tests (`mixed_batch_partial_success_throws_on_first_mismatch`) pins this behavior so callers know what to expect. If transactional semantics are wanted later, that's a follow-up — likely involves wrapping the batch in an explicit `BEGIN TRAN` / `ROLLBACK` on mismatch detection.
Other changes
Test plan
Audit closure
This is the last open row across all three dedup audit slices. On merge:
🤖 Generated with Claude Code