-
Notifications
You must be signed in to change notification settings - Fork 9
feat: add connection retry to proxyProvider #492
Conversation
f154fa4
to
044b8a5
Compare
const ids = U.throwIfError( | ||
await C.entryRead(C.polkadot)("Paras", "Parachains", []) | ||
.access("value") | ||
.as<number[]>() | ||
.run(), | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I re-wrote this because there is an issue with the client reference counting.
After
const ids = C.entryRead(C.polkadot)("Paras", "Parachains", [])
.access("value")
.as<number[]>()
The client reference count reaches 1
and it's discarded in
Lines 93 to 102 in 7edf6a2
async function discardCheck<CloseErrorData>( | |
client: rpc.Client<any, any, any, CloseErrorData>, | |
counter: Z.RcCounter, | |
) { | |
counter.i-- | |
if (!counter.i) { | |
return await client.discard() | |
} | |
return | |
} |
The above invokes the proxyProvider.release
that removes the WebSocket
listener.
As a result the next call C.Z.each(...)
successfully creates a new WebSocket
but there is not listener for the incoming messages.
Note: The listener is placed by the rpcClient
effect.
rpc/provider/proxy.ts
Outdated
const activeWs = new Map<string, WebSocket>() | ||
const connectingWs = new Map<string, Promise<WebSocket>>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 2 maps are used for
- caching an already opened
WebSocket
- deduplicating
WebSocket
creation while there is one that is connecting (not OPEN)
const listenersContainer = new ListenersContainer<string, Event, Event>() | ||
const activeWs = new Map<string, WebSocket>() | ||
const connectingWs = new Map<string, Promise<WebSocket>>() | ||
const CUSTOM_WS_CLOSE_CODE = 4000 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Status codes in the range 4000-4999 are reserved for private use (see https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1)
This code is used to signal a graceful client WebSocket
close.
@@ -71,3 +71,54 @@ export function nextIdFactory() { | |||
let i = 0 | |||
return () => i++ | |||
} | |||
|
|||
export class ListenersContainer< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The intent of this class is to replace the ProviderConnection
class and use it in the smoldot
provider too.
The ProviderConnection
class has the inner
and cleanUp
props, but they are not used by the class methods, so it seems that they don't belong to this class.
After removing these props from ProviderConnection
, this class has the responsibility of containing listeners for a single WebSocket
connection.
The ListenersContainer
is an improvement on the above that can contain listeners for many WebSocket
connections.
rpc/provider/proxy.ts
Outdated
const activeWs = new Map<string, WebSocket>() | ||
const connectingWs = new Map<string, Promise<WebSocket>>() | ||
const CUSTOM_WS_CLOSE_CODE = 4000 | ||
|
||
export const proxyProvider: Provider<string, Event, Event, Event> = (url, listener) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe that we need to pass retryOptions here.
Perhaps, creating with a proxyProviderFactory
const proxyProviderFactory = (retryOptions) => (url, listener) => {...};
const proxyProvider = proxyProviderFactory(defaultRetryOptions);
Thoughts?
Co-authored-by: Harry Solovay <harrysolovay@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With these changes, provider send
calls are more likely to successfully go through, bc unexpected provider open failures are caught and their opens are retried. This is great. However, is this the right first step towards a comprehensive recovery story? Does it position us to address other parts of the recovery story?
What else might trigger a retry? Can that retry be triggered by the client handler?
As a thought experiment, what refactoring would be required to not boil in the retry logic to the provider itself.
const p = retryProvider(baseProvider, retryOptions)
const c = new Client(p, discoveryValue)
Note: it is unclear to me whether retry logic should or should not be the default... but this is an important consideration given our thinking that we'll gradually add on recovery-related safeguards in the
handler
ofClient
. With this in mind, should things such asopenedWs
be accessible fromClient
? What other provider context might we want to access from elsewhere? And when?
@@ -106,6 +137,6 @@ function closeWs(socket: WebSocket): Promise<undefined | ProviderCloseError<Even | |||
controller.abort() | |||
resolve(new ProviderCloseError(e)) | |||
}, controller) | |||
socket.close() | |||
socket.close(CUSTOM_WS_CLOSE_CODE, "Client normal closure") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where will devs see the "Client normal closure"
message?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this would be the CloseEvent.reason
prop
https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/reason
rpc/provider/proxy.ts
Outdated
controller.abort() | ||
resolve(e) | ||
}, controller) | ||
function openedWs({ url, activeWs, connectingWs, listener, retryOptions }: OpenedWsProps) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we explicitly type the return type for legibility?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll add it to improve readability
export const proxyProviderFactory = ( | ||
{ retryOptions }: ProxyProviderFactoryProps = {}, | ||
): Provider<string, Event, Event, Event> => { | ||
const listenersContainer = new ListenersContainer<string, Event, Event>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be within the module scope? If not, multiple providers could house websockets of identical discovery values (correct?). On the flip side, the current approach means we don't need to track the number of "users" of a globally accessible ws instance (to decide whether or not it can actually be closed).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It depends on how the factory is used.
Calling the factory once and sharing the created provider works similar to not having a factory and using a module variable to cache stuff.
With the factory approach there is an improvement for concurrent unit tests and fixes some unit tests caching issues.
HandlerErrorData, | ||
> { | ||
#listeners = new Map< | ||
DiscoveryValue, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious how this will be generalized for smoldot (perhaps I recall incorrectly: the discovery value must first be retrieved based on the chainspec, right?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DiscoveryValue
is not a good name.
This is just a key type for Map
so it doesn't always need to be the provider discoveryValue
For smoldot, it's a bit challenging to pick a key for parachains.
To connect to a parachain, we need the potential relay chain spec.
So, a better key could be the concatenation of the relay+parachain specs.
Currently the key is an object which can easily have a different object ref for the same values
{
chainSpec: {
relay: string
para?: string
}
}
@harrysolovay I believe that we could The new signature for call and subscription could be
|
For context: this is on hold until @kratico returns from the academy :) |
Add proxy connection retry.
The retry logic uses https://deno.land/std@0.170.0/async/mod.ts?s=retry
How it works?
WebSocket
WebSocket
gets closed, attached listeners will be sent an errorWebSocket
will apply the retry logic againThere are 2 topics about retries
This PR attempts to solve the 1st bullet by improving the provider.
To solve the call/subscription retry, some knowledge about the inflight message intents (and ids) is needed.
That means that call/subscription retry need to be solved at the client level or above.
Next step
smoldot