Skip to content
FuncularLabsPublic

About

Provider-agnostic optimistic concurrency for .NET. Submit a base revision plus a diffgram; get an arbitrated field-level merge.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Tribunal — two writers, one row, one ruling

Tribunal

CI NuGet License: MIT

Provider-agnostic optimistic concurrency for .NET. Submit a base revision plus a diffgram; get an arbitrated merge.

Two writers. One row. One ruling.

Every .NET data stack detects concurrency conflicts the same way: compare a version token, and if it moved, throw. What happens next is left entirely to you — and in practice "next" is a reload-and-retry loop your users experience as lost work.

Tribunal takes the other half of the problem seriously. A caller submits the revision they read plus a diffgram of what they changed. Tribunal fetches current, performs a field-level three-way merge, and consults a policy only where two writers actually touched the same field. Everything else auto-merges, the way Git auto-merges non-overlapping hunks.

No ORM, no database, no serializer is assumed. Funcular.Tribunal.Abstractions has zero dependencies.

Packages

Package What's in it
Funcular.Tribunal.Abstractions Contracts only: Diffgram, FieldChange, FieldConflict, Verdict, Ruling, and the seams — IRevisionedStore, IFieldAccessor, IConflictPolicy, IValueEqualityComparer, IValueCoercer. Reference this from adapters.
Funcular.Tribunal The engine (MergeArbiter), the orchestrator (Tribunal), built-in policies, per-field policy builder, compiled-reflection accessor, value equality/coercion, revision comparers, snapshot diffgram builder, and an in-memory reference store.

Targets netstandard2.0 and net8.0.

Thirty-second tour

using Funcular.Tribunal;
using Funcular.Tribunal.Policies;
using Funcular.Tribunal.Reflection;
using Funcular.Tribunal.Revisions;

// 1. A policy. StoreWins for everything, except Balance must never be silently overruled.
var policy = ConflictPolicy.For<Account>()
    .Default(ConflictPolicy.StoreWins)
    .Field(a => a.Balance, ConflictPolicy.Abort)
    .Build();

// 2. The engine: pure, no I/O. Give it a way to read/write fields and a way to order revisions.
var arbiter = new MergeArbiter<Account, long>(
    ReflectionFieldAccessor<Account>.Instance,
    ComparableRevisionComparer<long>.Instance,
    policy);

// 3. The orchestrator: fetch → merge → compare-and-swap write, retried from the original diffgram.
ITribunal<Account, int, long> tribunal = new Tribunal<Account, int, long>(yourStore, arbiter);

// 4. A client read revision 41 and changed two fields.
var diffgram = new Diffgram<long>(41, new[]
{
    new FieldChange("Name",  originalValue: "Alice", proposedValue: "Alicia"),
    new FieldChange("Notes", originalValue: null,    proposedValue: "Prefers email"),
});

var result = await tribunal.SubmitAsync(key: 7, diffgram);

switch (result.Ruling.Outcome)
{
    case RulingOutcome.Applied:             /* everything landed */                         break;
    case RulingOutcome.AppliedWithDissent:  /* landed; result.Ruling.Dissents says what lost */ break;
    case RulingOutcome.Vanished:            /* the row was deleted */                       break;
    case RulingOutcome.Exhausted:           /* lost the CAS race MaxAttempts times */       break;
    // Aborted throws MergeConflictException by default; Anomalous / Invalid mean the diffgram is not trustworthy.
}

How the merge decides

For every change in the diffgram Tribunal compares three values for that field: base (what the caller says they saw), store (what the row holds now) and proposed (what the caller wants).

Base vs proposed Store vs base Store vs proposed Meaning What happens
equal — — The caller didn't change it Unchanged — store value stands, no policy
differs — equal Someone already made the same change Converged — nothing to do
differs equal differs Nobody else touched it Applied — no policy consulted
differs differs differs Both writers changed it, differently Contested — the policy decides

Before any row applies: at an equal revision, store ≠ base on any changed field is Anomalous (the caller's base snapshot isn't what it claims). "Equal" throughout means the configured value equality, tolerances included.

The policy returns one Verdict per contested field:

Verdict Git analogue Disposition
Accept --ours proposed value lands (Accepted)
Reject --theirs store value kept, reported as a dissent (Rejected)
Substitute(v) a manual resolution a computed value lands (Substituted) — e.g. a numeric delta merge
Abort(reason) stop and show the conflicts nothing is written; the ruling lists every contested field

Built-ins: ConflictPolicy.Abort, ConflictPolicy.ClientWins, ConflictPolicy.StoreWins, ConflictPolicy.FromDelegate(...), and ConflictPolicy.For<T>() for per-field composition.

The merge is two-phase. Every change is classified — including whether its target is writable — and every verdict collected before a single field is written. An Aborted, Anomalous or Invalid ruling never leaves a half-mutated entity, and the outcome doesn't depend on the order of changes in the diffgram. The one thing no library can promise: if a validating setter rejects a value after earlier fields were written, and undoing one of those also fails, the entity is in an undefined state — Tribunal throws MergeRollbackException rather than hand you an Invalid ruling over it, and nothing is written. Plain auto-properties never hit this.

