41bdc178c2
Two halves of the same INSERT OR REPLACE hazard in the LWW apply path. Wire-compatible with every earlier peer: no proto change, no lib_schema_version bump, no on-disk schema change. 1. RegisterReplicated now REFUSES a table that another table references with a row-mutating ON DELETE action (CASCADE / SET NULL / SET DEFAULT). Applying a remote row uses INSERT OR REPLACE, whose implicit DELETE fires that referential action — while the apply path holds the capture guard raised, precisely so its own writes are not re-captured. The child rows it deletes or nulls therefore produce no oplog entry: the peer keeps them and the pair diverges with nothing reporting a fault. Registration is the only point at which the problem is still visible, so it is refused there with an actionable LocalDbRegistrationException naming the referencing table and its action. NO ACTION / RESTRICT stay allowed: they mutate no child row, so the implicit delete just fails and the applier dead-letters the entry — loud, not silent. Registration-time only; a cascading child table created LATER is not caught (SQLite offers no DDL hook). No current consumer has a foreign key on a replicated table (ScadaBridge's ten tables have none), so this is a guard against a future foot-gun, not a live fix. 2. One entry's row write and its ledger write are now atomic. They were two statements under the poison catch, so a failure landing between them committed a deleted/replaced row carrying a STALE LIVE row-version entry — exactly the shape SnapshotStreamer warns about and skips, so that key never travels again and the pair is permanently divergent for it. A per-entry SAVEPOINT makes the pair all-or-nothing while keeping the catch's per-ENTRY granularity: entries applied earlier in the same batch survive the rollback, the poison entry is still dead-lettered, and the batch still continues. The RELEASE runs outside the catch on purpose, so a failure there rolls the whole batch back rather than being swallowed as poison. Measured cost on a 100k-row backlog drain: ~7% (178.6k rows/s -> 166.1k rows/s). Tests: both hazards proven against the unfixed code first (FK parent registered silently; an injected ledger-write abort left the row deleted with a live ledger entry). Adds FK guard coverage (parent refused, child allowed, SET NULL / SET DEFAULT refused, NO ACTION / RESTRICT allowed, unrelated cascades ignored) and two applier atomicity tests. The statement-count lock-in moves 7 -> 9 for the two new savepoint statements. Version 0.3.2 -> 0.3.3; README limitations + release notes document the constraint.
25 lines
1.1 KiB
XML
25 lines
1.1 KiB
XML
<Project>
|
|
|
|
<PropertyGroup>
|
|
<TargetFramework>net10.0</TargetFramework>
|
|
<Nullable>enable</Nullable>
|
|
<ImplicitUsings>enable</ImplicitUsings>
|
|
<LangVersion>latest</LangVersion>
|
|
<Version>0.3.3</Version>
|
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
<!-- NU1510 (package-pruning advice): the Replication and Tests projects carry a
|
|
Microsoft.AspNetCore.App FrameworkReference AND explicit PackageReferences to
|
|
in-framework packages (Hosting/Logging/DI/Configuration), as required by the
|
|
library layout. The advice is benign; keep the explicit refs and silence it. -->
|
|
<NoWarn>$(NoWarn);NU1510</NoWarn>
|
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
|
</PropertyGroup>
|
|
|
|
<!-- Embed the repo README in each packable nupkg (ignored by non-packable projects). -->
|
|
<ItemGroup Condition="'$(IsPackable)' == 'true'">
|
|
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" Visible="false" />
|
|
</ItemGroup>
|
|
|
|
</Project>
|