Skip to content

Latest commit

 

History

History
435 lines (336 loc) · 14.9 KB

README.md

File metadata and controls

435 lines (336 loc) · 14.9 KB

ServiceConcurrency

A concurrency and state library for .NET, shines in network calls.

NuGet package available at https://www.nuget.org/packages/ServiceConcurrency/

Intro

A library for handling concurrency and state, primarily for code that calls out to web services.

First, it prevents unnecessary calls from happening. When a call with matching arguments is already in flight, the concurrent caller is parked and will resume when the originating request finishes. If the argument is a collection, entities already in flight are stripped.

Second, it caches the state of value returning requests. When a cached value exists for any given request, it will be returned instead of a call being made. This value can be accessed at any time, preventing a need for additional backing fields in your code. If the argument is a collection, cached entities are stripped from the argument.

This library uses IMemoryCache for internal caching of data.

Real-life examples

Here's a few things ServiceConcurrency can handle for you. More in-depth examples can be found in later sections of this document.

GetSessionToken() will return a cached value after a first call has been made. If GetSessionToken() is called concurrently while it doesn't yet have a value cached, only one call to FetchSessionToken() will ever be made and the other concurrent calls will yield until a value is available.

private ServiceConcurrency.ReturnsValue<string> sessionTokenState =
    new ServiceConcurrency.ReturnsValue<string>();

public async Task<string> GetSessionToken()
{
    return await this.sessionTokenState.Execute(this.FetchSessionToken);
}

Same as above, but the argument collection is stripped of values that are already cached or in flight. So FetchUserProfiles() will never be called with an id more than once.

private ServiceConcurrency.TakesEnumerationArgReturnsValue<Guid, UserProfile> userProfilesState =
    new ServiceConcurrency.TakesEnumerationArgReturnsValue<Guid, UserProfile>();

public async Task<IEnumerable<UserProfile>> GetUserProfiles(IEnumerable<Guid> userProfileIds)
{
    return await this.userProfilesState.Execute(
        this.FetchUserProfiles,
        (guid, results) => results.SingleOrDefault(t => t.Id == guid),
        userProfileIds
    );
}

Classes

There are four classes in this library:

NoArgNoValue

Prevents concurrent calls by allowing only one active call at a time, where concurrent calls will wait for the active call to finish.

ReturnsValue<TValue>

Only one call will be made and subsequent calls will fetch the value from cache. Also prevents any concurrent calls from occurring, by allowing only one active call at a time, where concurrent calls will wait for the active call to finish.

TakesArg<TArg>

Prevents concurrent calls when sharing the same argument, by allowing only one active call at a time per argument, where concurrent calls will wait for the active call to finish.

TakesArgReturnsValue<TArg, TValue>

For a given argument, only one call will be made and subsequent calls will fetch the value from cache. Also prevents any concurrent calls from occurring, by allowing only one active call at a time per argument, where concurrent calls will wait for the active call to finish.

TakesEnumerationArg<TArg>

Concurrent calls will execute only once for a given argument in the argument collection.

The argument collection is stripped of values for which an operation is already in flight.

So simultaneously calling with ["A", "B", "C"] and ["B", "C", "D"] will result in one call with ["A", "B", "C"] and one with ["D"].

TakesEnumerationArgReturnsValue<TArg, TValue>

The first call, and any concurrent calls, will execute only once for a given argument in the argument collection. Subsequent calls will fetch value from cache.

The argument collection is stripped of values for which a cached value exists or an operation is already in flight.

So simultaneously calling with ["A", "B", "C"] and ["B", "C", "D"] will result in one call with ["A", "B", "C"] and one with ["D"]. The next time "A", "B", "C" or "D" is called with, it will be stripped from the collection and a value for it will be fetched from the cache.

API

Constructors

All ServiceConcurrency objects have a parameterless constructor, in which case an internal IMemoryCache will be created.

All ServiceConcurrency objects that return values also have a constructor that accepts an IMemoryCache object, in case you want to share the cache with all ServiceConcurrency objects. If you do, please make sure to use unique keys on the entries.

Methods

T Execute(...)

Executes a specific request. You provide a callback for making the outside call. See the examples for more information, as the arguments and return types are ServiceConcurrency type specific. The resulting value is cached and provided to you in your callback.

void Reset()

Resets the internal state, ie state for calls in process and internal cache (in case the ServiceConcurrency object returns values).

bool IsExecuting()

Returns whether the ServiceConcurrency object is currently executing a specific request.

The following methods are only available in ServiceConcurrency objects that return values.

void ResetCache()

Resets the internal cache. Also called from Reset().

bool TryGetValue(TArg key, out TValue value)

Gets a value from the cache.

void Set(TArg key, TValue value)

A cache setter in the rare case you might need to manipulate the cache manually.

void Remove(TArg key)

Removes a value from the cache.

bool ContainsKey(TArg key)

Checks whether an entry exists in the cache.

TValue this[TArg key]

Array operator for setting and getting cache entries. Throws a KeyNotFoundException in the getter if an entry doesn't exist.

IEnumerator<KeyValuePair<TArg, TValue>> GetEnumerator()
IEnumerator IEnumerable.GetEnumerator()

Enumerators for the internal cache. Allows you to use the objects in foreach and LINQ statements.

void Dispose()

Disposes of the internal cache in case bool IsCacheShared is false. If it's a shared cache it will just call ResetCache instead.

Properties

TValue Value;

Only in ReturnsValue<TValue>. This is the single cached object.

The following properties are only available in ServiceConcurrency objects that return values.

MemoryCacheEntryOptions CacheEntryOptions;

These options are internally used when a value is cached. Editing these allow you to set expiration, cache sizes etc. See MemoryCacheOptions for more details.

bool IsCacheShared;

Getter only. Denotes if the cache is shared or not.

Value conversion (optional)

Execute() accepts an optional value converter, which can modify the fetched value before returning and caching it. This is available only in the ServiceConcurrency objects that return values.

private ServiceConcurrency.ReturnsValue<string> lastName =
    new ServiceConcurrency.ReturnsValue<string>();

private const string FirstName = "John";

public async Task<string> GetFullName()
{
    return await this.lastName.Execute(
        this.GetLastName,
        (lastName) => $"{FirstName} {lastName}";
    );
}

ServiceConcurrency objects also accept an additional parameter that declare the value type of the internal request. When this is specified, the value converter will also convert between the source type and the destination type. This is useful if the request invoked by Execute() is of a different type than the desired backing field.

// FetchChatRooms() returns an IEnumerable<ChatRoom>, chatRoomMap handles it as Dictionary<Guid, ChatRoom>
private ServiceConcurrency.ReturnsValue<IEnumerable<ChatRoom>, Dictionary<Guid, ChatRoom>> chatRoomMap =
    new ServiceConcurrency.ReturnsValue<IEnumerable<ChatRoom>, Dictionary<Guid, ChatRoom>>();

public async Task<IEnumerable<ChatRoom>> UpdateChatRooms()
{
    return (await this.chatRoomMap.Execute(
        this.FetchChatRooms,
        (chatRooms) => chatRooms.ToDictionary(t => t.Id, t => t) // cache as id -> chatroom map
    ))?.Values;
}

public ChatRoom GetChatRoom(Guid chatRoomId)
{
    ChatRoom chatRoom;
    if (this.chatRoomMap.Value.TryGetValue(chatRoomId, out chatRoom)) // value is Dictionary<Guid, ChatRoom>
        return chatRoom;
    return null;
}

Examples

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;

public class MyService : IDisposable
{
    ////////////////////////////////////////////////////////////////////////////
    // NoArgNoValue example
    private ServiceConcurrency.NoArgNoValue simpleCallState =
        new ServiceConcurrency.NoArgNoValue();

    // Concurrent calls won't invoke the callback multiple times - only the first
    // call will invoke it, and the rest will wait until it finishes.
    public async Task CallSomething()
    {
        await this.simpleCallState.Execute(
            async () =>
            {
                Console.WriteLine("CallSomething call in flight");
                await Task.Delay(100);
            }
        );
    }


    ////////////////////////////////////////////////////////////////////////////
    // ReturnsValue example
    private ServiceConcurrency.ReturnsValue<string> returnsValueState =
        new ServiceConcurrency.ReturnsValue<string>();

    // Only one call will be made and subsequent calls will fetch the value from
    // cache. Also prevents any concurrent calls from occurring, by allowing only
    // one active call at a time, where concurrent calls will wait for the active
    // call to finish.
    public async Task<string> FetchSomething()
    {
        return await this.returnsValueState.Execute(
            async () =>
            {
                Console.WriteLine("FetchSomething call in flight");
                await Task.Delay(100);
                return "Hello world!";
            }
        );
    }


    ////////////////////////////////////////////////////////////////////////////
    // TakesArg example
    private ServiceConcurrency.TakesArg<Guid> takesArgState =
        new ServiceConcurrency.TakesArg<Guid>();

    // Prevents concurrent calls when sharing the same argument, by allowing only
    // one active call at a time per argument, where concurrent calls will wait for
    // the active call to finish.
    public async Task PostSomething(Guid someId)
    {
        await this.takesArgState.Execute(
            async (Guid id) =>
            {
                Console.WriteLine($"PostSomething call in flight, for argument {id}");
                await Task.Delay(100);
            },
            someId
        );
    }


    ////////////////////////////////////////////////////////////////////////////
    // TakesArgReturnsValue example
    private ServiceConcurrency.TakesArgReturnsValue<Guid, string> takesArgReturnsValueState =
        new ServiceConcurrency.TakesArgReturnsValue<Guid, string>();

    // For a given argument, only one call will be made and subsequent calls will
    // fetch the value from cache. Also prevents any concurrent calls from occurring,
    // by allowing only one active call at a time per argument, where concurrent
    // calls will wait for the active call to finish.
    public async Task<string> FetchSomethingFor(Guid someId)
    {
        return await this.takesArgReturnsValueState.Execute(
            async (Guid id) =>
            {
                Console.WriteLine($"FetchSomethingFor call in flight, for argument {id}");
                await Task.Delay(100);
                return $"The guid is {id}";
            },
            someId
        );
    }


    ////////////////////////////////////////////////////////////////////////////
    // TakesEnumerationArg example
    private ServiceConcurrency.TakesEnumerationArg<Guid> takesEnumerationArgState =
        new ServiceConcurrency.TakesEnumerationArg<Guid>();

    // Concurrent calls will execute only once for a given argument in the argument
    // collection.
    // 
    // The argument collection is stripped of values for which an operation is already
    // in flight.
    //
    // So simultaneously calling with ["A", "B", "C"] and ["B", "C", "D"] will result
    // in one call with ["A", "B", "C"] and one with ["D"].
    public async Task PostCollection(IEnumerable<Guid> someIds)
    {
        await this.takesEnumerationArgState.Execute(
            async (IEnumerable<Guid> ids) =>
            {
                Console.WriteLine($"PostCollection call in flight, for arguments {ids.Select(t => t)}");
                await Task.Delay(100);
            },
            someIds
        );
    }


    ////////////////////////////////////////////////////////////////////////////
    // TakesEnumerationArgReturnsValue example
    private ServiceConcurrency.TakesEnumerationArgReturnsValue<Guid, ExampleClass> takesEnumArgReturnsValueState =
        new ServiceConcurrency.TakesEnumerationArgReturnsValue<Guid, ExampleClass>();

    public class ExampleClass
    {
        public Guid Id { get; set; }
    }

    // The first call, and any concurrent calls, will execute only once for a
    // given argument in the argument collection. Subsequent calls will fetch value
    // from cache.
    // 
    // The argument collection is stripped of values for which a cached value exists
    // or an operation is alraedy in flight.
    //
    // So simultaneously calling with ["A", "B", "C"] and ["B", "C", "D"] will
    // result in one call with ["A", "B", "C"] and one with ["D"].
    // The next time "A", "B", "C" or "D" is called with, it will be stripped from
    // the collection and a value for it will be fetched from the cache.
    public async Task<IEnumerable<ExampleClass>> FetchCollectionForThese(IEnumerable<Guid> someIds)
    {
        return await this.takesEnumArgReturnsValueState.Execute(
            async (IEnumerable<Guid> ids) =>
            {
                Console.WriteLine($"FetchCollectionForThese call in flight, for arguments {ids.Select(t => t)}");
                await Task.Delay(100);

                return ids.Select(t => new ExampleClass()
                {
                    Id = t
                });
            },

            // a mapper from arg to result is required - should return the corresponding value for the passed argument
            (Guid id, IEnumerable<ExampleClass> result) => result.SingleOrDefault(t => t.Id == id),

            someIds
        );
    }
    
    void Dispose()
    {
        this.simpleCallState.Dispose();
        this.returnsValueState.Dispose();
        this.takesArgState.Dispose();
        this.takesArgReturnsValueState.Dispose();
        this.takesEnumerationArgState.Dispose();
        this.takesEnumArgReturnsValueState.Dispose();
    }
}