Skip to content

Implement BulkInsertWithVersionAsync — OverwriteIfVersionMatches semantics (#48)#62

Merged
jeremydmiller merged 1 commit into
mainfrom
feature/bulk-insert-version-check-48
May 12, 2026
Merged

Implement BulkInsertWithVersionAsync — OverwriteIfVersionMatches semantics (#48)#62
jeremydmiller merged 1 commit into
mainfrom
feature/bulk-insert-version-check-48

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

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:

  1. 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.

  2. 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`.

  3. 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

  • `dotnet build polecat.slnx -c Debug` — clean
  • Integration tests run on CI (require SQL Server):
    • `missing_id_is_inserted` — fresh row, NOT MATCHED branch
    • `matching_version_updates_and_bumps_version` — happy path
    • `mismatched_version_throws_concurrency_exception` — original row untouched after throw
    • `mixed_batch_partial_success_throws_on_first_mismatch` — pins best-effort semantics
    • `versionless_overload_with_version_mode_throws_helpful_invalidoperation` — message pins
    • `empty_collection_does_nothing` — guard

Audit closure

This is the last open row across all three dedup audit slices. On merge:

🤖 Generated with Claude Code

…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).
@jeremydmiller jeremydmiller merged commit 58a94e1 into main May 12, 2026
6 checks passed
@jeremydmiller jeremydmiller deleted the feature/bulk-insert-version-check-48 branch May 12, 2026 14:39
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement BulkInsertMode.OverwriteIfVersionMatches

1 participant