Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeLeases.cs
wtclaude 63a7dc4374 feat(bridge): lease deadlines and the participation ledger (Phase 11b)
Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.

BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.

BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.

And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:20 -05:00

808 lines
32 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
namespace Server.Custom.Bridge
{
/// <summary>
/// Protocol 6, part b. The lease plane: a live configuration value the website may hold for
/// a bounded time, and which this shard puts back <b>on its own</b> when the time is up.
///
/// EVENTS.md calls the lease the primitive underneath the whole event system, and the two
/// mechanisms it names are the whole of this file:
///
/// 1. **Restore is compare-and-set, never a blind write.** Before writing the baseline
/// back, the current value must still equal what the event applied. If it does not,
/// somebody moved it deliberately: report `drifted`, leave the world alone, and let an
/// operator decide. Blindly restoring would silently revert a staff member's change,
/// which is the one failure that would make operators distrust the feature.
///
/// 2. **The expiry lives here, not only in core.** The deadline comes down the wire and
/// this shard honours it whether or not the website is ever heard from again. Core
/// drives the normal restore; this is the backstop. That inversion is what makes an
/// unattended, scheduled world change defensible: the failure mode is a world back at
/// baseline early, never a world stuck changed indefinitely.
///
/// ── What a lease is made of, and why it is memory-only ─────────────────────────────────
///
/// `Server.Config` is a runtime key-value store. `Config.Set` mutates the in-memory entry
/// table; `Config.Load()` is guarded by `_Initialized` and so runs exactly once at boot,
/// which is what makes a Set survive every later Get. **Nothing here ever calls
/// `Config.Save()`**, and that is a decision rather than an omission (org lead, 2026-09-04):
/// a lease that never reaches disk means a shard restart is a *free* restore. It is the
/// strongest fail-safe available and it costs nothing, and it is also why `lease.list`
/// reports an empty hand after a restart, which is exactly what lets the website's
/// reconcile notice that the lease is gone.
///
/// A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's
/// ORIGINAL default marker too, because the entry compares against the value it was loaded
/// with. Restoring a key that was `@`-defaulted in a cfg file leaves it `@`-defaulted.
///
/// ── The catalog is short on purpose, and shorter than EVENTS.md expected ───────────────
///
/// §D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at
/// type initialisation (a lease does nothing) and read live (a lease takes effect at once).
/// Measured on ServUO 57.4 the split is not near even: of the 158 non-Bridge call sites in
/// `Scripts/`, roughly **eight** are live reads. A lease on any of the others applies
/// cleanly and changes nothing, which is the worst failure this feature has.
///
/// So the catalog below is an allowlist of keys verified by reading the call site, never
/// "any config key", and Phase 11b ships exactly one. Phase 12 adds the rest along with the
/// boot-time self-check that drops a key from the advertised catalog if it does not take.
///
/// ── Drift cannot happen by accident on a stock shard ───────────────────────────────────
///
/// `Config.Set` has exactly ONE caller in the whole of ServUO 57.4
/// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). There is no in-game command, gump
/// or console path that writes a config key, so on a stock shard a GM cannot drift a config
/// lease even deliberately. The compare-and-set below is still correct and still required —
/// Phase 12's object-property leases are trivially driftable, and a shard with custom
/// scripts may well write config at runtime — but proving the `drifted` path needs the
/// scaffolding command in `tools/scaffolding/`, and this paragraph is why.
/// </summary>
public static class BridgeLeases
{
private enum LeaseType
{
Float,
Int,
Bool,
Text
}
/// <summary>One allowlisted key: what it is, what it holds, and what it is worth by default.</summary>
private sealed class Catalog
{
public string Key;
public string Label;
public LeaseType Type;
public double Min;
public double Max;
/// <summary>
/// The value the shard's own call site passes as its default, as text.
///
/// It is carried rather than inferred because `Config.Get` cannot tell "absent" from
/// "absent, and here is what the caller would have used" — it just returns whatever
/// default it is handed. Reading a key with the WRONG default would make the
/// baseline a fiction, and restoring that fiction would leave the shard running on
/// a number no source file ever chose.
/// </summary>
public string Default;
}
/// <summary>
/// Phase 11b's one proven key (org lead, 2026-09-04).
///
/// `Scripts/Misc/CharacterCreation.cs` reads it live, inside the per-character creation
/// path, and divides by ten to get the per-skill cap. So it takes effect on the next
/// character created and is observable without a restart, which is what "proven" has to
/// mean here — the failure this catalog exists to prevent is a key that applies cleanly
/// and does nothing at all.
/// </summary>
private static readonly Catalog[] Keys =
{
new Catalog
{
Key = "PlayerCaps.SkillCap",
Label = "Starting skill cap",
Type = LeaseType.Float,
Min = 1000.0,
Max = 1500.0,
Default = "1000",
},
};
/// <summary>A lease this shard is holding, or has finished holding and not yet been asked about.</summary>
private sealed class Held
{
public string Key;
public string Baseline; // canonical text, as read before the lease applied
public string Applied; // canonical text, as written
public long UntilMs;
public string RunId;
public Timer Deadline;
// Set once the deadline has fired. The entry stays listed through the grace window so
// that teardown gets a definite verdict rather than finding nothing and having to guess
// whether the value came back or was never held.
public bool Expired;
public bool Restored;
public bool Drifted;
public string Current; // what was there instead, when drifted
public long ExpiredAtMs;
}
private static readonly Dictionary<string, Held> _held =
new Dictionary<string, Held>(StringComparer.Ordinal);
private static Timer _prune;
private static long _applied, _released, _drifted, _expired, _refused;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("lease.apply", OnApply);
BridgeBoot.RegisterHandler("lease.release", OnRelease);
BridgeBoot.RegisterHandler("lease.list", OnList);
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
Rearm();
}
/// <summary>Stops and recreates the prune timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
if (_prune != null)
{
_prune.Stop();
_prune = null;
}
_prune = Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), Prune);
}
public static string Status()
{
return String.Format(
"leases(held={0} applied={1} released={2} drifted={3} expired={4} refused={5})",
_held.Count, _applied, _released, _drifted, _expired, _refused);
}
// ---- lease.apply ----
/// <summary>
/// Takes a lease. `holdMs` is authoritative and `untilMs` is carried for display only.
///
/// That split is deliberate. An absolute deadline computed on the website and honoured
/// on the shard is a deadline measured against two clocks; a shard whose clock is ten
/// minutes fast would restore a ten-minute lease the instant it took it. A duration is
/// immune, and the absolute time is still worth carrying so that `lease.list` and the
/// run console can say when the hold ends in terms the operator's own clock agrees with.
/// </summary>
private static void OnApply(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "apply"))
return;
var entry = Lookup(BridgeJson.GetString(o, "key"));
if (entry == null)
{
Err(reqId, "apply", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'");
return;
}
string canonical;
string why;
if (!Coerce(entry, BridgeJson.GetString(o, "value"), out canonical, out why))
{
Err(reqId, "apply", why);
return;
}
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
var maxMs = (long)BridgeConfig.LeaseMaxDurationSec * 1000L;
if (holdMs < 1L)
{
Err(reqId, "apply", "a lease needs a positive holdMs");
return;
}
// **Refused, never clamped.** A clamp would silently give the website a shorter lease
// than it believes it has, and the website is the half that schedules the restore; the
// two would then disagree about when the world comes back. The shard's ceiling exists
// precisely for the case where the website is wrong, and being loud about it is the
// whole value.
if (holdMs > maxMs)
{
Err(reqId, "apply",
String.Format(CultureInfo.InvariantCulture,
"this shard holds a lease for at most {0} seconds, and {1} were asked for",
BridgeConfig.LeaseMaxDurationSec, holdMs / 1000L));
return;
}
Held existing;
if (_held.TryGetValue(entry.Key, out existing) && !existing.Expired)
{
Err(reqId, "apply",
"'" + entry.Key + "' is already leased" +
(existing.RunId == null ? "" : " by run " + existing.RunId));
return;
}
// A key whose previous lease expired is re-leasable, and the baseline is read fresh
// rather than inherited: whatever is true now is what this lease undertakes to restore.
var baseline = Read(entry);
var held = new Held
{
Key = entry.Key,
Baseline = baseline,
Applied = canonical,
UntilMs = BridgeJson.GetLong(o, "untilMs", BridgeJson.NowMs() + holdMs),
RunId = BridgeJson.GetString(o, "runId"),
};
Write(entry, canonical);
held.Deadline = Timer.DelayCall(TimeSpan.FromMilliseconds(holdMs), () => OnDeadline(entry.Key));
_held[entry.Key] = held;
_applied++;
Console.WriteLine("[Bridge] lease {0}: {1} -> {2} for {3}s (run {4})",
entry.Key, baseline, canonical, holdMs / 1000L, held.RunId ?? "-");
BridgeLink.Emit(BridgeJson.Begin("lease.applied")
.Str("key", entry.Key)
.Str("label", entry.Label)
.Str("baseline", baseline)
.Str("applied", canonical)
.Num("untilMs", held.UntilMs)
.Str("runId", held.RunId)
.End());
var sb = BridgeJson.Begin("lease.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", "apply")
.Str("key", entry.Key)
.Str("baseline", baseline)
.Str("applied", canonical)
.Num("untilMs", held.UntilMs);
BridgeLink.Emit(sb.End());
}
// ---- lease.release ----
/// <summary>
/// Gives a lease back, compare-and-set.
///
/// `expected` is what the event applied and `baseline` is what to put back. Both come
/// from the website's ledger rather than from this shard's memory, so a release still
/// works across a sidecar reconnect — and so that a shard which has forgotten the lease
/// entirely (a restart) can answer honestly instead of refusing.
/// </summary>
private static void OnRelease(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "release"))
return;
var entry = Lookup(BridgeJson.GetString(o, "key"));
if (entry == null)
{
Err(reqId, "release", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'");
return;
}
Held held;
_held.TryGetValue(entry.Key, out held);
// The deadline already dealt with it, and it drifted. That verdict is the one thing
// teardown must not lose, so it is held here through the grace window and handed over
// now rather than being reported as an ordinary restore.
if (held != null && held.Expired && held.Drifted)
{
Drop(entry.Key);
Drifted(reqId, entry.Key, held.Current);
return;
}
// Either the deadline restored it, or this shard restarted and never had it. Both are
// "the value is back and nothing more is owed", which is a successful release: the
// fail-safe firing is not a failure.
if (held == null || held.Expired)
{
Drop(entry.Key);
_released++;
var already = BridgeJson.Begin("lease.ok");
if (reqId != null) already.Str("reqId", reqId);
already.Str("action", "release")
.Str("key", entry.Key)
.Bool("released", true)
.Bool("alreadyRestored", true)
.Str("current", Read(entry));
BridgeLink.Emit(already.End());
return;
}
var expected = BridgeJson.GetString(o, "expected");
var current = Read(entry);
if (expected != null && !Same(entry, current, expected))
{
// Somebody moved it. Stop honouring the deadline too: the value is no longer this
// lease's to restore, and a timer that fired later would revert the change that was
// just reported as somebody else's.
Drop(entry.Key);
Drifted(reqId, entry.Key, current);
return;
}
var baseline = BridgeJson.GetString(o, "baseline");
if (baseline == null)
baseline = held.Baseline;
string canonical;
string why;
if (!Coerce(entry, baseline, out canonical, out why))
{
// The website handed back a baseline this key cannot hold. Refusing is right: the
// alternative is writing a value nothing has ever verified into a live shard.
Err(reqId, "release", "the baseline offered is not valid for this key: " + why);
return;
}
Write(entry, canonical);
Drop(entry.Key);
_released++;
Console.WriteLine("[Bridge] lease {0}: restored to {1}", entry.Key, canonical);
var sb = BridgeJson.Begin("lease.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", "release")
.Str("key", entry.Key)
.Bool("released", true)
.Bool("alreadyRestored", false)
.Str("current", canonical);
BridgeLink.Emit(sb.End());
}
// ---- lease.list ----
/// <summary>
/// Every key this shard offers, with what it is worth right now and what is holding it.
///
/// It answers two different questions with one frame on purpose. The website's lease
/// `read()` needs the current value before it applies anything; its `inForce()` needs to
/// know whether the shard still has a record of the hold. Splitting them into two verbs
/// would mean two round trips to answer one question about one key.
///
/// **`held` means "this shard still has a record of the lease", not "the value is still
/// overridden".** A lease whose deadline has fired is `held` with `expired: true` until
/// teardown collects its verdict, precisely so that reconcile does not report it gone
/// and have core write it off as orphaned when what actually happened was the backstop
/// working correctly.
/// </summary>
private static void OnList(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "list"))
return;
var sb = BridgeJson.Begin("lease.list.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Append(",\"leases\":[");
for (int i = 0; i < Keys.Length; i++)
{
var entry = Keys[i];
if (i > 0)
sb.Append(',');
sb.Append("{\"key\":");
BridgeJson.Text(sb, entry.Key);
sb.Append(",\"label\":");
BridgeJson.Text(sb, entry.Label);
sb.Append(",\"type\":\"").Append(TypeName(entry.Type)).Append('"');
sb.Append(",\"default\":");
BridgeJson.Text(sb, entry.Default);
sb.Append(",\"current\":");
BridgeJson.Text(sb, Read(entry));
if (entry.Type == LeaseType.Float || entry.Type == LeaseType.Int)
{
sb.Append(",\"min\":").Append(entry.Min.ToString("R", CultureInfo.InvariantCulture));
sb.Append(",\"max\":").Append(entry.Max.ToString("R", CultureInfo.InvariantCulture));
}
Held held;
if (_held.TryGetValue(entry.Key, out held))
{
sb.Append(",\"held\":true");
sb.Append(",\"baseline\":");
BridgeJson.Text(sb, held.Baseline);
sb.Append(",\"applied\":");
BridgeJson.Text(sb, held.Applied);
sb.Append(",\"untilMs\":").Append(held.UntilMs);
sb.Append(",\"runId\":");
BridgeJson.Text(sb, held.RunId);
sb.Append(",\"expired\":").Append(held.Expired ? "true" : "false");
if (held.Expired)
{
sb.Append(",\"restored\":").Append(held.Restored ? "true" : "false");
sb.Append(",\"drifted\":").Append(held.Drifted ? "true" : "false");
}
}
else
{
sb.Append(",\"held\":false");
}
sb.Append('}');
}
sb.Append(']');
BridgeLink.Emit(sb.End());
}
// ---- the deadline ----
/// <summary>
/// The backstop. Runs on the Core thread whether or not the website still exists, which
/// is the entire point of the lease framing: the undo is the default and holding is the
/// exception, so nothing has to be alive for the world to come back.
/// </summary>
private static void OnDeadline(string key)
{
Held held;
if (!_held.TryGetValue(key, out held) || held.Expired)
return;
var entry = Lookup(key);
if (entry == null)
return;
held.Deadline = null;
held.Expired = true;
held.ExpiredAtMs = BridgeJson.NowMs();
_expired++;
var current = Read(entry);
if (!Same(entry, current, held.Applied))
{
held.Drifted = true;
held.Current = current;
_drifted++;
Console.WriteLine("[Bridge] lease {0}: deadline passed but the value is now {1}, not {2}; NOT restoring",
key, current, held.Applied);
}
else
{
Write(entry, held.Baseline);
held.Restored = true;
Console.WriteLine("[Bridge] lease {0}: deadline passed, restored to {1} without being asked",
key, held.Baseline);
}
BridgeLink.Emit(BridgeJson.Begin("lease.expired")
.Str("key", key)
.Str("runId", held.RunId)
.Str("baseline", held.Baseline)
.Str("applied", held.Applied)
.Bool("restored", held.Restored)
.Bool("drifted", held.Drifted)
.Str("current", held.Drifted ? held.Current : held.Baseline)
.End());
}
/// <summary>
/// Drops expired entries once the grace window has passed.
///
/// The window exists so teardown can still collect a verdict; the prune exists because a
/// run that is never torn down must not leave a row here for the life of the process.
/// Dropping a DRIFTED entry is worth a line in the console: it is the one case where the
/// shard is quietly forgetting something an operator was meant to look at.
/// </summary>
private static void Prune()
{
if (_held.Count == 0)
return;
var cutoff = BridgeJson.NowMs() - (long)BridgeConfig.LeaseGraceSec * 1000L;
List<string> drop = null;
foreach (var kv in _held)
{
if (!kv.Value.Expired || kv.Value.ExpiredAtMs > cutoff)
continue;
if (drop == null)
drop = new List<string>();
drop.Add(kv.Key);
}
if (drop == null)
return;
for (int i = 0; i < drop.Count; i++)
{
Held held;
if (_held.TryGetValue(drop[i], out held) && held.Drifted)
{
Console.WriteLine(
"[Bridge] lease {0}: dropping a DRIFTED record nobody collected; the world is still at {1}",
drop[i], held.Current);
}
Drop(drop[i]);
}
}
// ---- the config plane ----
private static Catalog Lookup(string key)
{
if (key == null)
return null;
for (int i = 0; i < Keys.Length; i++)
{
if (String.Equals(Keys[i].Key, key, StringComparison.Ordinal))
return Keys[i];
}
return null;
}
/// <summary>
/// Reads a key through the same typed accessor the game does, and renders the answer as
/// canonical text.
///
/// Text is the transport for every lease value in both directions, whatever the declared
/// type. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a
/// parser and two strings to a diff, and a compare-and-set that compared formatted
/// numbers would report drift on a value nobody touched. Comparison is done by
/// <see cref="Same"/>, on parsed values, for exactly that reason.
/// </summary>
private static string Read(Catalog entry)
{
switch (entry.Type)
{
case LeaseType.Float:
return Config.Get(entry.Key, ParseDouble(entry.Default))
.ToString("R", CultureInfo.InvariantCulture);
case LeaseType.Int:
return Config.Get(entry.Key, (int)ParseDouble(entry.Default))
.ToString(CultureInfo.InvariantCulture);
case LeaseType.Bool:
return Config.Get(entry.Key, ParseBool(entry.Default)) ? "true" : "false";
default:
return Config.Get(entry.Key, entry.Default);
}
}
private static void Write(Catalog entry, string canonical)
{
switch (entry.Type)
{
case LeaseType.Float:
Config.Set(entry.Key, ParseDouble(canonical));
break;
case LeaseType.Int:
Config.Set(entry.Key, (int)ParseDouble(canonical));
break;
case LeaseType.Bool:
Config.Set(entry.Key, ParseBool(canonical));
break;
default:
Config.Set(entry.Key, canonical);
break;
}
// Deliberately no Config.Save(). See the class header: a lease that never reaches disk
// makes a shard restart a free restore.
}
/// <summary>Parses and range-checks a wire value, answering the canonical text for it.</summary>
private static bool Coerce(Catalog entry, string raw, out string canonical, out string why)
{
canonical = null;
why = null;
if (raw == null)
{
why = "no value was given";
return false;
}
if (entry.Type == LeaseType.Bool)
{
var t = raw.Trim();
if (String.Equals(t, "true", StringComparison.OrdinalIgnoreCase) || t == "1")
canonical = "true";
else if (String.Equals(t, "false", StringComparison.OrdinalIgnoreCase) || t == "0")
canonical = "false";
else
{
why = "'" + raw + "' is not a yes or no value";
return false;
}
return true;
}
if (entry.Type == LeaseType.Text)
{
canonical = raw;
return true;
}
double n;
if (!Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
{
why = "'" + raw + "' is not a number";
return false;
}
if (entry.Type == LeaseType.Int && n != Math.Floor(n))
{
why = "'" + raw + "' is not a whole number";
return false;
}
// The shard's own range, checked even though core checks the module's declaration
// first. The two are the same numbers today and that is not the point: this one is the
// one that is true when the website is wrong.
if (n < entry.Min || n > entry.Max)
{
why = String.Format(CultureInfo.InvariantCulture,
"{0} accepts {1} to {2}, and '{3}' is outside that",
entry.Label, entry.Min, entry.Max, raw);
return false;
}
canonical = entry.Type == LeaseType.Int
? ((long)n).ToString(CultureInfo.InvariantCulture)
: n.ToString("R", CultureInfo.InvariantCulture);
return true;
}
/// <summary>
/// Compare-and-set's comparison, done on parsed values rather than on text.
///
/// The two sides are formatted by two different runtimes — one of them a JavaScript
/// engine — and `1200` against `1200.0` is a difference only a string comparison can
/// see. Reporting that as drift would refuse to restore a value nobody had touched,
/// which is the failure mode of a safety check that is too eager: it leaves the world
/// changed and blames an innocent operator.
/// </summary>
private static bool Same(Catalog entry, string a, string b)
{
if (a == null || b == null)
return a == b;
if (entry.Type == LeaseType.Text)
return String.Equals(a, b, StringComparison.Ordinal);
if (entry.Type == LeaseType.Bool)
return ParseBool(a) == ParseBool(b);
double x, y;
if (!Double.TryParse(a, NumberStyles.Float, CultureInfo.InvariantCulture, out x) ||
!Double.TryParse(b, NumberStyles.Float, CultureInfo.InvariantCulture, out y))
return String.Equals(a, b, StringComparison.Ordinal);
return x == y;
}
private static double ParseDouble(string s)
{
double n;
return Double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out n) ? n : 0.0;
}
private static bool ParseBool(string s)
{
return String.Equals(s, "true", StringComparison.OrdinalIgnoreCase) || s == "1";
}
private static string TypeName(LeaseType t)
{
switch (t)
{
case LeaseType.Float: return "float";
case LeaseType.Int: return "int";
case LeaseType.Bool: return "bool";
default: return "string";
}
}
// ---- replies ----
private static bool Ready(string reqId, string action)
{
if (!BridgeConfig.EventsEnabled)
{
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
return false;
}
return true;
}
private static void Err(string reqId, string action, string reason)
{
_refused++;
var sb = BridgeJson.Begin("lease.error");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("reason", reason);
BridgeLink.Emit(sb.End());
}
private static void Drifted(string reqId, string key, string current)
{
_drifted++;
Console.WriteLine("[Bridge] lease {0}: DRIFTED, the world is at {1} and was left alone", key, current);
var sb = BridgeJson.Begin("lease.drifted");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("key", key).Str("current", current);
BridgeLink.Emit(sb.End());
}
private static void Drop(string key)
{
Held held;
if (_held.TryGetValue(key, out held) && held.Deadline != null)
held.Deadline.Stop();
_held.Remove(key);
}
}
}