Skip to content
bitzorcas
中EN

Guide

Numbering Counters, Recovery, Compensation, and Concurrency

Explains MAX+1, allocation uniqueness, three conflict retries, business-table recovery, inactive placeholders, compensation, and real concurrency and transaction boundaries.

Last updated

Numbering safety depends less on formatting than on choosing the next value under a tenant, business field, and prefix. The current source uses an allocation-fact table and final uniqueness, not a database sequence.

1. Counter partition

The query key is TableName + Field + Prefix; the adapter adds Tenant. Prefix contains every non-number segment, so date, branch, and business-field changes create independent partitions.

yesnoyesno

Tenant + Table + Field + Prefix

read all matching facts

MAX > 0?

MAX + 1

BusinessSerialContext?

business MAX + 1

1

The implementation loads every matching fact through ListAsync and computes Max in memory. It does not execute database MaxAsync, so large partitions cause read amplification.

2. Allocation fact

A new fact stores TableName, Field, SequenceNumber, full string, Prefix, IsActivated, IsSubNumber, and ParentSequenceNumber. The unique index covers the full string, not Prefix+SequenceNumber.

If a changed format maps different counters to one string, the final index still rejects the duplicate.

3. Concurrent conflict

GenerateRecordCaller BCaller AGenerateRecordCaller BCaller Aread MAX=7read MAX=7insert INV-000008successinsert INV-000008unique violationreread MAX and retry, at most 3

Under favorable transaction visibility this prevents a committed duplicate string, but it is not an atomic increment. Whether a transaction remains usable after a unique violation depends on adapter, database, and outer transaction behavior.

4. Exception recognition

IsUniqueConstraintViolation uppercases the message and matches UNIQUE CONSTRAINT, UNIQUE KEY, DUPLICATE KEY, CANNOT INSERT DUPLICATE KEY, 2627, or 2609.

It does not inspect structured provider codes or unwrap inner exceptions. Localized, non-SQL-Server, or wrapped errors can bypass retry.

5. Three retries

Only _records.AddAsync catches unique conflicts. Business-table duplicate checks also loop at most three times, but do not write a fact that advances MAX. When business data contains the candidate and facts do not, each retry can regenerate the same value and exhaust.

The comment that inconsistent business data is automatically skipped is therefore stronger than the algorithm.

6. Business MAX continuation

When a fact partition is empty, the generator can call an owner implementation:

Implement continuation without dynamic SQL
public sealed class InvoiceSerialContext(IInvoiceReadStore invoices)
: IBusinessSerialContext
{
// ① The business owner parses the prefix and runs a parameterized query.
public Task<long> GetMaxSequenceFromBusinessTableAsync(
string prefix, CancellationToken cancellationToken)
=> invoices.GetMaxSerialCounterAsync(prefix, cancellationToken);
// ② Numbering receives only a duplicate Boolean, not table-query access.
public Task<bool> ExistsInBusinessTableAsync(
string value, CancellationToken cancellationToken)
=> invoices.SerialExistsAsync(value, cancellationToken);
}

Never concatenate request.TableName into SQL. The callback exists specifically to keep query ownership safe.

7. Continuation race

Two first calls can both see no facts, read the same business MAX, and race to insert. The unique index lets one retry; an isolation level that cannot see the other commit may still exhaust all attempts.

Migration should preload a waterline or establish it in a dedicated transaction instead of leaving first-time continuation to a hot request path.

8. Placeholder mode

SequenceNumberRequest.IsEnabled=false inserts a fact with IsActivated=false. It still participates in MAX, so it becomes a gap unless compensation is enabled.

There is no public API to activate a selected placeholder; only a compensating rule can pick the smallest inactive fact automatically.

9. Compensation

On the first attempt with IsCompensate=true, the store finds the smallest inactive, non-deleted fact under the same Table/Field/Prefix, assigns IsActivated=request.IsEnabled and ModifyTime, then returns it.

Passing false again leaves the fact inactive, so a later call can compensate it again.

10. Compensation race

Compensation is List→First→Update without a version or conditional activation predicate. Concurrent requests can select the same row and both return success. This path also skips the business-table duplicate callback.

Commercial use requires an atomic claim and affected-row check before returning a value.

11. Parent and child values

Non-null ParentSequenceNumber only sets IsSubNumber=true and stores the parent string. It does not verify parent existence, tenant, activation, hierarchy, or format, and parent is not part of the counter key.

This is relationship metadata, not a protected sub-number model.

12. Transaction boundary

The port comments say generation does not manage transactions and relies on a Command TransactionPipelineBehavior. The current source has no production Command consumer.

When allocation and aggregate writes use different stores or transactions, a number may be reserved without business data, or business data may commit without the fact. The product must explicitly choose its gap policy.

Allocate and persist in a business command
// ① Validate business input before allocation inside a transactional handler.
var allocation = await numbering.GenerateAsync(request, cancellationToken);
if (allocation.IsFailure)
return Result.Failure(allocation.Error);
// ② Persist the returned string as an immutable business fact.
invoice.AssignSerialNumber(allocation.Value!.SequenceNumberStr);
await invoices.AddAsync(invoice, cancellationToken);
// ③ A no-gap claim still requires proof of one shared atomic transaction.
return Result.Success();

14. Hardening direction

Prefer a provider-neutral compare-and-swap or atomic-waterline port over loading a full partition. Compensation should use the same atomic claim primitive. Retain full-string uniqueness as the last defense.

15. Inspection commands

Terminal window
# MAX+1, business fallback, compensation, and exception strings.
sed -n '200,360p' src/Platform/Numbering/*Infrastructure/SequenceNumber/NumberingSequenceNumberStore.cs
# No current lock, atomic increment, or conditional claim.
rg -n "DistributedLock|Interlocked|Increment|CompareAndSwap|UpdateWhereAsync" src/Platform/Numbering -g '*.cs'

Module overview · Testing and GA

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%