Why the diffgram carries original values

Because the caller tells us what they saw, Tribunal can detect a field-level conflict without fetching or storing the historical row. That is what keeps it provider-agnostic: any store that can do UPDATE … WHERE key = @k AND revision = @expected can host it.

It also lets Tribunal fail closed on lies. At an equal revision the store must agree with every original value; if it doesn't, the caller's base snapshot is not what it claims and the ruling is Anomalous. A base revision newer than the store's is Anomalous too — that's a rolled-back store or the wrong replica, not a fast path.

Retries inside a submit are idempotent

Tribunal.SubmitAsync loops fetch → merge → CAS write. When the write loses a race it re-fetches and re-merges the original diffgram against the fresh row. Nothing is rebased onto the previous attempt's result, so a Substitute that computes a delta (store + (proposed − base)) yields the right answer on attempt 2 as well. The budget is bounded (TribunalOptions.MaxAttempts, default 3) with a jittered backoff you can replace. A ruling that changes nothing (empty diffgram, everything already converged or unchanged, or every contested field resolved for the store) is not written at all; IsWritten is false and NewRevision is the revision that was read.

Two things this does not cover, because no library can:

  • Your store's FetchAsync must hand back an instance it doesn't share (no identity-map object, no reuse across fetches). The merge mutates in place; a shared instance would carry attempt 1's result into attempt 2.
  • If TryWriteAsync throws after the row committed (a dropped acknowledgement), the exception tells the caller nothing. Re-read before resubmitting; don't replay the same diffgram blind.

Revisions

Revisions are ordered through IRevisionComparer<TRevision> rather than an IComparable<T> constraint, because the most common token of all — SQL Server rowversion — is a byte[]. Provided:

  • ComparableRevisionComparer<T> for anything IComparable<T> (long, int, DateTime, …)
  • ByteArrayRevisionComparer for rowversion / timestamp (unsigned big-endian)
  • DelegateRevisionComparer<T> for the rest

Value equality and coercion

Naive Equals manufactures phantom conflicts. DefaultValueEqualityComparer treats byte[], bounded collections (order-free for sets and dictionaries; lazy enumerables are never enumerated) and plain POCOs (your own classes and structs — anything not declared in a framework assembly — that don't override Equals, compared by the public properties and fields you declared, recursively, so a cloned List<Address> is not a change; framework handles like StringBuilder, Lazy<T> or an IDisposable are not values at all — the accessor leaves them out of the catalog (a settable POCO parent whose only leaves are such handles is catalogued as a leaf itself, compared by its members), and as members of a POCO they compare by reference, so distinct handles are contested rather than silently converged; [NotMapped] opts a member out explicitly, on overrides too) structurally, numerics across widths (int 5 == long 5 for the ten fixed-width CLR numeric primitives and decimal, eleven types in all; BigInteger/Half/Int128/UInt128/nint/nuint are not in the set; mixed decimal/double compared at decimal precision, NaN == NaN), DateTime within a configurable tolerance (set ~4 ms for SQL Server datetime; ticks are compared regardless of Kind, so normalise to UTC at the edges), and strings ordinally. It never throws. Use the same comparer instance for the diffgram builder and the arbiter — a change one considers real, the other may consider Unchanged. DefaultValueCoercer converts wire-shaped values (JSON long → int, string → Guid/enum/DateTime) to the field's CLR type first, and refuses lossy or undefined conversions (5.5 → int, 99 → SomeEnum, true → SomeEnum). Offset-bearing date strings become UTC. Both are seams (IValueEqualityComparer, IValueCoercer).

What Tribunal is not

  • Not a sync engine. It arbitrates one update against one store; it doesn't replicate.
  • Not a CRDT. Convergence is a policy decision you make, not an algebraic guarantee.
  • Not a lock. Nothing is held. If the store moves under you mid-merge, Tribunal re-hears the case.

Plugging in an ORM

Three seams, all in the Abstractions package, none of which require the ORM to change:

  1. IFieldAccessor<T> / IFieldCatalog<T> — the ORM's cached property metadata, if it has any.
  2. IRevisionedStore<T, TKey, TRevision> — one read, one compare-and-swap write.
  3. IDiffgramBuilder<T, TRevision> — optional; the ORM's change tracking can emit diffgrams directly.

See docs/FUNKYORM-INTEGRATION.md for the FunkyORM plan — the test suite already drives a Tribunal end-to-end through FunkyORM's SQLite provider (FunkyOrmSqliteStore).

Roadmap

  • v0.2 keyed collection diffs (element identity, not ordinal index)
  • Funcular.Tribunal.Json — JsonElement coercion and a stable wire format for diffgrams
  • Funcular.Tribunal.FunkyOrm — the adapter described above

License

MIT © Funcular Labs, Inc.

About

Provider-agnostic optimistic concurrency for .NET. Submit a base revision plus a diffgram; get an arbitrated field-level merge.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages