Class LockTable
- Namespace
- DynaSharp.Coordination
- Assembly
- DynaSharp.Coordination.dll
Named locks in a DynamoDB table, each held through a Lease with a fencing token: AcquireAsync(string, TimeSpan, CancellationToken) and TryAcquireAsync(string, TimeSpan, CancellationToken) take one, RunAsLeaderAsync(string, TimeSpan, Func<Lease, CancellationToken, Task>, CancellationToken) elects a leader.
public sealed class LockTable
- Inheritance
-
LockTable
- Inherited Members
- Extension Methods
Examples
var locks = new LockTable(client.Table("Shop", "pk", "sk"));
var lease = await locks.TryAcquireAsync("cache-refresh", TimeSpan.FromSeconds(30), ct);
if (lease is null)
{
return; // Another process holds it; this one tries again on its next round.
}
await using (lease)
{
await client.TransactWrite().IsHeld(lease).Put(orders, order).SendAsync(ct);
}
Remarks
Semantics. They are those of the DynamoDB Lock Client: a lease has a relative duration and a record version number, its holder renews it, and a contender takes it over only after it has seen the same record version for a whole lease on its own monotonic clock. No two clocks are ever compared, so the clocks of the machines may be set apart by any offset; what it assumes is that they run at about the same rate. A host that is suspended, or a virtual machine that freezes, can still hold a lease that has ended without knowing it, which is what IsHeld<TChain>(TChain, Lease) and FencingToken are for.
The item. A lock is one item in the table the caller passes, so it fits a single-table design. Its partition key is LOCK# and the name of the lock,
its sort key, if the table has one, is LOCK, and both key attributes must be strings. Its attributes are _type (LOCK),
owner, rvn, fence and leaseMs, with holder and a time to live attribute if the LockTableOptions ask for
them; the names are fixed. An item at that key with an owner whose attributes are not those,
such as an entity whose key happens to match, makes an acquire throw InvalidOperationException naming the lock and the table, rather
than guess how long it is held. A release keeps the item, so that the fencing token never goes back: without
TimeToLiveAttribute the item has no time to live, and deleting it starts the token at 1 again, which a system that checks the token refuses.
Clock, logs and retries. It uses the TimeProvider of the client of the table for every wait and for the monotonic clock, and its
LoggerFactory for the log category DynaSharp.Coordination.LockTable. The requests go through the client
with its retries. It is thread-safe; hold one per table, because it remembers which holders it has seen, which a takeover depends on.
One region. The lock table must have a single region that writes. A global table that takes writes in more than one region can grant a lock twice, with the same fencing token, because each region applies its conditional write before the other one's arrives.
Constructors
LockTable(Table<Item>, LockTableOptions?)
Initializes a new instance of the LockTable class over a table without a model.
public LockTable(Table<Item> table, LockTableOptions? options = null)
Parameters
tableTable<Item>The table, opened with Table(string, string, string?). Its key attributes must be strings.
optionsLockTableOptionsWhat the items of the locks carry beyond the lock, or null for nothing.
Exceptions
- ArgumentNullException
tableis null.- ArgumentException
The
optionsare not valid: see LockTableOptions.
Fields
MaxLeaseDuration
The longest lease, a day.
public static readonly TimeSpan MaxLeaseDuration
Field Value
MinLeaseDuration
The shortest lease: below a second a lease cannot outlast the round trips and retries of its renewals.
public static readonly TimeSpan MinLeaseDuration
Field Value
Properties
Options
Gets what the items of the locks carry beyond the lock.
public LockTableOptions Options { get; }
Property Value
Table
Gets the table that holds the locks, without a model.
public Table<Item> Table { get; }
Property Value
Methods
AcquireAsync(string, TimeSpan, CancellationToken)
Acquires a lock, waiting as long as it takes: cancel the token to give up. It takes a lock over once its holder has not renewed it for a whole lease.
public Task<Lease> AcquireAsync(string name, TimeSpan leaseDuration, CancellationToken cancellationToken = default)
Parameters
namestringThe name of the lock.
leaseDurationTimeSpanHow long the lease lasts without a renewal, from MinLeaseDuration to MaxLeaseDuration.
cancellationTokenCancellationTokenEnds the wait with an OperationCanceledException.
Returns
- Task<Lease>
The lease. An acquire whose answer came only after a whole lease hands out a lease that has ended already, see Ended.
Remarks
Polling. While another holder has the lock it reads the item every third of that holder's lease, or every 10 seconds if that is sooner, and takes the lock over once the
holder has not renewed it for a whole lease, measured on the monotonic clock of the client from the first time this saw that record version. The wait sends one strongly consistent GetItem per poll and no write while the holder renews; it writes when it finds the lock free or the
holder due, see TryAcquireAsync(string, TimeSpan, CancellationToken).
Giving up. A process that must not wait for a live holder passes a token that cancels after a little more than one lease: that is long enough to take over the lock of a holder that crashed, which TryAcquireAsync(string, TimeSpan, CancellationToken) of a short-lived process cannot.
Exceptions
- ArgumentException
nameis null or empty.- ArgumentOutOfRangeException
leaseDurationis below MinLeaseDuration or above MaxLeaseDuration.- OperationCanceledException
cancellationTokenwas cancelled.- ResourceNotFoundException
The table does not exist.
- DynamoDbException
DynamoDB failed a request, after the retries of the client.
- InvalidOperationException
The item at the key of the lock has an
ownerbut is not a lock item written by LockTable.
For<T>(Table<T>, LockTableOptions?)
Makes a lock table over any table, typed or not, for LockTable.For(client.Table<Order>("Shop")): it needs only the name of the table,
the names of its keys and its client, so the locks share a table with entities without restating its keys.
public static LockTable For<T>(Table<T> table, LockTableOptions? options = null) where T : IDynamoDbDocument<T>
Parameters
tableTable<T>The table. Its key attributes must be strings.
optionsLockTableOptionsWhat the items of the locks carry beyond the lock, or null for nothing.
Returns
- LockTable
The lock table, over the same table opened without a model.
Type Parameters
THow the table reads and writes items. The locks do not use it.
Exceptions
- ArgumentNullException
tableis null.- ArgumentException
The
optionsare not valid: see LockTableOptions.
RunAsLeaderAsync(string, TimeSpan, Func<Lease, CancellationToken, Task>, CancellationToken)
Leads on a lock: of all the processes that run this for the same lock, one at a time runs work, and when its lease is lost another takes over.
public Task RunAsLeaderAsync(string name, TimeSpan leaseDuration, Func<Lease, CancellationToken, Task> work, CancellationToken cancellationToken = default)
Parameters
namestringThe name of the lock.
leaseDurationTimeSpanHow long a lease lasts without a renewal, from MinLeaseDuration to MaxLeaseDuration. It is also about how long the work of a leader that crashed stays undone.
workFunc<Lease, CancellationToken, Task>The work of the leader. It gets the lease and a token to stop at.
cancellationTokenCancellationTokenEnds the leadership: the work is stopped and the lock released.
Returns
- Task
A task that completes when the work completed on its own, and fails when the work failed on its own, the caller cancelled or an acquire failed.
Examples
await locks.RunAsLeaderAsync("order-sweeper", TimeSpan.FromSeconds(30), async (lease, stop) =>
{
while (true)
{
await client.TransactWrite().IsHeld(lease).Put(orders, order).SendAsync(stop);
await Task.Delay(TimeSpan.FromMinutes(1), stop);
}
}, ct);
Remarks
A term. It acquires the lock with AcquireAsync(string, TimeSpan, CancellationToken), runs the work while the lease is held, and when the lease is lost, waits for the work to stop and contends again. The token the work gets is cancelled when the caller cancels or the lease is lost, so work that honours it stops in time.
How the work ends. The work ends in one of three ways. If the lease was lost, whatever the work returned or threw, this contends again. If the caller cancelled,
the lease is released and this throws OperationCanceledException, whatever the work returned or threw. If the work returned or threw on its own, the lease is released and this
returns or throws that exception: a leader that is done hands the lock on. A service that should lead for as long as it runs calls this in a loop,
as the guide shows for a BackgroundService.
Errors of the election. An acquire that fails with a transient error, a DynamoDbException whose IsTransient is true, a TimeoutException, an HttpRequestException or a CredentialsUnavailableException, after the retries of the client, is counted, and this contends again after a third of the lease. Only the first failure of a row is logged as a warning, and the next try that DynamoDB answers as an information, so an outage of an hour logs two lines and not one per try. Any other error ends it, ResourceNotFoundException included. An acquire whose answer came only after a whole lease starts no term, and this contends again.
Fencing. Work that does not honour the token runs on after the lease is lost, and another process may then be leader at the same time. Writes that must not happen then take IsHeld<TChain>(TChain, Lease), or the FencingToken to a system that checks it.
Current. During a term it is the lease of the term, for the work and what it starts, so code deep in the work fences its writes with IsHeld<TChain>(TChain) without the lease passed down to it.
Exceptions
- ArgumentNullException
workis null.- ArgumentException
nameis null or empty.- ArgumentOutOfRangeException
leaseDurationis below MinLeaseDuration or above MaxLeaseDuration.- OperationCanceledException
cancellationTokenwas cancelled, also when the work then threw something else.- InvalidOperationException
The item at the key of the lock is not a lock item written by LockTable; see AcquireAsync(string, TimeSpan, CancellationToken). Or the work threw it.
TryAcquireAsync(string, TimeSpan, CancellationToken)
Tries to acquire a lock once, without waiting: null if another holder has it. It takes over a lock whose holder stopped renewing it, which this LockTable knows only after watching it for a whole lease.
public Task<Lease?> TryAcquireAsync(string name, TimeSpan leaseDuration, CancellationToken cancellationToken = default)
Parameters
namestringThe name of the lock. Every process that names it contends for the same item.
leaseDurationTimeSpanHow long the lease lasts without a renewal, from MinLeaseDuration to MaxLeaseDuration. The lease renews itself every third of it.
cancellationTokenCancellationTokenCancels the requests.
Returns
- Task<Lease>
The lease, or null if another holder has the lock. An acquire whose answer came only after a whole lease hands out a lease that has ended already, see Ended.
Remarks
Takeover. It succeeds if the lock is free, released, or held by a lease that this LockTable has seen unchanged for that lease's whole duration. A call that finds the lock held remembers the record version of the holder and the time on the monotonic clock, so a later call of the same LockTable takes the lock over once the holder's lease has run out since then without a renewal. Polling this is how a contender waits; AcquireAsync(string, TimeSpan, CancellationToken) does the polling.
Short-lived processes. A takeover needs a LockTable that lives longer than a lease. A process that lives shorter, such as a scheduled job, a function or a command line tool, never takes over a lock whose holder crashed with this, whatever it waits between its runs: it calls AcquireAsync(string, TimeSpan, CancellationToken) with a token that cancels after more than one lease, which waits out a crashed holder and gives up on a live one.
Requests. A LockTable that has not seen the lock held writes at once: one conditional UpdateItem that raises the fencing token with
ADD, and which returns the item that failed its condition if the lock is held. Once it has seen a holder, it reads the item with a strongly
consistent GetItem first, and writes only if the lock is free or the holder is due for a takeover. So a contender that waits for a live holder
sends reads, and a failed conditional write happens only when contenders race for a lock that is free or due.
Exceptions
- ArgumentException
nameis null or empty.- ArgumentOutOfRangeException
leaseDurationis below MinLeaseDuration or above MaxLeaseDuration.- ResourceNotFoundException
The table does not exist.
- DynamoDbException
DynamoDB failed the request, after the retries of the client.
- InvalidOperationException
The item at the key of the lock has an
ownerbut is not a lock item written by LockTable.