Repository navigation
Notary hardening - #5063
Notary hardening#5063
Conversation
djb15
left a comment
There was a problem hiding this comment.
Thanks for the distinct commits, it made reviewing this PR really easy 🙏
Re the new ready checks, what are the scenarios in which the Notary might not have been ready? Also, what's the result now if the Notary isn't ready, do messages just go through (i.e. fail open)?
The readiness flag is to ensure that you can't call Notary member functions without first initializing it through To answer your 2nd question - the behaviour is that it returns an error now, if not ready. The specific call sites that call into the Notary are already handling errors from the Notary, so this doesn't change that kind of behaviour. |
4598a39 to
62e25c9
Compare
4108e84 to
62e25c9
Compare
johnsaigle
left a comment
There was a problem hiding this comment.
I think this PR fixes some known bugs, which is great 👍🏻
At the same time, IMO the implementation goes the wrong direction in other places.
The Notary has some consistency problems in it, including unclear ownership of locking, memory and the DB drifting apart on errors, and invalid states being representable. (Those exist in the current implementation too.)
Below are some things that IMO should be addressed. For clarity, this is a critique of my original implementation in addition to some of the changes in this PR. This can help the Notary implementation better embody what we've laid out in the Safety Critical Code doc.
-
Who owns atomicity? Right now it's split between the queue's internal lock and the Notary's mutex, and some operations use one, some the other, and some neither (ReleaseReadyMessages, blackhole,RemoveBlackholedMsg). ProcessMsg is an important example: IsBlackholed, IsDelayed, and delay each lock separately, so an admin BlackholeDelayedMsg can land in between. A Valid reobservation then sees the message in neither place, gets Approve, and we sign something an admin just blackholed. I'd rather have one type own the queue, the blackhole set, and the DB under a single mutex, with multi-step transitions done under one lock.
-
Memory and DB drift on errors. The remove helpers update memory before the DB. If the DB delete fails, the message is gone from memory but still on disk, and the next Valid reobservation gets approved. That fails open. On a DB error, memory should keep holding the message back.
-
Invalid states are representable. The readiness flag means a Notary can exist without its state loaded, so every method needs an isReady check, and while it's false IsBlackholed just quietly returns false. If NewNotary loads state up front, that state can't exist and the flag goes away.
-
PopReady invites the "unready" case. Having PopReady alongside a Pop that ignores ReleaseTime makes early release look like a normal operation. I think there should be one Pop(when) that checks readiness and removes under the lock, plus RemoveItem for targeting a specific message.
-
Nil checks are inconsistent. Some queue methods tolerate a nil receiver and others don't. A nil msgPubSet behaves like an empty set, so Contains on a nil blackhole set says "not blackholed", which also fails open. The constructors always build these, so a nil is a bug and should just panic. A few other nil checks only exist because the mock DB returns nil for everything.
-
The mock DB is too forgiving. The real DB errors when you delete a key that doesn't exist; the mock doesn't, so the tests can't catch bugs in the error paths.
Rather than block on the above, which could require a lot more discussion, I'll leave it up to @djb15 and @pleasew8t to figure out how much you want to change here. I've also opened #5095 to address all the above, and we can consider that separately.
|
@johnsaigle thanks for making the requested changes to the feedback. I've considered these in two buckets, where one set is directly relevant to the changes this PR introduced, and the other set is more improvements to the notary which we can review in the other PR. To summarize, I cherry-picked + ported the relevant commits from #5095 for the former bucket:
|
Rename PendingMessageQueue.PopReady to Pop. Remove the old Pop, which took the top message and did not check its ReleaseTime. A caller that removes a specific message can use RemoveItem, so no production code needs a Pop that ignores ReleaseTime. Keep Peek, and document that its result is a snapshot that must not decide what to remove. A removal goes through Pop(when), which checks readiness under the lock, or through RemoveItem, which names the message, so a stale Peek result cannot cause an early release. Fix doc comments that said the earliest ReleaseTime is the last element and that the queue sorts from latest to earliest. container/heap keeps the earliest ReleaseTime at index 0. It does not sort the other elements. Replace "thread-safe" in the queue doc comment. The comment now says that each method is atomic but a sequence of calls is not. Remove the Len check from the ReleaseReadyMessages loop, because Pop returns nil for an empty queue. (cherry picked from commits 5226470 and 6fc6664 in wormhole-foundation#5095) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NewNotary now loads the delayed and blackholed messages from the database before it returns. If the load fails, NewNotary returns an error. Every Notary holds its persisted state, so the readiness flag does not guard a real state. Delete these items: the ready field, isReady, ErrNotReady, and the readiness test at the top of each public method. Remove runOnce, runErr, and run. Run now only registers the metrics and starts the gauge goroutine. It cannot fail, so it no longer returns an error, and node.go calls it without a check. loadFromDB runs once, on a new Notary, so remove ErrAlreadyInitialized and the fallbacks that created the delayed queue and blackholed set when they were nil. The startup order in node.go already made the flag unnecessary. The guardian starts the processor and the admin server after Run returns, so no caller used the Notary before it loaded its state. Behavior changes: - NewNotary loads from the database in all environments. Before, Run skipped the load in the GoTest environment. - When the flag was false, ProcessMsg returned ErrNotReady and the processor dropped the message. IsBlackholed and IsDelayed returned false. These paths no longer exist. Remove TestNotary_RunCanBeCalledTwice and countingNotaryDB. Add a test that NewNotary returns a Notary that holds the stored messages. Ported from 6f73c7c in wormhole-foundation#5095. Co-Authored-By: John Saigle <john@asymmetric.re> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MockNotaryDB.LoadAll now gives an empty result, the same as the real database. Remove ErrNilDBResult, the four admin read checks that used it, and its test. They existed only because the mock returned nil with no error. Remove the nil logger check in ProcessMsg. NewNotary rejects a nil logger. Ported from part of 3d521fa in wormhole-foundation#5095. Co-Authored-By: John Saigle <john@asymmetric.re> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4881af7 to
9b8443e
Compare
This PR brings a few hardening improvements to the Notary. The majority of changes relate to concurrency, where out of order execution (i.e, executing
Run()while or after some other executes) could cause a panic. Note that the exact conditions where each of these instances would materialize were considered unlikely to occur, given how the Notary is used at present. But this may change in the future.Summary of Changes
peek/popfunction. In rare cases, a queue may be manipulated between when apeekand apopoccurs. The newPopReady()function allows doing both operations under the same mutex, accepting a time as input to assess whether or not a pending message is ready.nilchecks that don't change the behaviour of the Notary. These are mostly for hardening.blackholed, and duringloadFromDB()it will only instantiate a newdelayedqueue andblackholedset if none exists. This fixes a race condition where, despiteloadFromDB()locking a mutex, other functions would still read from these pointers. Unlikely to materialize, but easy to fix.readyflag to the Notary struct, which is used by public methods to exit early if the Notary is not ready yet.Runfunction is idempotent, by only allowing it to execute once. If it is ever necessary for multiple instances of the Notary to exist, this decision should be revisited.Manual Review Notes
The commits have been structured to follow the changes listed above, so that reviewing each commit in order groups together changes linked to the same idea. The main thing to look out for is anything that actually changes the way the Notary operates (unrelated to lifecycle).