Skip to content

Notary hardening - #5063

Merged
djb15 merged 11 commits into
wormhole-foundation:mainfrom
pleasew8t:notary-improvements
Nov 5, 2026
Merged

djb15 merged 11 commits into
wormhole-foundation:mainfrom
pleasew8t:notary-improvements

Conversation

@pleasew8t

Copy link
Copy Markdown
Contributor

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

  • Scope the Badger iterator with the "NOTARY" prefix to avoid full DB loads. Before this PR, the Notary would load the full DB, which had fairly significant resource consumption.
  • Adds an atomic peek/pop function. In rare cases, a queue may be manipulated between when a peek and a pop occurs. The new PopReady() function allows doing both operations under the same mutex, accepting a time as input to assess whether or not a pending message is ready.
  • Adds a few more nil checks that don't change the behaviour of the Notary. These are mostly for hardening.
  • During construction, the Notary now instantiates blackholed, and during loadFromDB() it will only instantiate a new delayed queue and blackholed set if none exists. This fixes a race condition where, despite loadFromDB() locking a mutex, other functions would still read from these pointers. Unlikely to materialize, but easy to fix.
  • Adds a ready flag to the Notary struct, which is used by public methods to exit early if the Notary is not ready yet.
  • Ensures that the Run function 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).

@djb15 djb15 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)?

@pleasew8t

Copy link
Copy Markdown
Contributor Author

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 Run(). Prior to this PR, such ordering may have resulted in unwanted behaviour (including potential nil pointer dereferences), so this just closes that possibility. There isn't ever a scenario in which an uninitialized Notary would/should be used.

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.

djb15
djb15 previously approved these changes Oct 20, 2026
Comment thread node/pkg/notary/notary.go Resolved
bemic
bemic previously approved these changes Oct 21, 2026

@johnsaigle johnsaigle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@djb15

djb15 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@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:

  • Agree with the feedback on Pop.
  • The feedback on the ready checking was also fair, and I also previously asked a question about it, so probably wasn't the neatest approach. Appreciate the alternative suggestion.
  • Removed the redundant nil checks.

@djb15
djb15 requested a review from johnsaigle November 3, 2026 17:47
pleasew8t and others added 5 commits November 4, 2026 09:57
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>
@djb15
djb15 merged commit 4725d90 into wormhole-foundation:main Nov 5, 2026
68 of 70 checks passed
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.

4 participants