Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bootstrap state machine #90

Merged
merged 10 commits into from
Aug 16, 2023
Merged

Add bootstrap state machine #90

merged 10 commits into from
Aug 16, 2023

Conversation

iand
Copy link
Contributor

@iand iand commented Aug 11, 2023

This adds a state machine for running the bootstrap query described in #45. The state machine is very simple (it runs a query that attempts to find the self node), but it drives the design of the coordinator in a number of ways:

  • The coordinator now manages two state machines: bootstrap and user queries. It enforces the constraint that no user queries can be progressed while the bootstrap is running. This establishes the pattern for managing a set of state machines.
  • Priority is simple: the coordinator first attempts to advance the bootstrap state machine and only if it is idle, indicating the bootstrap has no further work, will it proceed to advance the query pool state machine.
  • This changes the design of the state machine. Previously the state machine reacted to an incoming event passed to the Advance method. However this causes complications in the presence of multiple state machines. What should happen if the bootstrap is waiting for a message response but the caller attempts to start a user query? The coordinator needs to remember the "add query" event until the bootstrap is complete, so that event would need to remain on the coordinator's queue. But the coordinator needs to read from the queue to detect if an incoming message response is available for the bootstrap, without removing the "add query" event. Thus we need separate queues for the two state machines. Rather than manage those in the coordinator, we give each state machine its own event queue. External callers enqueue events and the state machine dequeues the next one each time it attempts to advance state.
  • The above change leads to a simple interface for state machines: an Enqueue method for notifying a new event and an Advance method that returns the next state.
  • update 2023-08-15: The above change leads to a simple interface for state machines:an Advance method that accepts an event and returns the next state.
  • Coordinator methods like StartQuery and StopQuery now enqueue an event for query pool
  • A new Bootstrap method enqueues an event for bootstrap state machine
  • update 2023-08-15: the queues for the state machines are managed by the coordinator, which allows state machines to be more cleanly composed into hierarchies (for example, the state machine managing the routing table include queue will use a query pool state machine and this change eliminates the need to manage event queues of child state machines)

There are still some ugly parts which I may be able to fix within the scope of this PR:

  • the coordinator implements a number of unused methods to conform to the scheduler.Scheduler interface. All that is needed is the RunOne method.
  • the name of the bootstrap query needs to be factored into a constant or remembered by the coordinator - coordinator now uses a separate callback to deal with bootstrap query instead of checking query id
  • events are action.Action interfaces so they can use the standard queue interface. The Run method is unused. The queue could simply be a channel or we could modify the queue interface to be parameterised by type, allowing us to have a queue of BootstrapEvents (removed 2023-08-15)
  • currently the bootstrap method expects a function that generates a FindNode request for the given node. FindNode is such a fundamental DHT operation that I think it should be provided as a method by the Endpoint

Fixes #47


if cfg.QueueCapacity < 1 {
return &kaderr.ConfigurationError{
Component: "PoolConfig",
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Component: "PoolConfig",
Component: "BootstrapConfig",

Comment on lines 23 to 56
type StateMachine[S any, E event.Action] interface {
// Enqueue enqueues an event to be processed by the state machine.
Enqueue(context.Context, E)
// Advance advances the state of the state machine.
Advance(context.Context) S
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think a StateMachine interface makes sense. I also think it's great that we can constrain the type of events that we pass into the state machine with the generic type parameters!

I'm not sure about the part where we require each state to maintain its own queue of events it needs to process. I would have expected that this is the responsibility of another component. What drove the requirement to have a separate queue in each state?

Copy link
Contributor

@dennis-tra dennis-tra Aug 14, 2023

Choose a reason for hiding this comment

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

Okay, I just read the PR description


bootstrap, err := routing.NewBootstrap(self, bootstrapCfg)
if err != nil {
return nil, fmt.Errorf("query pool: %w", err)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return nil, fmt.Errorf("query pool: %w", err)
return nil, fmt.Errorf("bootstrap: %w", err)

c.queue.Enqueue(ctx, ev)
// c.inboundEvents <- ev

c.pool.Enqueue(ctx, qev)
Copy link
Contributor

Choose a reason for hiding this comment

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

What if the queue is at its capacity? Enqueue is exported so anyone could put messages in the queue. If it was just internal we probably could make sure that this won't happen.

Besides in the coordinator, Enqueue is only called in tests, so we could make it a private method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What if the queue is at its capacity? Enqueue is exported so anyone could put messages in the queue. If it was just internal we probably could make sure that this won't happen.

The pool is only internal - the coordinator creates it - so noone else is adding events.

Besides in the coordinator, Enqueue is only called in tests, so we could make it a private method?

Do you mean pool.Enqeue? That's from synchronous methods exposed by the coordinator, not just tests.

pool StateMachine[query.PoolState, query.PoolEvent]

// bootstrap is the bootstrap state machine, responsible for bootstrapping the routing table
bootstrap StateMachine[routing.BootstrapState, routing.BootstrapEvent]
Copy link
Contributor

Choose a reason for hiding this comment

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

We could define the StateMachine type in routing, e.g.:

type BootstrapStateMachine coord.StateMachine[BootstrapState, BootstrapEvent]

(cyclic dependency as the packages are structured now though)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I prefer to define interfaces where they are used. In this case the coordinator is using the StateMachine interface.

@dennis-tra
Copy link
Contributor

Priority is simple: the coordinator first attempts to advance the bootstrap state machine and only if it is idle, indicating the bootstrap has no further work, will it proceed to advance the query pool state machine.

I think it should be possible to already start queries as soon as we have connected to the first peer. It'll probably not be the fastest query but it'll resolve and will be faster than waiting for the whole bootstrap process to finish.

What should happen if the bootstrap is waiting for a message response but the caller attempts to start a user query?

IMO this should result in an error for the caller. So the coordinator would process that message normally, determine that it cannot start the query, and return an error to the caller.

However, I see your point and that, in the general case, it might be necessary to delay events until a certain state is reached.

I think having separate queues undermines the hierarchy of the hierarchical state machine. Sub-components can enqueue events with sub-states, by-passing the parent state machines that might want to react to or track these events as well. I think it's easier to reason about the control flow if data always flows from the root to the leaves and cannot skip any intermediate states.

@iand
Copy link
Contributor Author

iand commented Aug 14, 2023

I think having separate queues undermines the hierarchy of the hierarchical state machine. Sub-components can enqueue events with sub-states, by-passing the parent state machines that might want to react to or track these events as well. I think it's easier to reason about the control flow if data always flows from the root to the leaves and cannot skip any intermediate states.

I don't understand this point. The coordinator is coordinating state machines and is translating asynchronous behaviour into synchronous. That async to sync interface needs to have some kind of queueing. Each hierarchical state machine is synchronous and there is no enqueing of substates, only the coordinator is feeding async events to the top of each state machine hierarchy.

@iand
Copy link
Contributor Author

iand commented Aug 14, 2023

I think it should be possible to already start queries as soon as we have connected to the first peer. It'll probably not be the fastest query but it'll resolve and will be faster than waiting for the whole bootstrap process to finish.

I discuss this a bit in the design issue #45 and expand in this comment. If we're only connected to a bootstrap node then queries will hit that bootstrap node far more than is usual and the bootstrap node has to deal with that for every node that starts up. We should at least wait until the routing table is better populated before allowing queries (or we could throttle them based on bootstrap progress).

@iand
Copy link
Contributor Author

iand commented Aug 14, 2023

What should happen if the bootstrap is waiting for a message response but the caller attempts to start a user query?

IMO this should result in an error for the caller. So the coordinator would process that message normally, determine that it cannot start the query, and return an error to the caller.

That only works in this specific case. A more general example is what happens if a non-bootstrap query response is received as an event while the bootstrap is waiting for its own query response? There is no place to return an error in that case.

In general the coordinator will also be advancing other state machines like the routing table probe/refresh, exploring etc. These will essentially be background tasks and should be given the chance to progress and potentially change state, which needs to be returned by the coordinator. Any incoming event needs to be queued in that case.

The rust-libp2p design handles this by calling methods to mutate state on receipt of an inbound event. So if a message response is received it calls the equivalent of onMessageReceived on pool, which looks up the query and calls onMessageReceived on the query which then calls onMessageReceived on the iterator which looks up the responding node and changes its state.

@iand
Copy link
Contributor Author

iand commented Aug 15, 2023

@dennis-tra I moved the event queues out of the state machines and into the coordinator. After some thought I ended up agreeing with your point that this is the responsibility of the coordinator, and it allows state machines to be composed in a simple way

@iand iand mentioned this pull request Aug 16, 2023
3 tasks
@iand iand merged commit accf5ea into main Aug 16, 2023
@iand iand deleted the sm-bootstrap branch August 16, 2023 15:18
iand added a commit that referenced this pull request Aug 16, 2023
Based on sm-bootstrap branch (#90)

This adds a state machine for running the include process described in
#45. The state machine
manages a queue of candidates nodes and processes them by checking
whether they respond to a find node request. Candidates that respond
with one or more closer nodes are considered live and added to the
routing table. Nodes that do not respond or do not provide any suggested
closer nodes are dropped from the queue. The number of concurrent checks
in flight is configurable.

Not done yet:

- [ ] check timeouts
- [ ] removing nodes failing checks from routing table
- [ ] notifying of unroutable nodes
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.

Bootstrap process
2 participants