Skip to content
This repository has been archived by the owner on Dec 18, 2018. It is now read-only.

Client Subset - AllExcept #700

Merged
merged 9 commits into from
Aug 23, 2017
Merged

Client Subset - AllExcept #700

merged 9 commits into from
Aug 23, 2017

Conversation

mikaelm12
Copy link
Contributor

Wanted some initial review + wanted to get some discussion on how to do this for the RedisHubLifetimeManager. Discussed this with @moozzyk yesterday a little but I figured it would be good to put it out here.

@dnfclas
Copy link

dnfclas commented Aug 9, 2017

@mikaelm12,
Thanks for having already signed the Contribution License Agreement. Your agreement was validated by .NET Foundation. We will now review your pull request.
Thanks,
.NET Foundation Pull Request Bot

Copy link
Contributor

@moozzyk moozzyk left a comment

Choose a reason for hiding this comment

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

Some nits

@@ -491,7 +491,6 @@ public void HubsCannotHaveOverloadedMethods()
Task secondEndPointTask = endPoint.OnConnectedAsync(secondClient.Connection);
Copy link
Contributor

Choose a reason for hiding this comment

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

while you are at it: rename the test class and the file to HubEndPointTests

namespace Microsoft.AspNetCore.SignalR
{
public interface IHubClients
{
IClientProxy All { get; }

IClientProxy AllExcept(List<string> excludedIds);
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think having params on a public interface API is a great plan actually... I'd rather use IEnumerable or IReadOnlyList and have a params variant implemented in an extension method. params forces you to allocate an array, if you don't already have one (until we get params IEnumerable someday...)

Copy link
Contributor

Choose a reason for hiding this comment

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

I thought about IEnumerable but there is always a problem with lazy evaluation. IReadOnlyList is a good option. Agree that params is a little weird (you don't know connectionIds upfront but you need to know the count) but it saves you a few keystrokes if you want to exclude just one connectionId you store in a variable (e.g. .Others).

@@ -3,6 +3,7 @@

using System;
using System.Threading.Tasks;
using System.Collections.Generic;
Copy link
Contributor

Choose a reason for hiding this comment

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

needed?

Copy link
Contributor Author

@mikaelm12 mikaelm12 Aug 16, 2017

Choose a reason for hiding this comment

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

Edit: Yeah it is, I was looking at the wrong file when I originally commented.

@@ -141,6 +142,11 @@ public override Task InvokeAllAsync(string methodName, object[] args)
return _wrappedHubLifetimeManager.InvokeAllAsync(methodName, args);
}

public override Task InvokeAllExceptAsync(List<string> excludedIds, string methodName, object[] args)
{
return _wrappedHubLifetimeManager.InvokeAllExceptAsync( excludedIds, methodName, args);
Copy link
Contributor

Choose a reason for hiding this comment

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

format

{
return InvokeAllWhere(methodName, args, connection =>
{
return !excludedIds.Contains(connection.ConnectionId);
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure if this is for now but this feels slow

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, using a HashSet<string> for excludedIds would be faster, since it has a O(1) contains test

@BrennanConroy
Copy link
Member

BrennanConroy commented Aug 10, 2017

For redis; servers can subscribe to a new channel typeof(THub).FullName + ".AllExcept" and when AllExcept is called we can put the list of connections in the message and then when the servers receive it they could parse the message for the connections...

Or we could create an internal HubMessage wrapper that has a list of connections so the parsers could deal with it instead of us.

{
var serviceProvider = CreateServiceProvider();

dynamic endPoint = serviceProvider.GetService(GetEndPointType(typeof(MethodHub)));
Copy link
Member

Choose a reason for hiding this comment

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

var endPoint = serviceProvider.GetService<HubEndPoint<MethodHub>>();

Copy link
Member

Choose a reason for hiding this comment

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

Dynamic not needed in this test

@KPixel
Copy link

KPixel commented Aug 11, 2017

Great to see this feature coming.

I think its implementation should be the reverse of InvokeConnectionsAsync(). I discussed that one here: #683. Can you please include it?

I also like the idea of a HubMessage wrapper that takes the list of connectionIds. We can reuse it there too.

Can you change the type of excludedIds to ICollection<string>? It will enable using HashSet, which makes sense here.

Copy link
Contributor

@analogrelay analogrelay left a comment

Choose a reason for hiding this comment

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

Interface all the things!

{
return InvokeAllWhere(methodName, args, connection =>
{
return !excludedIds.Contains(connection.ConnectionId);
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, using a HashSet<string> for excludedIds would be faster, since it has a O(1) contains test

@@ -340,6 +339,11 @@ private HubMessage DeserializeMessage(RedisValue data)
return message;
}

public override Task InvokeAllExceptAsync(List<string> excludedIds, string methodName, object[] args)
{
throw new NotImplementedException();
Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming this is part of the WIP-ness of this PR ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah lol. Working this out now 😄

@@ -20,6 +22,11 @@ public HubContext(HubLifetimeManager<THub> lifetimeManager)

public virtual IGroupManager Groups { get; }

public IClientProxy AllExcept(List<string> excludedIds)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should use IReadOnlyList<string> or IEnumerable<string> here. We never plan to write to this list, and we don't care exactly how it's implemented. In fact, if our implementations are going to use HashSet<string>, IEnumerable would be better since we're going to need to convert it to a different type of collection anyway.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, should have a params string[] excludedIds overload (possibly via an extension method) so you can call .AllExcept(connection1, connection2, connection3)...

@@ -13,6 +14,8 @@ public abstract class HubLifetimeManager<THub>

public abstract Task InvokeAllAsync(string methodName, object[] args);

public abstract Task InvokeAllExceptAsync(List<string> excludedIds, string methodName, object[] args);
Copy link
Contributor

Choose a reason for hiding this comment

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

IEnumerable<string>/IReadOnlyList<string>

namespace Microsoft.AspNetCore.SignalR
{
public interface IHubClients
{
IClientProxy All { get; }

IClientProxy AllExcept(List<string> excludedIds);
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think having params on a public interface API is a great plan actually... I'd rather use IEnumerable or IReadOnlyList and have a params variant implemented in an extension method. params forces you to allocate an array, if you don't already have one (until we get params IEnumerable someday...)

private readonly HubLifetimeManager<THub> _lifetimeManager;
private readonly List<string> _excludedIds;

public AllClientsExceptProxy(HubLifetimeManager<THub> lifetimeManager, List<string> excludedIds)
Copy link
Contributor

Choose a reason for hiding this comment

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

IEnumerable<string>/IReadOnlyList<string>

Copy link

@KPixel KPixel Aug 11, 2017

Choose a reason for hiding this comment

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

Why not ICollection<string>? It is the lowest level interface that offers Contains().

Edit: The dev can choose between convenience (List<string>, or params string[]) and performance (Set<string>) or even some other custom implementation.

Copy link
Contributor

@analogrelay analogrelay Aug 12, 2017

Choose a reason for hiding this comment

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

We never need to write to the collection, so we should avoid using a writable interface, hence IReadOnlyCollection or IReadOnlyList would be a better option.

In general, we want to lead users towards good performance without them having to know how our code is implemented, so we try to avoid leading users to a path where they have to consider the performance of collection objects they give us. It's our job to make processing this collection fast, not the user's.

Copy link

Choose a reason for hiding this comment

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

IReadOnlyCollection and IReadOnlyList do not have a Contains() method, which is essential to the implementation.

And if we care about performance, we must allow passing an ISet-based collection.

So, I think it is better to take a (potentially) writable interface that performs well than take a read-only collection that SignalR must first convert to something like HashSet.

That interface ends up being ICollection because we also want to allow IList for convenience. Otherwise, the overload method taking params string[] cannot pass its parameter directly.

Copy link
Contributor

Choose a reason for hiding this comment

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

The point is moot until we have benchmarks to properly understand the performance characteristics in practical scenarios. Given that the most common scenario we've seen for this involves a very small number of entries in the list, we may just spin our wheels here for a while with almost no gain :).

The original comment is just about using an interface over the concrete List<T> type currently used. IReadOnlyCollection<T>/IReadOnlyList<T> are the "correct" expressions of what this public API requires, which is generally our first stop when building an API. Performance decisions to compromise that have to be data-driven and based on actual usage.

@mikaelm12
Copy link
Contributor Author

Tested this functionality with the presence chat sample and it looks pretty good 😄

@mikaelm12 mikaelm12 changed the title WIP Client Subset - AllExcept Client Subset - AllExcept Aug 17, 2017
@mikaelm12 mikaelm12 force-pushed the mikaelm12/ClientSubsets branch from 233374a to 2b7b869 Compare August 17, 2017 19:19
@mikaelm12
Copy link
Contributor Author

⬆️ 📅

{
return InvokeAllWhere(methodName, args, connection =>
{
var excludedIdsSet = new HashSet<string>(excludedIds);
Copy link
Member

Choose a reason for hiding this comment

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

Move this outside of the callback, right now you allocate a HashSet for every connection

{
var serviceProvider = CreateServiceProvider();

dynamic endPoint = serviceProvider.GetService(GetEndPointType(typeof(MethodHub)));
Copy link
Member

Choose a reason for hiding this comment

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

Dynamic not needed in this test

@@ -20,6 +22,11 @@ public HubContext(HubLifetimeManager<THub> lifetimeManager)

public virtual IGroupManager Groups { get; }

public IClientProxy AllExcept(IReadOnlyList<string> excludedIds)
Copy link
Member

Choose a reason for hiding this comment

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

Some public APIs use IReadOnlyList some use IReadOnlyCollection. Normalize them

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should all be IReadOnlyList now

namespace Microsoft.AspNetCore.SignalR
{
public interface IHubClients
{
IClientProxy All { get; }

IClientProxy AllExcept(IReadOnlyList<string> excludedIds);
//IClientProxy AllExcept(params string[] excludedIds);
Copy link
Member

Choose a reason for hiding this comment

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

Remove

@@ -124,8 +124,9 @@ public Task<string> SendInvocationAsync(string methodName, params object[] args)
public async Task<string> SendInvocationAsync(string methodName, bool nonBlocking, params object[] args)
{
var invocationId = GetInvocationId();

var payload = _protocolReaderWriter.WriteMessage(new InvocationMessage(invocationId, nonBlocking, methodName, args));
var invocationMessage = new InvocationMessage(invocationId, nonBlocking, methodName, args);
Copy link
Member

Choose a reason for hiding this comment

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

Revert

public override Task InvokeAllExceptAsync(string methodName, object[] args, IReadOnlyCollection<string> excludedIds)
{
var message = new InvocationMessage(GetInvocationId(), nonBlocking: true, target: methodName, arguments: args);
var data = new object[] { message, excludedIds };
Copy link
Member

Choose a reason for hiding this comment

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

This looks... What was wrong with making a class that wraps InvocationMessage and adds a IReadOnlyCollection?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was a WIP hack that I didn't update 😄

@mikaelm12
Copy link
Contributor Author

🆙 📅

var message = DeserializeMessage<RedisExcludeClientMessage>(data);
var invocationMessage = (InvocationMessage)message.HubMessage;
var excludedIds = message.ExcludedIds;
var excludedIdsSet = new HashSet<string>(excludedIds);
Copy link
Member

Choose a reason for hiding this comment

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

This isn't used

}

private async Task PublishAsync(string channel, HubMessage hubMessage)
private async Task PublishAsync(string channel, object args)
Copy link
Member

Choose a reason for hiding this comment

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

Revert

@@ -362,6 +394,17 @@ public override void WriteLine(string value)
}
}

private class RedisExcludeClientMessage
Copy link
Member

Choose a reason for hiding this comment

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

This should be able to inherit from InvocationMessage

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would it make more sense to inherit from HubMessage?

{
return InvokeAllWhere(methodName, args, connection =>
{
var excludedIdsSet = new HashSet<string>(excludedIds);
Copy link
Member

Choose a reason for hiding this comment

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

Should just get rid of this and call .Contains on excludedIds

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, this will create a new HashSet for every check. If we decide we need a HashSet for optimization, we would want to initialize it outside the lambda so there's only one instance.

public override Task InvokeAllExceptAsync(string methodName, object[] args, IReadOnlyList<string> excludedIds)
{
var message = new InvocationMessage(GetInvocationId(), nonBlocking: true, target: methodName, arguments: args);
var redisExcludeMessage = new RedisExcludeClientMessage(message, excludedIds);
Copy link
Member

Choose a reason for hiding this comment

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

The point of making it inherit from InvocationMessage was that you could avoid newing up two objects, and just do a single RedisExcludeClientMessage

Copy link
Contributor

Choose a reason for hiding this comment

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

Hrm... feels like a a premature optimization, but I guess we can do it.

Assert.Equal("Send", invocation.Target);
Assert.Equal("To second", invocation.Arguments[0]);

var thirdClientResult = await thirdClient.ReadAsync().OrTimeout(); ;
Copy link
Member

Choose a reason for hiding this comment

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

extra semi-colon

{
public IReadOnlyList<string> ExcludedIds;

public RedisExcludeClientsMessage(string invocationId, bool nonBlocking, string target, IReadOnlyList<string> excludedIds, params object[] arguments) : base(invocationId, nonBlocking, target, arguments)
Copy link
Member

Choose a reason for hiding this comment

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

Should probably add a newline


public override Task InvokeAllExceptAsync(string methodName, object[] args, IReadOnlyList<string> excludedIds)
{
var message = new RedisExcludeClientsMessage(GetInvocationId(), nonBlocking: true, target: methodName, excludedIds: excludedIds ,arguments: args);
Copy link
Member

Choose a reason for hiding this comment

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

formatting excludedIds ,arguments:

@mikaelm12 mikaelm12 mentioned this pull request Aug 21, 2017
var invocationMessage = (InvocationMessage)message.HubMessage;
var excludedIds = message.ExcludedIds;

//// TODO: This isn't going to work when we allow JsonSerializer customization or add Protobuf
Copy link
Contributor

Choose a reason for hiding this comment

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

How is it not going to work? If we're just writing serialized data directly to the connection, then we're broken for MsgPack too, if one node is using JSON and the other is using MsgPack. If we're serializing separately for every connection, then it should work fine with JsonSerializer customization and Protobuf because each send uses the client's configured serializers.

Copy link
Contributor

Choose a reason for hiding this comment

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

I derped and commented on outdated stuff. Disregard

public override Task InvokeAllExceptAsync(string methodName, object[] args, IReadOnlyList<string> excludedIds)
{
var message = new InvocationMessage(GetInvocationId(), nonBlocking: true, target: methodName, arguments: args);
var redisExcludeMessage = new RedisExcludeClientMessage(message, excludedIds);
Copy link
Contributor

Choose a reason for hiding this comment

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

Hrm... feels like a a premature optimization, but I guess we can do it.

HubMessage = message;
ExcludedIds = ids;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

need an extra line

{
return InvokeAllWhere(methodName, args, connection =>
{
var excludedIdsSet = new HashSet<string>(excludedIds);
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, this will create a new HashSet for every check. If we decide we need a HashSet for optimization, we would want to initialize it outside the lambda so there's only one instance.

var message = DeserializeMessage<RedisExcludeClientsMessage>(data);
var excludedIds = message.ExcludedIds;

//// TODO: This isn't going to work when we allow JsonSerializer customization or add Protobuf
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you clarify how this is broken with JsonSerializer customization and Protobuf? I'd think it'd already be broken for MsgPack if that was the case. We should be re-serializing for each client in the current implementation, right? It's slower, but guarantees it's correct and should work fine with serializer customization.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comment is a copy pasta result. Not sure tbh

Copy link
Member

Choose a reason for hiding this comment

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

super nit: the comment has too many ////

_logger.LogInformation("Subscribing to channel: {channel}", channelName);
_bus.Subscribe(channelName, async (c, data) =>
{
await previousBroadcastTask;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this correct to use the same task as broadcast?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yeah. This doesn't look good.

}
}

previousBroadcastTask = Task.WhenAll(tasks);
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 this is wrong/racey - you can replace a task that has not been finished with another task that is not finished. Sharing the same task between broadcast and .allexcept makes it even more racey

@mikaelm12
Copy link
Contributor Author

If there's no further discussion I'm gonna merge this eod

@mikaelm12 mikaelm12 merged commit d469cc3 into dev Aug 23, 2017
@mikaelm12 mikaelm12 deleted the mikaelm12/ClientSubsets branch September 29, 2017 23:51
@moozzyk moozzyk mentioned this pull request Oct 11, 2017
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants