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>
This commit is contained in:
807
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
807
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
@@ -0,0 +1,807 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user