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

stream: force subscription store check as stop gap for wrapper side implementation #1717

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions exchanges/bybit/bybit_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/gofrs/uuid"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
"github.com/thrasher-corp/gocryptotrader/exchanges/subscription"
"github.com/thrasher-corp/gocryptotrader/types"
)

Expand All @@ -33,10 +34,11 @@ type Authenticate struct {

// SubscriptionArgument represents a subscription arguments.
type SubscriptionArgument struct {
auth bool `json:"-"`
RequestID string `json:"req_id"`
Operation string `json:"op"`
Arguments []string `json:"args"`
auth bool `json:"-"`
RequestID string `json:"req_id"`
Operation string `json:"op"`
Arguments []string `json:"args"`
associatedSubs subscription.List `json:"-"` // Used to store associated subscriptions
gbjk marked this conversation as resolved.
Show resolved Hide resolved
}

// Fee holds fee information
Expand Down
40 changes: 29 additions & 11 deletions exchanges/bybit/bybit_websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,28 +167,30 @@ func (by *Bybit) handleSubscriptions(operation string, subs subscription.List) (
if err != nil {
return
}
chans := []string{}
authChans := []string{}
var chans subscription.List
var authChans subscription.List
gbjk marked this conversation as resolved.
Show resolved Hide resolved
for _, s := range subs {
if s.Authenticated {
authChans = append(authChans, s.QualifiedChannel)
authChans = append(authChans, s)
} else {
chans = append(chans, s.QualifiedChannel)
chans = append(chans, s)
}
}
for _, b := range common.Batch(chans, 10) {
args = append(args, SubscriptionArgument{
Operation: operation,
RequestID: strconv.FormatInt(by.Websocket.Conn.GenerateMessageID(false), 10),
Arguments: b,
Operation: operation,
RequestID: strconv.FormatInt(by.Websocket.Conn.GenerateMessageID(false), 10),
Arguments: b.QualifiedChannels(),
associatedSubs: b,
})
}
if len(authChans) != 0 {
args = append(args, SubscriptionArgument{
auth: true,
Operation: operation,
RequestID: strconv.FormatInt(by.Websocket.Conn.GenerateMessageID(false), 10),
Arguments: authChans,
auth: true,
Operation: operation,
RequestID: strconv.FormatInt(by.Websocket.Conn.GenerateMessageID(false), 10),
Arguments: authChans.QualifiedChannels(),
associatedSubs: authChans,
})
}
return
Expand Down Expand Up @@ -225,6 +227,22 @@ func (by *Bybit) handleSpotSubscription(operation string, channelsToSubscribe su
if !resp.Success {
return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.RetMsg)
}

var conn stream.Connection
if payloads[a].auth {
conn = by.Websocket.AuthConn
} else {
conn = by.Websocket.Conn
}
gloriousCode marked this conversation as resolved.
Show resolved Hide resolved

if operation == "unsubscribe" {
err = by.Websocket.RemoveSubscriptions(conn, payloads[a].associatedSubs...)
} else {
err = by.Websocket.AddSubscriptions(conn, payloads[a].associatedSubs...)
}
if err != nil {
return err
}
}
return nil
}
Expand Down
9 changes: 8 additions & 1 deletion exchanges/deribit/deribit_websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,14 @@ func (d *Deribit) handleSubscription(method string, subs subscription.List) erro
err = common.AppendError(err, errors.New(s.String()))
}
}
return err
if err != nil {
return err
}

if method == "unsubscribe" {
return d.Websocket.RemoveSubscriptions(d.Websocket.Conn, subs...)
}
return d.Websocket.AddSubscriptions(d.Websocket.Conn, subs...)
Copy link
Collaborator

@gloriousCode gloriousCode Jan 28, 2025

Choose a reason for hiding this comment

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

Is there something I am missing with this?
image
This inclusion does not appear to make much sense to me, given that by this point all subscriptions have been set to the Subscribed state due the for loop above this

Why aren't you handling the unsubscribe in the for loop above?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nice catch, didn't even notice that. None of those subs were being added to the subscription store and my changes complained. I will check it out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

}

func getValidatedCurrencyCode(pair currency.Pair) string {
Expand Down
59 changes: 40 additions & 19 deletions exchanges/stream/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ var (
errReadMessageErrorsNil = errors.New("read message errors is nil")
errWebsocketSubscriptionsGeneratorUnset = errors.New("websocket subscriptions generator function needs to be set")
errSubscriptionsExceedsLimit = errors.New("subscriptions exceeds limit")
errSubscriptionsNotAdded = errors.New("subscriptions not added")
errSubscriptionsNotRemoved = errors.New("subscriptions not removed")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Now that these are being returned by Connect(), I think it would be nice to export these so you can help distinguish an error from connecting or an error for subscribing and act accordingly

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

errInvalidMaxSubscriptions = errors.New("max subscriptions cannot be less than 0")
errSameProxyAddress = errors.New("cannot set proxy address to the same address")
errNoConnectFunc = errors.New("websocket connect func not set")
Expand Down Expand Up @@ -372,6 +374,10 @@ func (w *Websocket) connect() error {
if err := w.SubscribeToChannels(nil, subs); err != nil {
return err
}

if w.subscriptions.Len() != len(subs) {
return fmt.Errorf("%s %w expecting %d subscribed", w.exchangeName, errSubscriptionsNotAdded, len(subs))
}
}
return nil
}
Expand Down Expand Up @@ -455,6 +461,11 @@ func (w *Websocket) connect() error {
break
}

if len(subs) != 0 && w.connectionManager[i].Subscriptions.Len() != len(subs) {
multiConnectFatalError = fmt.Errorf("%v %w expecting %d subscribed %v", w.exchangeName, errSubscriptionsNotAdded, len(subs), subs)
break
Copy link
Collaborator

Choose a reason for hiding this comment

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

I still have this problem:
We call connect on an exchange, one pair on one asset fails to subscribe (like TRUMPEW the other day) and all connections get torn down for all asset types and subs.
I still don't agree with that.
The biggest problem I think I have is the strong coupling between connections and subs.
If a consumer wants to connect the websocket without subs, and then later call Subscribe manually, we both allow it, and don't support it at the same time.

I think this is a wider topic, because this change doesn't make 454/460 worse, but it doubles down on the concept.

I think we need to say "Connecting is connecting, and Subscribing is separate" especially when new subs or resubs could connect new subs.

I'll continue to work on this, so I'm just highlighting for alignment on direction.

In the meantime, I'd gently request again to log the error, and return it, but not tear down all of the subs and conns for all assets.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We call connect on an exchange, one pair on one asset fails to subscribe (like TRUMPEW the other day) and all connections get torn down for all asset types and subs.

Do you remember what specifically caused this?

The coupling connect() with subs is not great, regarding "Connecting is connecting, and Subscribing is separate" though is I think connect function might be changed to handle connections that don't require subscriptions and spawn a middleware handler that performs a JIT connection based on current/incoming subscriptions which also drops connections when unsubscribing when subs is empty, that way it scales a bit better with respect to a connections max sub capacity. I don't currently have a design that could help you in that respect though.

In the meantime, I'd gently request again to log the error, and return it, but not tear down all of the subs and conns for all assets.

Sure, if it's easier for everyone else I will just log it and I can just modify my own trading branch to be throwing the baby out with the bathwater myself. 🤷

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@gloriousCode @thrasher- I pivot off these errors in my own trading branches that retries connect until it establish a clean slate or base line of full subscriptions across all trading pairs that I require. Happy to just log this out like GK suggests if we all agree and I will update tests.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you remember what specifically caused this?

A badly listed futures pair erroring on orderbook.

I pivot off these errors in my own trading branches that retries connect until it establish a clean slate or base line of full subscriptions across all trading pairs that I require.

Okay. This might be the crux of our disagreement.
You should not need to disconnect and unsub everything just because one thing fails, if your goal is to ensure it eventually works.
connectionManager should be monitoring and reconnecting, and if it doesn't have requisite granularity we should improve that.
And I think subscriptionManager should manager subscriptions.

What I'm aiming for is smaller responsibilities for functions that have names (and meanings) like connect, so they're more versatile (or composable) and easier to test.

Can you throw me any other requirements you might have like "retry subs and conns until everything is working", if you have any ?

I see exactly the same situation, and also wanna know "When's everything looking good?".

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

What I'm aiming for is smaller responsibilities for functions that have names (and meanings) like connect, so they're more versatile (or composable) and easier to test.

Good direction to have, I agree.

Can you throw me any other requirements you might have like "retry subs and conns until everything is working", if you have any ? && "When's everything looking good?"

Pretty much just this; Personally, I am still going to rely on ensuring a clean slate of full subscriptions before proceeding. If even one subscription fails, it indicates a potential issue with the connection or the subscription logic, and I prefer to retry until everything is established correctly, or bring the instance offline and fix it.

Copy link
Collaborator

@gloriousCode gloriousCode Jan 29, 2025

Choose a reason for hiding this comment

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

  • I see gk's issue with regards to killing all connections, but I don't see it as something huge because we already kill everything when Subscribe is called and return errors. The refactoring you speak of gk will change that section regardless, but I get not wanting to tangle further.
  • Logging the issue isn't actionable via code and so I don't think that's the best way to go

I propose we move subscribing for multi-connections to the end of the connect() function. It is a small change™️ that satisfies the following criteria:

  • The default handling of websocket connections via websocketroutine_manager.go will log any errors returned
  • It won't by default kill all connections when subscriptions have issue
  • It allows users who call Connect() directly to handle any errors that are returned, and resubscribe/reconnect/panic("help!")

Gist:
https://gist.github.com/gloriousCode/609d25ea1ee9b954ecbb95b505c76609

It comes with the caveat that I haven't tested things thoroughly. I tried things like separating subscribing, but that's way too large a refactor for this PR and comes with many extra considerations that I don't think are what is desired out of this PR - which is to ensure all subscriptions are subscribed to and that the caller knows it

Does this solution make people happy?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep, that's a good compromise with minimal intrusion.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

}

if w.verbose {
log.Debugf(log.WebsocketMgr, "%s websocket: [conn:%d] [URL:%s] connected. [Subscribed: %d]",
w.exchangeName,
Expand Down Expand Up @@ -625,14 +636,7 @@ func (w *Websocket) FlushChannels() error {
if err != nil {
return err
}
subs, unsubs := w.GetChannelDifference(nil, newSubs)
if err := w.UnsubscribeChannels(nil, unsubs); err != nil {
return err
}
if len(subs) == 0 {
return nil
}
return w.SubscribeToChannels(nil, subs)
return w.updateChannelSubscriptions(nil, w.subscriptions, newSubs)
}

for x := range w.connectionManager {
Expand All @@ -658,17 +662,9 @@ func (w *Websocket) FlushChannels() error {
w.connectionManager[x].Connection = conn
}

subs, unsubs := w.GetChannelDifference(w.connectionManager[x].Connection, newSubs)

if len(unsubs) != 0 {
if err := w.UnsubscribeChannels(w.connectionManager[x].Connection, unsubs); err != nil {
return err
}
}
if len(subs) != 0 {
if err := w.SubscribeToChannels(w.connectionManager[x].Connection, subs); err != nil {
return err
}
err = w.updateChannelSubscriptions(w.connectionManager[x].Connection, w.connectionManager[x].Subscriptions, newSubs)
if err != nil {
return err
}

// If there are no subscriptions to subscribe to, close the connection as it is no longer needed.
Expand All @@ -683,6 +679,31 @@ func (w *Websocket) FlushChannels() error {
return nil
}

// updateChannelSubscriptions subscribes or unsubscribes from channels and checks that the correct number of channels
// have been subscribed to or unsubscribed from.
func (w *Websocket) updateChannelSubscriptions(c Connection, store *subscription.Store, incoming subscription.List) error {
gloriousCode marked this conversation as resolved.
Show resolved Hide resolved
subs, unsubs := w.GetChannelDifference(c, incoming)
if len(unsubs) != 0 {
prevState := store.Len()
if err := w.UnsubscribeChannels(c, unsubs); err != nil {
return err
}
if diff := prevState - store.Len(); diff != len(unsubs) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't feel right ...

  1. Shouldn't Unsubscribe be erroring if it wasn't successful ?
  2. Shouldn't the state of all subs in unsubs be changing ?
    That said, it's not too bad either.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Shouldn't Unsubscribe be erroring if it wasn't successful ?

I think it should, I just added it as a catch all in the event we forgot to remove it from the store when it was successful. Then it should complain. should 😬

Shouldn't the state of all subs in unsubs be changing

Now you are making me think this is all completely wrong 😆. This specifically didn't catch any issues. Can you suggest a better way as a back up check? Cause I am drooling at my screen trying to figure it out 🤤.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sorry for late reply on this.
I think I'd want see that ranging the subs and unsubs have changed State, and that store contains (or doesn't) each one. ContainsAll and ContainsNone or something.
I really don't like checking len as a catchall, because it could false positive.
We're not locking store when we do any of this, afterall.

return fmt.Errorf("%v %w expected %d unsubscribed", w.exchangeName, errSubscriptionsNotRemoved, len(unsubs))
}
}
if len(subs) != 0 {
prevState := store.Len()
if err := w.SubscribeToChannels(c, subs); err != nil {
return err
}
if diff := store.Len() - prevState; diff != len(subs) {
return fmt.Errorf("%v %w expected %d subscribed", w.exchangeName, errSubscriptionsNotAdded, len(subs))
}
}
return nil
}

func (w *Websocket) setState(s uint32) {
w.state.Store(s)
}
Expand Down
Loading
Loading