Skip to content

An edit keeps its server identity, so history stays attached (#2158) - #2276

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/2158-an-edit-keeps-its-server-identity
Aug 15, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/2158-an-edit-keeps-its-server-identity

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #2158.

The defect

The Add/Edit save re-derived server_id from host/database/read-only-intent on every save — the code said so as its intent:

"The row's server_id is (re)derived from host/database/read-only-intent so a definition always lands on its shared identity."

So an operator fixing a hostname typo wrote a row under a new id, and the old row was deleted. The registry stayed tidy and every collect.* row keyed to the old id was orphaned with nothing pointing at it.

That's why it went unnoticed: nothing looks broken. The server simply reads as though it had never been monitored.

An edit now keeps the row's identity and rewrites the address in place. Derivation runs only on Add, where there is no history to lose.

Why identity must be assigned rather than derived

Not a preference — it falls out of the consequences. Three issues pull on this identity and they pull in opposite directions:

says pressure on the derivation
#2158 a config edit must not change the identity fewer fields, or none
#2228 two configs resolving to one real database must not be two identities no config-derived hash can decide this — the configs genuinely differ
#2218 two instances on one host need distinguishing more fields (engine, port)

So "add engine and port to the hash" is refuted by its own consequences: it fixes #2218 and makes #2158 strictly worse, while doing nothing for #2228. One shape satisfies all three — identity allocated once and never recomputed, the derived address demoted to a lookup key, and what the target actually IS taken from the connection.

Most of that was already built and pointed this way: StoredServerId made the store authoritative, DarlingServerConnector reads config.ServerId rather than re-hashing, and ServersOnlyInFile even carried a comment predicting this transition. The Viewer's save was the last place identity was recomputed.

This also unblocks #2218 as a consequence rather than a prerequisite: once derivation only runs at Add, adding engine/port to it is harmless, because it can no longer orphan anything.

Two things the fix had to carry with it

The collision guard. It compared a derived id against the registry, which only works while every row's id equals the hash of its own address — exactly the invariant this change gives up. Left as-is, an edit could point a second registration at an address another server already monitors and the guard would never fire, because the id it looked up belongs to nobody. That's #2228's shape reached from the registry side. It now matches the address columns and compares ids afterwards, which is what still lets a rename or a credential change through as "collided with myself."

IS NOT DISTINCT FROM for the nullable database is load-bearing: = never matches NULL, so every server-scoped registration — the common case — would read as "address free" and the guard would pass for all of them.

The darling.json reconcile. It matched on id alone, so after an edit it would report a server that is monitored as absent — and that log line gives advice ("add them with the Viewer's Add Server dialog"), so being wrong is worse than being silent: it would tell the operator to re-add the one server they'd just fixed, on every start. Now id or name; either-or rather than name-only, because nothing enforces display-name uniqueness and name-only would let a same-named sibling hide a genuinely unmonitored server.

Testing

The save path is a WPF dialog, so the identity-preservation and guard-rewrite halves are pinned at the source — a re-derivation there is invisible in every other test and silently discards history, which is worth holding at the only level available. The pins assert both the new shapes and the absence of the old ones (the derived-id guard, the delete-old-identity step), so a partial revert fails.

ServersOnlyInFile is pure, so its six cases are ordinary tests: an edited server not reported, the same server reported without the name arm (so the test can't pass vacuously), a deleted server still reported, an id match alone sufficing, neither-present reported, and the no-names-supplied overload staying id-only so an un-updated caller can't silently suppress the warning.

I executed the predicate's logic and verified all the source pins locally — 20/20, including doc-comment hygiene on all four files. Full solution builds, 0 warnings.

Not in this PR

🤖 Generated with Claude Code

The Add/Edit save re-derived server_id from host/database/read-only-intent on
every save, so fixing a hostname typo wrote a row under a new id and deleted
the old one. The registry stayed tidy and every collect.* row keyed to the
old id was orphaned with nothing pointing at it -- which is why nobody
noticed: the server just reads as though it was never monitored.

An edit now keeps the row's identity and rewrites the address in place.
Derivation runs only on Add, where there is no history to lose.

Assigned rather than derived, argued from consequences: #2158 says a config
edit must not change the identity; #2228 says two configs resolving to one
real database must not be two identities, which no config-derived hash can
decide because the configs genuinely differ; #2218 says two instances on one
host need distinguishing, which wants MORE fields in the derivation and makes
#2158 strictly worse. One shape satisfies all three -- allocate once, never
recompute, treat the derived address as a lookup key, and get what the target
actually IS from the connection. StoredServerId and the connector's
config.ServerId already did their half; the Viewer's save was the last site.

Two things the fix had to carry. The collision guard compared a DERIVED id
against the registry, which only holds while every row's id equals the hash
of its own address -- left alone, an edit could point a second registration
at an address another server already monitors and the guard would never
fire, because the id it looked up belongs to nobody. It now matches the
address columns (IS NOT DISTINCT FROM for the nullable database; = never
matches NULL, so every server-scoped registration would read as "free") and
compares ids afterwards, which still lets a rename or re-credential through.
And the darling.json reconcile matched on id alone, so after an edit it
would call a monitored server absent and advise re-adding it -- wrong advice
every start about the server just fixed. Now id OR name, either-or so a
same-named sibling cannot hide a genuinely unmonitored one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) August 15, 2026 03:49
Comment on lines +416 to 422
var occupant = await _dataService.GetMonitoredServerByAddressAsync(row.Host, row.Database, row.ReadOnlyIntent);
if (occupant is not null && occupant.ServerId != row.ServerId)
{
StatusText.Text = "A server with this address (and database / read-only intent) is already monitored. Edit it from Manage Servers instead.";
SaveButton.IsEnabled = true;
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: the new guard only checks address collisions, and the id-collision check it replaced is gone — so Add can now silently steal another server's identity/history.

ComputeServerId is a pure deterministic hash of (host, database, readOnlyIntent) (ViewerDataService.MonitoredServers.cs:251), and config_monitored_servers.server_id is the only unique/primary key on the table — there's no UNIQUE(host, database, read_only_intent) constraint (see migration-ladder-v3.3.0.sql). Before this PR, the guard was:

if (_originalServerId != row.ServerId
    && await _dataService.GetMonitoredServerAsync(row.ServerId) is not null)

which caught any pre-existing row already sitting on the id row.ServerId — including on Add, since _originalServerId is null there.

The new guard only asks "does anything currently sit at this address":

var occupant = await _dataService.GetMonitoredServerByAddressAsync(row.Host, row.Database, row.ReadOnlyIntent);
if (occupant is not null && occupant.ServerId != row.ServerId)

Walk through this sequence, which is exactly the scenario #2158 exists to fix:

  1. Server A is Added at (h1, d1, false) → gets id X = ComputeServerId(h1, d1, false).
  2. Server A is later Edited to h2 (per this PR, it keeps id X, correctly).
  3. h1 gets reused for a new instance (IP/hostname reuse is routine in cloud/ephemeral infra). Operator Adds it as a brand-new server with host h1.
  4. row.ServerId derives to X again — the same id A now owns under a different address. GetMonitoredServerByAddressAsync(h1, ...) finds no occupant (A lives at h2 now), so the guard passes.
  5. UpsertMonitoredServerAsync runs INSERT ... ON CONFLICT (server_id) DO UPDATE SET name=EXCLUDED.name, host=EXCLUDED.host, ... — every column, including secrets — silently overwriting A's row with the new server's data. A's collect.* history is now misattributed to the new Add, with no error, no log line, nothing to alert the operator.

This is the same failure class this PR fixes for Edit, reopened for Add. Since Add always writes a freshly-derived id with no _originalServerId, it seems like the id-existence check should be kept (e.g. _originalServerId != row.ServerId && await _dataService.GetMonitoredServerAsync(row.ServerId) is not null) in addition to the new address-based check — the address check alone doesn't answer "does this id already belong to someone else."

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Review summary

Scope: this PR is entirely C#/Postgres-store code in the Darling app (no T-SQL, no Lite files touched), so there's no Lite/Darling parity concern here — Lite has no equivalent multi-server registry/Add-Edit dialog.

The core fix (identity survives an edit, derivation only on Add, collision guard rechecked against address instead of a derived id, reconcile matches id-or-name) is sound and well-tested for the Edit path. Left one inline finding on a gap it opens on the Add path:

  • AddServerDialog.xaml.cs (SaveButton_Click guard, ~L406-422): the new guard only checks whether the address is already occupied. The id-existence check it replaced (GetMonitoredServerAsync(row.ServerId) is not null) is gone entirely. Since ComputeServerId is a pure deterministic hash of (host, database, readOnlyIntent), an Add whose address was previously used by some other server that has since been edited away from it will re-derive that other server's id, pass the address check (no occupant at the new address), and then silently clobber that server's row via ON CONFLICT (server_id) DO UPDATE — full column rewrite, including secrets, with the victim's collect.* history now misattributed to the new server. No error, no log, nothing surfaces it. This is the same failure class [BUG] Editing a server's Host/Database/ReadOnlyIntent re-keys its runtime server_id — registry PK orphaned, collected history split #2158 fixes for Edit, reopened for Add. Suggest keeping an id-existence check for the Add path in addition to the new address check. Details and repro steps in the inline comment.

Minor, non-blocking: the _originalServerId field doc comment (AddServerDialog.xaml.cs:44, "when the identity fields change, the old row is replaced") describes the pre-#2158 delete-and-recreate behavior this PR removes, and is now stale — GitHub wouldn't let me anchor an inline comment there since it's outside the diff hunks, so flagging it here. Given how deliberately this PR keeps doc comments in sync with behavior elsewhere, this one looks like it was missed.

Everything else checked out: parameterized SQL throughout (no injection surface), IS NOT DISTINCT FROM correctly handles the nullable database column, GetMonitoredServerByAddressAsync's column list matches ReadMonitoredServerRowNoSecret's ordinal reads exactly, and the ServersOnlyInFile id-or-name reconcile logic is correct (including the "no names supplied" back-compat overload and the deleted-server-still-reported case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant