├── img ├── running.png ├── projections.drawio.png └── websockets.drawio.png ├── src ├── Projections │ ├── PostgresOptions.cs │ ├── Teams │ │ ├── TeamProjection.cs │ │ ├── TeamQueryHandler.cs │ │ └── TeamSubscriber.cs │ ├── Tournaments │ │ ├── TournamentProjection.cs │ │ ├── TournamentQueryHandler.cs │ │ └── TournamentSubscriber.cs │ ├── Projections.csproj │ └── ProjectionManager.cs ├── WebSockets │ ├── WebSocketMessage.cs │ ├── WebSockets.csproj │ ├── TeamSubscriber.cs │ └── TournamentSubscriber.cs ├── Domain.Abstractions │ ├── StreamConfig.cs │ ├── ITraceable.cs │ ├── ErrorHasOccurred.cs │ ├── Domain.Abstractions.csproj │ └── Grains │ │ ├── EventSourcedGrain.cs │ │ └── SubscriberGrain.cs ├── API │ ├── Teams │ │ ├── Input.cs │ │ ├── Output.cs │ │ └── Controller.cs │ ├── Response.cs │ ├── Tournaments │ │ ├── Input.cs │ │ ├── Output.cs │ │ └── Controller.cs │ ├── Dockerfile │ ├── API.csproj │ ├── Middlewares │ │ └── WebSocketPubSubMiddleware.cs │ └── Program.cs ├── Domain │ ├── Constants.cs │ ├── Teams │ │ ├── Rules.cs │ │ ├── Commands.cs │ │ ├── Events.cs │ │ ├── State.cs │ │ └── Grain.cs │ ├── Domain.csproj │ ├── Tournaments │ │ ├── Commands.cs │ │ ├── Events.cs │ │ ├── State.cs │ │ ├── Rules.cs │ │ ├── ValueObjects.cs │ │ └── Grain.cs │ ├── Results.cs │ └── Sagas │ │ └── TeamAddedSaga.cs ├── API.Identity │ ├── Dockerfile │ ├── API.Identity.csproj │ ├── UserController.cs │ ├── Program.cs │ └── UserAuthentication.cs ├── Silo.Dashboard │ ├── Dockerfile │ ├── Silo.Dashboard.csproj │ └── Program.cs └── Silo │ ├── Dockerfile │ ├── Silo.csproj │ └── Program.cs ├── .dockerignore ├── postgres ├── 05_auth_schema.sql ├── 01_orleans_main.sql ├── 04_read_schema.sql ├── Dockerfile ├── 03_orleans_persistence.sql └── 02_orleans_clustering.sql ├── kubernetes ├── postgres-service.yaml ├── secrets.yaml ├── api-service.yaml ├── postgres-deployment.yaml ├── silo-service.yaml ├── config-maps.yaml ├── silo-deployment.yaml └── api-deployment.yaml ├── utils └── WebSockets.Client │ ├── WebSockets.Client.csproj │ └── Program.cs ├── kind-cluster.yaml ├── tests └── Domain.Tests │ ├── Domain.Tests.csproj │ └── FixtureTest.cs ├── LICENSE ├── Makefile ├── .gitignore ├── Orleans.Tournament.sln └── readme.md /img/running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmorelli92/Orleans.Tournament/HEAD/img/running.png -------------------------------------------------------------------------------- /img/projections.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmorelli92/Orleans.Tournament/HEAD/img/projections.drawio.png -------------------------------------------------------------------------------- /img/websockets.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmorelli92/Orleans.Tournament/HEAD/img/websockets.drawio.png -------------------------------------------------------------------------------- /src/Projections/PostgresOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Projections; 2 | 3 | public record PostgresOptions(string ConnectionString); -------------------------------------------------------------------------------- /src/WebSockets/WebSocketMessage.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.WebSockets; 2 | 3 | public record WebSocketMessage(string Type, object Payload); -------------------------------------------------------------------------------- /src/Domain.Abstractions/StreamConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Abstractions; 2 | 3 | public record StreamConfig(string Name, string Namespace); -------------------------------------------------------------------------------- /src/API/Teams/Input.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.API.Teams; 2 | 3 | public record CreateTeamModel(string Name); 4 | 5 | public record AddPlayerModel(IEnumerable Names); -------------------------------------------------------------------------------- /src/API/Response.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.API; 2 | 3 | public record TraceResponse(Guid TraceId); 4 | 5 | public record ResourceResponse(Guid Id, Guid TraceId) : TraceResponse(TraceId); -------------------------------------------------------------------------------- /src/Domain.Abstractions/ITraceable.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Abstractions; 2 | 3 | public interface ITraceable 4 | { 5 | Guid TraceId { get; } 6 | 7 | Guid InvokerUserId { get; } 8 | } -------------------------------------------------------------------------------- /src/Domain.Abstractions/ErrorHasOccurred.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Abstractions; 2 | 3 | public record ErrorHasOccurred( 4 | int Code, 5 | string Name, 6 | Guid TraceId, 7 | Guid InvokerUserId) : ITraceable; -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/bin 2 | **/obj 3 | **/out 4 | **/TestResults 5 | .git 6 | .gitignore 7 | .idea/ 8 | .vs/ 9 | .vscode/ 10 | postgres/ 11 | .md 12 | tests/ 13 | **/OrleansAdoNetContent/ 14 | kubernetes/ 15 | LICENSE 16 | **/wwwroot/ 17 | -------------------------------------------------------------------------------- /postgres/05_auth_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA auth; 2 | 3 | CREATE TABLE auth.user ( 4 | id UUID NOT NULL, 5 | email TEXT NOT NULL, 6 | password_hash TEXT NOT NULL, 7 | salt_key TEXT NOT NULL, 8 | claims TEXT ARRAY NOT NULL, 9 | CONSTRAINT user_pk PRIMARY KEY (id) 10 | ); -------------------------------------------------------------------------------- /postgres/01_orleans_main.sql: -------------------------------------------------------------------------------- 1 | -- https://github.com/dotnet/orleans/blob/3.x/src/AdoNet/Shared/PostgreSQL-Main.sql 2 | CREATE TABLE OrleansQuery 3 | ( 4 | QueryKey varchar(64) NOT NULL, 5 | QueryText varchar(8000) NOT NULL, 6 | 7 | CONSTRAINT OrleansQuery_Key PRIMARY KEY(QueryKey) 8 | ); 9 | -------------------------------------------------------------------------------- /kubernetes/postgres-service.yaml: -------------------------------------------------------------------------------- 1 | kind: Service 2 | apiVersion: v1 3 | metadata: 4 | name: postgres-service 5 | spec: 6 | selector: 7 | app: postgres 8 | ports: 9 | - name: tcp 10 | port: 5432 11 | targetPort: 5432 #internal port 12 | nodePort: 30700 #external port 13 | protocol: TCP 14 | type: NodePort -------------------------------------------------------------------------------- /src/API/Tournaments/Input.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.API.Tournaments; 2 | 3 | public record AddTeamModel(Guid TeamId); 4 | 5 | public record CreateTournamentModel(string Name); 6 | 7 | public record SetMatchResultModel( 8 | Guid LocalTeamId, 9 | int LocalGoals, 10 | Guid AwayTeamId, 11 | int AwayGoals); -------------------------------------------------------------------------------- /src/Projections/Teams/TeamProjection.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | 3 | namespace Tournament.Projections.Teams; 4 | 5 | public record Tournament(Guid Id, string Name); 6 | 7 | public record TeamProjection( 8 | Guid Id, 9 | string Name, 10 | IImmutableList Players, 11 | IImmutableList Tournaments); -------------------------------------------------------------------------------- /src/Domain/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain; 2 | 3 | public class Constants 4 | { 5 | public const string InMemoryStream = "InMemoryStream"; 6 | public const string WebSocketNamespace = "WebSocketNamespace"; 7 | public const string TeamNamespace = "TeamNamespace"; 8 | public const string TournamentNamespace = "TournamentNamespace"; 9 | } -------------------------------------------------------------------------------- /postgres/04_read_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA read; 2 | 3 | CREATE TABLE read.team_projection ( 4 | id UUID NOT NULL, 5 | payload JSONB NOT NULL, 6 | CONSTRAINT team_projection_pk PRIMARY KEY (id) 7 | ); 8 | 9 | CREATE TABLE read.tournament_projection ( 10 | id UUID NOT NULL, 11 | payload JSONB NOT NULL, 12 | CONSTRAINT tournament_projection_pk PRIMARY KEY (id) 13 | ); -------------------------------------------------------------------------------- /src/Projections/Tournaments/TournamentProjection.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using Tournament.Domain.Tournaments; 3 | 4 | namespace Tournament.Projections.Tournaments; 5 | 6 | public record Team(Guid Id, string Name); 7 | 8 | public record TournamentProjection( 9 | Guid Id, 10 | string Name, 11 | IImmutableList Teams, 12 | Fixture Fixture); -------------------------------------------------------------------------------- /kubernetes/secrets.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: jwt-issuer-key 5 | data: 6 | value: bVVMLU02TjVdNDtTOVhIcA== 7 | --- 8 | apiVersion: v1 9 | kind: Secret 10 | metadata: 11 | name: postgres-connection 12 | data: 13 | value: U2VydmVyPXBvc3RncmVzLXNlcnZpY2U7UG9ydD01NDMyO1VzZXIgSWQ9ZGJ1c2VyO1Bhc3N3b3JkPWRicGFzc3dvcmQ7RGF0YWJhc2U9b3JsZWFucy10b3VybmFtZW50 14 | -------------------------------------------------------------------------------- /src/Domain/Teams/Rules.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Teams; 2 | 3 | public partial class TeamState 4 | { 5 | public Results TeamExists() 6 | => Created 7 | ? Results.Unit 8 | : Results.TeamDoesNotExist; 9 | 10 | public Results TeamDoesNotExists() 11 | => !Created 12 | ? Results.Unit 13 | : Results.TeamAlreadyExist; 14 | } -------------------------------------------------------------------------------- /src/API.Identity/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine3.14 AS build 2 | 3 | COPY src/API.Identity/API.Identity.csproj src/API.Identity/API.Identity.csproj 4 | 5 | RUN dotnet restore src/API.Identity 6 | 7 | COPY src/API.Identity/ src/API.Identity/ 8 | 9 | RUN dotnet publish src/API.Identity -o app -c Release 10 | 11 | FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine3.14 12 | WORKDIR /app 13 | COPY --from=build app . 14 | ENTRYPOINT ["dotnet", "Tournament.API.Identity.dll"] -------------------------------------------------------------------------------- /postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.0-alpine 2 | COPY /01_orleans_main.sql /docker-entrypoint-initdb.d/01.sql 3 | COPY /02_orleans_clustering.sql /docker-entrypoint-initdb.d/02.sql 4 | COPY /03_orleans_persistence.sql /docker-entrypoint-initdb.d/03.sql 5 | COPY /04_read_schema.sql /docker-entrypoint-initdb.d/04.sql 6 | COPY /05_auth_schema.sql /docker-entrypoint-initdb.d/05.sql 7 | 8 | ENV POSTGRES_PASSWORD="dbpassword" 9 | ENV POSTGRES_USER="dbuser" 10 | ENV POSTGRES_DB="orleans-tournament" 11 | -------------------------------------------------------------------------------- /utils/WebSockets.Client/WebSockets.Client.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | Tournament.Utils.WebSockets.Client 7 | Tournament.Utils.WebSockets.Client 8 | latest 9 | enable 10 | enable 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /kind-cluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kind.x-k8s.io/v1alpha4 2 | kind: Cluster 3 | nodes: 4 | - role: control-plane 5 | extraPortMappings: 6 | - containerPort: 30700 7 | hostPort: 30700 8 | protocol: TCP 9 | - containerPort: 30701 10 | hostPort: 30701 11 | protocol: TCP 12 | - containerPort: 30702 13 | hostPort: 30702 14 | protocol: TCP 15 | - containerPort: 30703 16 | hostPort: 30703 17 | protocol: TCP 18 | - containerPort: 30704 19 | hostPort: 30704 20 | protocol: TCP -------------------------------------------------------------------------------- /src/Silo.Dashboard/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine3.14 AS build 2 | 3 | COPY src/Silo.Dashboard/Silo.Dashboard.csproj src/Silo.Dashboard/Silo.Dashboard.csproj 4 | 5 | RUN dotnet restore src/Silo.Dashboard 6 | 7 | COPY src/Silo.Dashboard/ src/Silo.Dashboard/ 8 | 9 | RUN dotnet publish src/Silo.Dashboard -o /src/Silo.Dashboard/out -c Release 10 | 11 | FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine3.14 12 | COPY --from=build /src/Silo.Dashboard/out /app 13 | WORKDIR /app 14 | ENTRYPOINT ["dotnet", "Tournament.Silo.Dashboard.dll"] -------------------------------------------------------------------------------- /src/Domain/Teams/Commands.cs: -------------------------------------------------------------------------------- 1 | using Tournament.Domain.Abstractions; 2 | 3 | namespace Tournament.Domain.Teams; 4 | 5 | public record CreateTeam( 6 | string Name, 7 | Guid TeamId, 8 | Guid TraceId, 9 | Guid InvokerUserId) : ITraceable; 10 | 11 | public record AddPlayer( 12 | string Name, 13 | Guid TeamId, 14 | Guid TraceId, 15 | Guid InvokerUserId) : ITraceable; 16 | 17 | public record JoinTournament( 18 | Guid TeamId, 19 | Guid TournamentId, 20 | Guid TraceId, 21 | Guid InvokerUserId) : ITraceable; -------------------------------------------------------------------------------- /src/Domain/Teams/Events.cs: -------------------------------------------------------------------------------- 1 | using Tournament.Domain.Abstractions; 2 | 3 | namespace Tournament.Domain.Teams; 4 | 5 | public record TeamCreated( 6 | string Name, 7 | Guid TeamId, 8 | Guid TraceId, 9 | Guid InvokerUserId) : ITraceable; 10 | 11 | public record PlayerAdded( 12 | string Name, 13 | Guid TeamId, 14 | Guid TraceId, 15 | Guid InvokerUserId) : ITraceable; 16 | 17 | public record TournamentJoined( 18 | Guid TeamId, 19 | Guid TournamentId, 20 | Guid TraceId, 21 | Guid InvokerUserId) : ITraceable; -------------------------------------------------------------------------------- /src/Domain/Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.Domain 6 | Tournament.Domain 7 | enable 8 | latest 9 | enable 10 | enable 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /kubernetes/api-service.yaml: -------------------------------------------------------------------------------- 1 | kind: Service 2 | apiVersion: v1 3 | metadata: 4 | name: api-service 5 | spec: 6 | selector: 7 | app: api 8 | tier: backend 9 | role: client 10 | ports: 11 | - name: http 12 | port: 80 13 | targetPort: 80 #internal port 14 | nodePort: 30703 #external port 15 | type: NodePort 16 | --- 17 | kind: Service 18 | apiVersion: v1 19 | metadata: 20 | name: api-identity-service 21 | spec: 22 | selector: 23 | app: api 24 | tier: backend 25 | role: identity 26 | ports: 27 | - name: http 28 | port: 80 29 | targetPort: 80 #internal port 30 | nodePort: 30704 #external port 31 | type: NodePort -------------------------------------------------------------------------------- /src/Domain/Tournaments/Commands.cs: -------------------------------------------------------------------------------- 1 | using Tournament.Domain.Abstractions; 2 | 3 | namespace Tournament.Domain.Tournaments; 4 | 5 | public record CreateTournament( 6 | string Name, 7 | Guid TournamentId, 8 | Guid TraceId, 9 | Guid InvokerUserId) : ITraceable; 10 | 11 | public record AddTeam( 12 | Guid TournamentId, 13 | Guid TeamId, 14 | Guid TraceId, 15 | Guid InvokerUserId) : ITraceable; 16 | 17 | public record StartTournament( 18 | Guid TournamentId, 19 | Guid TraceId, 20 | Guid InvokerUserId) : ITraceable; 21 | 22 | public record SetMatchResult( 23 | Guid TournamentId, 24 | Match Match, 25 | Guid TraceId, 26 | Guid InvokerUserId) : ITraceable; -------------------------------------------------------------------------------- /src/API/Teams/Output.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using Tournament.Projections.Teams; 3 | 4 | namespace Tournament.API.Teams; 5 | 6 | public record Tournament( 7 | Guid Id, 8 | string Name); 9 | 10 | public record TeamResponse( 11 | Guid Id, 12 | string Name, 13 | IImmutableList Players, 14 | IImmutableList Tournaments) 15 | { 16 | public static TeamResponse From(TeamProjection projection) 17 | => new(projection.Id, projection.Name, projection.Players, projection.Tournaments.Select(e => new Tournament(e.Id, e.Name)).ToImmutableList()); 18 | 19 | public static IReadOnlyList From(IReadOnlyList projection) 20 | => projection.Select(From).ToList(); 21 | } -------------------------------------------------------------------------------- /src/Domain/Tournaments/Events.cs: -------------------------------------------------------------------------------- 1 | using Tournament.Domain.Abstractions; 2 | 3 | namespace Tournament.Domain.Tournaments; 4 | 5 | public record TournamentCreated( 6 | string Name, 7 | Guid TournamentId, 8 | Guid TraceId, 9 | Guid InvokerUserId) : ITraceable; 10 | 11 | public record TeamAdded( 12 | Guid TeamId, 13 | Guid TournamentId, 14 | Guid TraceId, 15 | Guid InvokerUserId) : ITraceable; 16 | 17 | public record TournamentStarted( 18 | Guid TournamentId, 19 | List Teams, 20 | int Seed, 21 | Guid TraceId, 22 | Guid InvokerUserId) : ITraceable; 23 | 24 | public record MatchResultSet( 25 | Guid TournamentId, 26 | Match Match, 27 | Guid TraceId, 28 | Guid InvokerUserId) : ITraceable; -------------------------------------------------------------------------------- /src/WebSockets/WebSockets.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.WebSockets 6 | Tournament.WebSockets 7 | latest 8 | enable 9 | enable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/API/Tournaments/Output.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using Tournament.Domain.Tournaments; 3 | using Tournament.Projections.Tournaments; 4 | 5 | namespace Tournament.API.Tournaments; 6 | 7 | public record Team( 8 | Guid Id, 9 | string Name); 10 | 11 | public record TournamentResponse( 12 | Guid Id, 13 | string Name, 14 | IImmutableList Teams, 15 | Fixture Fixture) 16 | { 17 | public static TournamentResponse From(TournamentProjection projection) 18 | => new(projection.Id, projection.Name, projection.Teams.Select(e => new Team(e.Id, e.Name)).ToImmutableList(), projection.Fixture); 19 | public static IReadOnlyList From(IReadOnlyList projection) 20 | => projection.Select(From).ToList(); 21 | } -------------------------------------------------------------------------------- /src/Domain.Abstractions/Domain.Abstractions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.Domain.Abstractions 6 | Tournament.Domain.Abstractions 7 | latest 8 | enable 9 | enable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/API.Identity/API.Identity.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.API.Identity 6 | Tournament.API.Identity 7 | latest 8 | enable 9 | enable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /kubernetes/postgres-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: postgres-deployment 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: postgres 10 | tier: infrastructure 11 | role: database 12 | strategy: 13 | rollingUpdate: 14 | maxSurge: 1 15 | maxUnavailable: 1 16 | type: RollingUpdate 17 | template: 18 | metadata: 19 | labels: 20 | app: postgres 21 | tier: infrastructure 22 | role: database 23 | spec: 24 | containers: 25 | - name: ot-postgres 26 | image: ot-postgres:local 27 | imagePullPolicy: IfNotPresent 28 | ports: 29 | - name: postgresql 30 | containerPort: 5432 31 | restartPolicy: Always 32 | terminationGracePeriodSeconds: 60 33 | -------------------------------------------------------------------------------- /src/Projections/Teams/TeamQueryHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Projections.Teams; 2 | 3 | public interface ITeamQueryHandler 4 | { 5 | Task GetTeamAsync(Guid id); 6 | 7 | Task> GetTeamsAsync(); 8 | } 9 | 10 | public class TeamQueryHandler : ITeamQueryHandler 11 | { 12 | private readonly ProjectionManager _projectionManager; 13 | 14 | public TeamQueryHandler(PostgresOptions postgresOptions) 15 | { 16 | _projectionManager = new ProjectionManager("read", "team_projection", postgresOptions); 17 | } 18 | 19 | public Task GetTeamAsync(Guid id) 20 | => _projectionManager.GetProjectionAsync(id); 21 | 22 | public Task> GetTeamsAsync() 23 | => _projectionManager.GetProjectionsAsync(); 24 | } -------------------------------------------------------------------------------- /kubernetes/silo-service.yaml: -------------------------------------------------------------------------------- 1 | kind: Service 2 | apiVersion: v1 3 | metadata: 4 | name: silo-service 5 | spec: 6 | selector: 7 | app: silo 8 | tier: backend 9 | role: host 10 | ports: 11 | - name: http 12 | port: 80 13 | targetPort: 80 #internal port 14 | nodePort: 30701 #external port 15 | - name: silo 16 | port: 30710 17 | targetPort: 30710 #internal port 18 | nodePort: 30710 #external port 19 | - name: gateway 20 | port: 30711 21 | targetPort: 30711 #internal port 22 | nodePort: 30711 #external port 23 | type: NodePort 24 | --- 25 | kind: Service 26 | apiVersion: v1 27 | metadata: 28 | name: silo-dashboard-service 29 | spec: 30 | selector: 31 | app: silo 32 | tier: backend 33 | role: dashboard 34 | ports: 35 | - name: http 36 | port: 80 37 | targetPort: 80 #internal port 38 | nodePort: 30702 #external port 39 | type: NodePort 40 | -------------------------------------------------------------------------------- /src/API/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine3.14 AS build 2 | 3 | COPY src/API/API.csproj src/API/API.csproj 4 | COPY src/Domain/Domain.csproj src/Domain/Domain.csproj 5 | COPY src/Projections/Projections.csproj src/Projections/Projections.csproj 6 | COPY src/Domain.Abstractions/Domain.Abstractions.csproj src/Domain.Abstractions/Domain.Abstractions.csproj 7 | COPY src/WebSockets/WebSockets.csproj src/WebSockets/WebSockets.csproj 8 | 9 | RUN dotnet restore src/API 10 | 11 | COPY src/API/ src/API/ 12 | COPY src/Domain/ src/Domain/ 13 | COPY src/Projections/ src/Projections/ 14 | COPY src/Domain.Abstractions/ src/Domain.Abstractions/ 15 | COPY src/WebSockets/ src/WebSockets/ 16 | 17 | RUN dotnet publish src/API -o src/API/out -c Release 18 | 19 | FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine3.14 20 | COPY --from=build /src/API/out /app 21 | WORKDIR /app 22 | ENTRYPOINT ["dotnet", "Tournament.API.dll"] -------------------------------------------------------------------------------- /src/Projections/Projections.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.Projections 6 | Tournament.Projections 7 | latest 8 | enable 9 | enable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Silo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine3.14 AS build 2 | 3 | COPY src/Silo/Silo.csproj src/Silo/Silo.csproj 4 | COPY src/Domain/Domain.csproj src/Domain/Domain.csproj 5 | COPY src/Projections/Projections.csproj src/Projections/Projections.csproj 6 | COPY src/Domain.Abstractions/Domain.Abstractions.csproj src/Domain.Abstractions/Domain.Abstractions.csproj 7 | COPY src/WebSockets/WebSockets.csproj src/WebSockets/WebSockets.csproj 8 | 9 | RUN dotnet restore src/Silo 10 | 11 | COPY src/Silo/ src/Silo/ 12 | COPY src/Domain/ src/Domain/ 13 | COPY src/Projections/ src/Projections/ 14 | COPY src/Domain.Abstractions/ src/Domain.Abstractions/ 15 | COPY src/WebSockets/ src/WebSockets/ 16 | 17 | RUN dotnet publish src/Silo -o /src/Silo/out -c Release 18 | 19 | FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine3.14 20 | COPY --from=build /src/Silo/out /app 21 | WORKDIR /app 22 | ENTRYPOINT ["dotnet", "Tournament.Silo.dll"] -------------------------------------------------------------------------------- /src/Domain/Results.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain; 2 | 3 | public enum Results 4 | { 5 | Unit = 0, 6 | TeamDoesNotExist = 1, 7 | TeamAlreadyExist = 2, 8 | TournamentDoesNotExist = 3, 9 | TournamentAlreadyExist = 4, 10 | TeamIsAlreadyAdded = 5, 11 | TournamentHasMoreThanEightTeams = 6, 12 | TournamentCantStartWithLessThanEightTeams = 7, 13 | TournamentDidNotStart = 8, 14 | MatchDoesNotExist = 9, 15 | MatchAlreadyPlayed = 10, 16 | DrawResultIsNotAllowed = 11, 17 | NotAllMatchesPlayed = 12, 18 | TournamentAlreadyOnFinals = 13, 19 | TournamentAlreadyStarted = 14 20 | } 21 | 22 | public class ResultsUtil 23 | { 24 | public static Results Eval(params Results[] list) 25 | { 26 | foreach (var result in list) 27 | if (result != Results.Unit) 28 | return result; 29 | 30 | return Results.Unit; 31 | } 32 | } -------------------------------------------------------------------------------- /src/Projections/Tournaments/TournamentQueryHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Projections.Tournaments; 2 | 3 | public interface ITournamentQueryHandler 4 | { 5 | Task GetTournamentAsync(Guid id); 6 | 7 | Task> GetTournamentsAsync(); 8 | } 9 | 10 | public class TournamentQueryHandler : ITournamentQueryHandler 11 | { 12 | private readonly ProjectionManager _projectionManager; 13 | 14 | public TournamentQueryHandler(PostgresOptions postgresOptions) 15 | { 16 | _projectionManager = new ProjectionManager("read", "tournament_projection", postgresOptions); 17 | } 18 | 19 | public Task GetTournamentAsync(Guid id) 20 | => _projectionManager.GetProjectionAsync(id); 21 | 22 | public Task> GetTournamentsAsync() 23 | => _projectionManager.GetProjectionsAsync(); 24 | } -------------------------------------------------------------------------------- /src/Domain/Tournaments/State.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Tournaments; 2 | 3 | public partial class TournamentState 4 | { 5 | public Guid Id { get; set; } 6 | public bool Created { get; set; } 7 | public string Name { get; set; } 8 | public List Teams { get; set; } 9 | public Fixture Fixture { get; set; } 10 | 11 | public TournamentState() 12 | { 13 | Name = string.Empty; 14 | Fixture = Fixture.Empty; 15 | Teams = Enumerable.Empty().ToList(); 16 | } 17 | 18 | public void Apply(TournamentCreated evt) 19 | { 20 | Id = evt.TournamentId; 21 | Created = true; 22 | } 23 | 24 | public void Apply(TeamAdded evt) 25 | => Teams.Add(evt.TeamId); 26 | 27 | public void Apply(TournamentStarted evt) 28 | => Fixture = Fixture.Create(Teams, evt.Seed); 29 | 30 | public void Apply(MatchResultSet evt) 31 | => Fixture = Fixture!.SetMatchResult(evt.Match); 32 | } 33 | -------------------------------------------------------------------------------- /src/WebSockets/TeamSubscriber.cs: -------------------------------------------------------------------------------- 1 | using Orleans; 2 | using Orleans.Streams; 3 | using Tournament.Domain; 4 | using Tournament.Domain.Abstractions; 5 | using Tournament.Domain.Abstractions.Grains; 6 | 7 | namespace Tournament.WebSockets; 8 | 9 | // Subscribes to the InMemoryStream for the TeamNamespace 10 | // Each event will be then published back on the stream but on the WebSocketNamespace 11 | [ImplicitStreamSubscription(Constants.TeamNamespace)] 12 | public class TeamSubscriber : SubscriberGrain 13 | { 14 | public TeamSubscriber() 15 | : base(new StreamConfig(Constants.InMemoryStream, Constants.TeamNamespace)) 16 | { 17 | } 18 | 19 | public override async Task HandleAsync(object evt, StreamSequenceToken token) 20 | { 21 | if (evt is ITraceable obj) 22 | await StreamProvider! 23 | .GetStream(obj.InvokerUserId, Constants.WebSocketNamespace) 24 | .OnNextAsync(new WebSocketMessage(evt.GetType().Name, evt)); 25 | 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Domain.Tests/Domain.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.Domain.Tests 6 | Tournament.Domain.Tests 7 | enable 8 | latest 9 | enable 10 | enable 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Silo.Dashboard/Silo.Dashboard.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.Silo.Dashboard 6 | Tournament.Silo.Dashboard 7 | latest 8 | enable 9 | enable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/WebSockets/TournamentSubscriber.cs: -------------------------------------------------------------------------------- 1 | using Orleans; 2 | using Orleans.Streams; 3 | using Tournament.Domain; 4 | using Tournament.Domain.Abstractions; 5 | using Tournament.Domain.Abstractions.Grains; 6 | 7 | namespace Tournament.WebSockets; 8 | 9 | // Subscribes to the InMemoryStream for the TournamentNamespace 10 | // Each event will be then published back on the stream but on the WebSocketNamespace 11 | [ImplicitStreamSubscription(Constants.TournamentNamespace)] 12 | public class TournamentSubscriber : SubscriberGrain 13 | { 14 | public TournamentSubscriber() 15 | : base(new StreamConfig(Constants.InMemoryStream, Constants.TournamentNamespace)) 16 | { 17 | } 18 | 19 | public override async Task HandleAsync(object evt, StreamSequenceToken token) 20 | { 21 | if (evt is ITraceable obj) 22 | await StreamProvider! 23 | .GetStream(obj.InvokerUserId, Constants.WebSocketNamespace) 24 | .OnNextAsync(new WebSocketMessage(evt.GetType().Name, evt)); 25 | 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pablo Morelli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /kubernetes/config-maps.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: api-config 5 | data: 6 | ASPNETCORE_ENVIRONMENT: Development 7 | ASPNETCORE_URLS: http://*:80 8 | CLUSTER_ID: Local 9 | SERVICE_ID: Orleans-Tournament 10 | BUILD_VERSION: 0.0.1 11 | JWT_ISSUER: orleans.tournament.com 12 | JWT_AUDIENCE: orleans.tournament.com 13 | --- 14 | apiVersion: v1 15 | kind: ConfigMap 16 | metadata: 17 | name: api-identity-config 18 | data: 19 | ASPNETCORE_ENVIRONMENT: Development 20 | ASPNETCORE_URLS: http://*:80 21 | BUILD_VERSION: 0.0.1 22 | JWT_ISSUER: orleans.tournament.com 23 | JWT_AUDIENCE: orleans.tournament.com 24 | --- 25 | apiVersion: v1 26 | kind: ConfigMap 27 | metadata: 28 | name: silo-config 29 | data: 30 | ASPNETCORE_ENVIRONMENT: Development 31 | ASPNETCORE_URLS: http://*:80 32 | CLUSTER_ID: Local 33 | SERVICE_ID: Orleans-Tournament 34 | SILO_PORT: "30711" 35 | GATEWAY_PORT: "30710" 36 | BUILD_VERSION: 0.0.1 37 | --- 38 | apiVersion: v1 39 | kind: ConfigMap 40 | metadata: 41 | name: silo-dashboard-config 42 | data: 43 | ASPNETCORE_ENVIRONMENT: Development 44 | ASPNETCORE_URLS: http://*:80 45 | CLUSTER_ID: Local 46 | SERVICE_ID: Orleans-Tournament 47 | BUILD_VERSION: 0.0.1 48 | -------------------------------------------------------------------------------- /src/Domain/Teams/State.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Teams; 2 | 3 | public partial class TeamState 4 | { 5 | public Guid Id { get; set; } 6 | public bool Created { get; set; } 7 | public string Name { get; set; } 8 | public List Players { get; set; } 9 | public List Tournaments { get; set; } 10 | 11 | public TeamState() 12 | { 13 | Name = string.Empty; 14 | Players = Enumerable.Empty().ToList(); 15 | Tournaments = Enumerable.Empty().ToList(); 16 | } 17 | 18 | public TeamState( 19 | Guid id, 20 | bool created, 21 | string name, 22 | List players, 23 | List tournaments) 24 | { 25 | Id = id; 26 | Created = created; 27 | Name = name; 28 | Players = players; 29 | Tournaments = tournaments; 30 | } 31 | 32 | public void Apply(TeamCreated evt) 33 | { 34 | Id = evt.TeamId; 35 | Name = evt.Name; 36 | Created = true; 37 | } 38 | 39 | public void Apply(PlayerAdded evt) 40 | => Players.Add(evt.Name); 41 | 42 | public void Apply(TournamentJoined evt) 43 | => Tournaments.Add(evt.TournamentId); 44 | } -------------------------------------------------------------------------------- /src/API/API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.API 6 | Tournament.API 7 | latest 8 | enable 9 | enable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | docker/build: 2 | docker build -t ot-postgres:local -f postgres/Dockerfile postgres/ 3 | docker build -t ot-api:local -f src/API/Dockerfile . 4 | docker build -t ot-api-identity:local -f src/API.Identity/Dockerfile . 5 | docker build -t ot-silo:local -f src/Silo/Dockerfile . 6 | docker build -t ot-silo-dashboard:local -f src/Silo.Dashboard/Dockerfile . 7 | 8 | # Second and third line are to enable metrics server 9 | cluster/init: 10 | kind create cluster --config=./kind-cluster.yaml 11 | kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.5.0/components.yaml 12 | kubectl patch -n kube-system deployment metrics-server --type=json -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' 13 | 14 | cluster/images: 15 | -kind load docker-image \ 16 | ot-api:local \ 17 | ot-api-identity:local \ 18 | ot-silo:local \ 19 | ot-silo-dashboard:local \ 20 | ot-postgres:local 21 | 22 | cluster/apply: 23 | kubectl apply -f kubernetes 24 | 25 | cluster/teardown: 26 | kubectl delete secret --all 27 | kubectl delete deployment --all 28 | 29 | run: cluster/init docker/build cluster/images cluster/apply 30 | 31 | restart: docker/build cluster/images cluster/teardown cluster/apply 32 | -------------------------------------------------------------------------------- /src/Domain.Abstractions/Grains/EventSourcedGrain.cs: -------------------------------------------------------------------------------- 1 | using Orleans; 2 | using Orleans.EventSourcing; 3 | using Orleans.Streams; 4 | 5 | namespace Tournament.Domain.Abstractions.Grains; 6 | 7 | public abstract class EventSourcedGrain : JournaledGrain 8 | where TState : class, new() 9 | { 10 | private readonly string _type; 11 | private readonly string _namespace; 12 | protected IStreamProvider? StreamProvider; 13 | 14 | protected EventSourcedGrain(StreamConfig streamConfig) 15 | { 16 | (_type, _namespace) = streamConfig; 17 | } 18 | 19 | public override Task OnActivateAsync() 20 | { 21 | // StreamProvider cannot be obtained outside the Orleans lifecycle methods 22 | StreamProvider = GetStreamProvider(_type); 23 | 24 | return base.OnActivateAsync(); 25 | } 26 | 27 | protected async Task PersistPublishAsync(object evt) 28 | { 29 | RaiseEvent(evt); 30 | 31 | await StreamProvider! 32 | .GetStream(this.GetPrimaryKey(), _namespace) 33 | .OnNextAsync(evt); 34 | } 35 | 36 | protected async Task PublishErrorAsync(ErrorHasOccurred evt) 37 | { 38 | await StreamProvider! 39 | .GetStream(this.GetPrimaryKey(), _namespace) 40 | .OnNextAsync(evt); 41 | } 42 | } -------------------------------------------------------------------------------- /src/Domain/Sagas/TeamAddedSaga.cs: -------------------------------------------------------------------------------- 1 | using Orleans; 2 | using Orleans.Streams; 3 | using Tournament.Domain.Abstractions; 4 | using Tournament.Domain.Abstractions.Grains; 5 | using Tournament.Domain.Teams; 6 | using Tournament.Domain.Tournaments; 7 | 8 | namespace Tournament.Domain.Sagas; 9 | 10 | // Subscribes to the InMemoryStream for the TournamentNamespace 11 | // For TeamAdded event, get the Team grain and publish the JoinTournament command 12 | [ImplicitStreamSubscription(Constants.TournamentNamespace)] 13 | public class TeamAddedSaga : SubscriberGrain 14 | { 15 | public TeamAddedSaga() 16 | : base(new StreamConfig(Constants.InMemoryStream, Constants.TournamentNamespace)) 17 | { 18 | } 19 | 20 | public override async Task HandleAsync(object evt, StreamSequenceToken token) 21 | { 22 | if (evt is not TeamAdded obj) 23 | return true; 24 | 25 | // We already know that the Team exists, as it was validated 26 | // on the AddTeam command and before publishing the TeamAdded event 27 | var teamGrain = GrainFactory.GetGrain(obj.TeamId); 28 | 29 | var joinTournamentCmd = new JoinTournament(obj.TeamId, obj.TournamentId, obj.TraceId, obj.InvokerUserId); 30 | await teamGrain.JoinTournamentAsync(joinTournamentCmd); 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Domain.Abstractions/Grains/SubscriberGrain.cs: -------------------------------------------------------------------------------- 1 | using Orleans; 2 | using Orleans.Streams; 3 | 4 | namespace Tournament.Domain.Abstractions.Grains; 5 | 6 | // This interface is needed for Orleans' Grain activation 7 | public interface ISubscriber : IGrainWithGuidKey 8 | { 9 | } 10 | 11 | public abstract class SubscriberGrain : Grain, ISubscriber 12 | { 13 | private readonly string _type; 14 | private readonly string _namespace; 15 | private StreamSubscriptionHandle? _sub; 16 | 17 | protected IStreamProvider? StreamProvider; 18 | 19 | protected SubscriberGrain(StreamConfig streamConfig) 20 | { 21 | (_type, _namespace) = streamConfig; 22 | } 23 | 24 | public override async Task OnActivateAsync() 25 | { 26 | // StreamProvider cannot be obtained outside the Orleans lifecycle methods 27 | StreamProvider = GetStreamProvider(_type); 28 | 29 | _sub = await StreamProvider 30 | .GetStream(this.GetPrimaryKey(), _namespace) 31 | .SubscribeAsync(HandleAsync); 32 | 33 | await base.OnActivateAsync(); 34 | } 35 | 36 | public override async Task OnDeactivateAsync() 37 | { 38 | await _sub!.UnsubscribeAsync(); 39 | await base.OnDeactivateAsync(); 40 | } 41 | 42 | public abstract Task HandleAsync(object evt, StreamSequenceToken token); 43 | } -------------------------------------------------------------------------------- /src/Silo/Silo.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | Tournament.Silo 6 | Tournament.Silo 7 | latest 8 | enable 9 | enable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/Domain.Tests/FixtureTest.cs: -------------------------------------------------------------------------------- 1 | using Tournament.Domain.Tournaments; 2 | using Xunit; 3 | 4 | namespace Tournament.Domain.Tests; 5 | 6 | public class FixtureTests 7 | { 8 | [Fact] 9 | public void SetMatchResult_Should_Preserve_Index_Position() 10 | { 11 | var teams = new List() { 12 | new Guid("10000000-0000-0000-0000-000000000000"), 13 | new Guid("20000000-0000-0000-0000-000000000000"), 14 | new Guid("30000000-0000-0000-0000-000000000000"), 15 | new Guid("40000000-0000-0000-0000-000000000000"), 16 | new Guid("50000000-0000-0000-0000-000000000000"), 17 | new Guid("60000000-0000-0000-0000-000000000000"), 18 | new Guid("70000000-0000-0000-0000-000000000000"), 19 | new Guid("80000000-0000-0000-0000-000000000000"), 20 | }; 21 | 22 | var sut = Fixture.Create(teams, 1); 23 | 24 | var expected = sut.Quarter.Matches[0]; 25 | var expectedLocalGoals = 2; 26 | var expectedAwayGoals = 0; 27 | 28 | var setMatch = new Match( 29 | expected.LocalTeamId, 30 | expected.AwayTeamId, 31 | new MatchResult(expectedLocalGoals, expectedAwayGoals, true)); 32 | 33 | sut = sut.SetMatchResult(setMatch); 34 | 35 | // The index should be the same 36 | var actual = sut.Quarter.Matches[0]; 37 | 38 | Assert.Equal(actual.LocalTeamId, expected.LocalTeamId); 39 | Assert.Equal(actual.AwayTeamId, expected.AwayTeamId); 40 | Assert.NotNull(actual.MatchResult); 41 | Assert.Equal(actual.MatchResult!.LocalGoals, expectedLocalGoals); 42 | Assert.Equal(actual.MatchResult.AwayGoals, expectedAwayGoals); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Domain/Teams/Grain.cs: -------------------------------------------------------------------------------- 1 | using Orleans; 2 | using Tournament.Domain.Abstractions; 3 | using Tournament.Domain.Abstractions.Grains; 4 | 5 | namespace Tournament.Domain.Teams; 6 | 7 | public interface ITeamGrain : IGrainWithGuidKey 8 | { 9 | // Commands 10 | Task CreateAsync(CreateTeam cmd); 11 | 12 | Task AddPlayerAsync(AddPlayer cmd); 13 | 14 | Task JoinTournamentAsync(JoinTournament cmd); 15 | 16 | // Queries 17 | Task TeamExistAsync(); 18 | } 19 | 20 | public class TeamGrain : EventSourcedGrain, ITeamGrain 21 | { 22 | public TeamGrain() 23 | : base(new StreamConfig(Constants.InMemoryStream, Constants.TeamNamespace)) 24 | { } 25 | 26 | public async Task CreateAsync(CreateTeam cmd) 27 | { 28 | var task = State.TeamDoesNotExists() switch 29 | { 30 | Results.Unit => PersistPublishAsync(new TeamCreated(cmd.Name, cmd.TeamId, cmd.TraceId, cmd.InvokerUserId)), 31 | Results x => PublishErrorAsync(new ErrorHasOccurred((int)x, x.ToString(), cmd.TraceId, cmd.InvokerUserId)) 32 | }; 33 | 34 | await task; 35 | } 36 | 37 | public async Task AddPlayerAsync(AddPlayer cmd) 38 | { 39 | var task = State.TeamExists() switch 40 | { 41 | Results.Unit => PersistPublishAsync(new PlayerAdded(cmd.Name, cmd.TeamId, cmd.TraceId, cmd.InvokerUserId)), 42 | Results x => PublishErrorAsync(new ErrorHasOccurred((int)x, x.ToString(), cmd.TraceId, cmd.InvokerUserId)) 43 | }; 44 | 45 | await task; 46 | } 47 | 48 | // Saga command, already validated in the saga 49 | public async Task JoinTournamentAsync(JoinTournament cmd) 50 | => await PersistPublishAsync(new TournamentJoined(cmd.TeamId, cmd.TournamentId, cmd.TraceId, cmd.InvokerUserId)); 51 | 52 | public Task TeamExistAsync() 53 | => Task.FromResult(State.Created); 54 | } -------------------------------------------------------------------------------- /src/Silo.Dashboard/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Orleans; 3 | using Orleans.Configuration; 4 | using Orleans.Hosting; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | // Parse environment variables 9 | IConfiguration configuration = builder.Configuration; 10 | var clusterId = configuration["CLUSTER_ID"]; 11 | var serviceId = configuration["SERVICE_ID"]; 12 | var buildVersion = configuration["BUILD_VERSION"]; 13 | var postgresConnection = configuration["POSTGRES_CONNECTION"]; 14 | 15 | // Orleans cluster connection 16 | var clusterClient = new ClientBuilder() 17 | .Configure(options => 18 | { 19 | options.ClusterId = clusterId; 20 | options.ServiceId = serviceId; 21 | }) 22 | .UseAdoNetClustering(opt => 23 | { 24 | opt.Invariant = "Npgsql"; 25 | opt.ConnectionString = postgresConnection; 26 | }) 27 | .ConfigureLogging(e => 28 | e.AddJsonConsole(options => 29 | { 30 | options.IncludeScopes = true; 31 | options.TimestampFormat = "dd/MM/yyyy hh:mm:ss"; 32 | options.JsonWriterOptions = new JsonWriterOptions { Indented = true }; 33 | }) 34 | .AddFilter(level => level >= LogLevel.Warning) 35 | ) 36 | .ConfigureApplicationParts(parts => parts.AddFromDependencyContext().WithReferences()) 37 | .UseDashboard() 38 | .Build(); 39 | 40 | builder 41 | .Services 42 | .AddSingleton(clusterClient) 43 | .AddSingleton((IGrainFactory)clusterClient) 44 | .AddServicesForSelfHostedDashboard(null, opt => 45 | { 46 | opt.HideTrace = true; 47 | opt.Port = 80; 48 | opt.CounterUpdateIntervalMs = 5000; 49 | }); 50 | 51 | var app = builder.Build(); 52 | 53 | app.UseOrleansDashboard(); 54 | app.MapGet("/version", () => Results.Ok(new { BuildVersion = buildVersion })); 55 | app.MapGet("/leave", async () => 56 | { 57 | await clusterClient.Close(); 58 | Results.Ok(); 59 | }); 60 | 61 | // Connect to cluster 62 | await clusterClient.Connect(async e => 63 | { 64 | await Task.Delay(TimeSpan.FromSeconds(2)); 65 | return true; 66 | }); 67 | 68 | // Starting API 69 | await app.RunAsync(); 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | .vscode/ 30 | 31 | # Visual Studio 2017 auto generated files 32 | Generated\ Files/ 33 | 34 | # MSTest test Results 35 | [Tt]est[Rr]esult*/ 36 | [Bb]uild[Ll]og.* 37 | 38 | # NUNIT 39 | *.VisualState.xml 40 | TestResult.xml 41 | 42 | # Build Results of an ATL Project 43 | [Dd]ebugPS/ 44 | [Rr]eleasePS/ 45 | dlldata.c 46 | 47 | # Benchmark Results 48 | BenchmarkDotNet.Artifacts/ 49 | 50 | # .NET Core 51 | project.lock.json 52 | project.fragment.lock.json 53 | artifacts/ 54 | **/Properties/launchSettings.json 55 | 56 | # StyleCop 57 | StyleCopReport.xml 58 | 59 | # TeamCity is a build add-in 60 | _TeamCity* 61 | 62 | # DotCover is a Code Coverage Tool 63 | *.dotCover 64 | 65 | # Visual Studio code coverage results 66 | *.coverage 67 | *.coveragexml 68 | 69 | # NuGet Packages 70 | *.nupkg 71 | # The packages folder can be ignored because of Package Restore 72 | **/[Pp]ackages/* 73 | # except build/, which is used as an MSBuild target. 74 | !**/[Pp]ackages/build/ 75 | # Uncomment if necessary however generally it will be regenerated when needed 76 | #!**/[Pp]ackages/repositories.config 77 | # NuGet v3's project.json files produces more ignorable files 78 | *.nuget.props 79 | *.nuget.targets 80 | 81 | # SQL Server files 82 | *.mdf 83 | *.ldf 84 | *.ndf 85 | 86 | # GhostDoc plugin setting file 87 | *.GhostDoc.xml 88 | 89 | # Paket dependency manager 90 | .paket/paket.exe 91 | paket-files/ 92 | 93 | # FAKE - F# Make 94 | .fake/ 95 | 96 | # FSharp 97 | .ionide/ 98 | 99 | # JetBrains Rider 100 | .idea/ 101 | *.sln.iml 102 | # Mac 103 | .DS_Store 104 | # Orleans 105 | OrleansAdoNetContent/ 106 | orleans.codegen.cs 107 | # Website 108 | wwwroot/ 109 | -------------------------------------------------------------------------------- /kubernetes/silo-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: silo-deployment 5 | spec: 6 | replicas: 3 7 | selector: 8 | matchLabels: 9 | app: silo 10 | tier: backend 11 | role: host 12 | strategy: 13 | rollingUpdate: 14 | maxSurge: 1 15 | maxUnavailable: 1 16 | type: RollingUpdate 17 | template: 18 | metadata: 19 | labels: 20 | app: silo 21 | tier: backend 22 | role: host 23 | spec: 24 | containers: 25 | - name: ot-silo 26 | image: ot-silo:local 27 | imagePullPolicy: IfNotPresent 28 | ports: 29 | - name: http 30 | containerPort: 80 31 | envFrom: 32 | - configMapRef: 33 | name: silo-config 34 | env: 35 | - name: POSTGRES_CONNECTION 36 | valueFrom: 37 | secretKeyRef: 38 | name: postgres-connection 39 | key: value 40 | lifecycle: 41 | preStop: 42 | httpGet: 43 | path: "leave" 44 | port: 80 45 | restartPolicy: Always 46 | terminationGracePeriodSeconds: 60 47 | --- 48 | apiVersion: apps/v1 49 | kind: Deployment 50 | metadata: 51 | name: silo-dashboard-deployment 52 | spec: 53 | replicas: 1 54 | selector: 55 | matchLabels: 56 | app: silo 57 | tier: backend 58 | role: dashboard 59 | strategy: 60 | rollingUpdate: 61 | maxSurge: 1 62 | maxUnavailable: 1 63 | type: RollingUpdate 64 | template: 65 | metadata: 66 | labels: 67 | app: silo 68 | tier: backend 69 | role: dashboard 70 | spec: 71 | containers: 72 | - name: ot-silo-dashboard 73 | image: ot-silo-dashboard:local 74 | imagePullPolicy: IfNotPresent 75 | ports: 76 | - name: http 77 | containerPort: 80 78 | envFrom: 79 | - configMapRef: 80 | name: silo-dashboard-config 81 | env: 82 | - name: POSTGRES_CONNECTION 83 | valueFrom: 84 | secretKeyRef: 85 | name: postgres-connection 86 | key: value 87 | lifecycle: 88 | preStop: 89 | httpGet: 90 | path: "leave" 91 | port: 80 92 | restartPolicy: Always 93 | terminationGracePeriodSeconds: 60 94 | -------------------------------------------------------------------------------- /src/Domain/Tournaments/Rules.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Tournaments; 2 | 3 | public partial class TournamentState 4 | { 5 | public Results TournamentExists() 6 | => Created 7 | ? Results.Unit 8 | : Results.TournamentDoesNotExist; 9 | 10 | public Results TournamentDoesNotExists() 11 | => !Created 12 | ? Results.Unit 13 | : Results.TournamentAlreadyExist; 14 | 15 | public Results TeamIsNotAdded(Guid teamId) 16 | => !Teams.Contains(teamId) 17 | ? Results.Unit 18 | : Results.TeamIsAlreadyAdded; 19 | 20 | public Results LessThanEightTeams() 21 | => Teams.Count < 8 22 | ? Results.Unit 23 | : Results.TournamentHasMoreThanEightTeams; 24 | 25 | public Results EightTeamsToStartTournament() 26 | => Teams.Count == 8 27 | ? Results.Unit 28 | : Results.TournamentCantStartWithLessThanEightTeams; 29 | 30 | public Results TournamentStarted() 31 | => Fixture.Quarter.IsEmpty == false 32 | ? Results.Unit 33 | : Results.TournamentDidNotStart; 34 | 35 | public Results TournamentDidNotStart() 36 | => Fixture.Quarter.IsEmpty 37 | ? Results.Unit 38 | : Results.TournamentAlreadyStarted; 39 | 40 | public Results MatchExistsAndIsNotPlayed(Match match) 41 | { 42 | if (Fixture.Quarter.IsEmpty) 43 | return Results.TournamentDidNotStart; 44 | 45 | // Get current phase 46 | var currentPhase = Fixture.Quarter; 47 | 48 | if (!Fixture.Final.IsEmpty) 49 | currentPhase = Fixture.Final; 50 | else if (!Fixture.Semi.IsEmpty) 51 | currentPhase = Fixture.Semi; 52 | 53 | var currentMatch = currentPhase.Matches.SingleOrDefault(e => 54 | e.LocalTeamId == match.LocalTeamId && 55 | e.AwayTeamId == match.AwayTeamId); 56 | 57 | if (currentMatch is null) 58 | return Results.MatchDoesNotExist; 59 | 60 | if (currentMatch.MatchResult.Played) 61 | return Results.MatchAlreadyPlayed; 62 | 63 | return Results.Unit; 64 | } 65 | 66 | public Results MatchIsNotDraw(MatchResult matchResult) 67 | => matchResult.LocalGoals == matchResult.AwayGoals 68 | ? Results.DrawResultIsNotAllowed 69 | : Results.Unit; 70 | } 71 | -------------------------------------------------------------------------------- /src/Projections/ProjectionManager.cs: -------------------------------------------------------------------------------- 1 | using Dapper; 2 | using Npgsql; 3 | 4 | namespace Tournament.Projections; 5 | 6 | public class ProjectionManager 7 | { 8 | private readonly string _getQuery; 9 | private readonly string _getAllQuery; 10 | private readonly string _updateCommand; 11 | private readonly PostgresOptions _postgresOptions; 12 | 13 | public ProjectionManager( 14 | string schemaName, 15 | string tableName, 16 | PostgresOptions postgresOptions) 17 | { 18 | _postgresOptions = postgresOptions; 19 | 20 | _getQuery = $"SELECT payload FROM {schemaName}.{tableName} WHERE id = @id"; 21 | _getAllQuery = $"SELECT payload FROM {schemaName}.{tableName}"; 22 | _updateCommand = $"INSERT INTO {schemaName}.{tableName} (id, payload) VALUES (@id, @payload::jsonb) ON CONFLICT(id) DO UPDATE SET payload = excluded.payload;"; 23 | } 24 | 25 | public async Task GetProjectionAsync(Guid id) 26 | { 27 | using var connection = new NpgsqlConnection(_postgresOptions.ConnectionString); 28 | 29 | var payload = await connection.QuerySingleOrDefaultAsync(_getQuery, param: new { id }); 30 | 31 | if (string.IsNullOrEmpty(payload)) 32 | throw new Exception("the projection with the supplied ID does not exist"); 33 | 34 | var projection = System.Text.Json.JsonSerializer.Deserialize(payload); 35 | 36 | if (projection is null) 37 | throw new Exception("the projection is null, this indicates the json stored is not in sync with the projection"); 38 | 39 | return projection; 40 | } 41 | 42 | public async Task> GetProjectionsAsync() 43 | { 44 | using var connection = new NpgsqlConnection(_postgresOptions.ConnectionString); 45 | 46 | var payloads = await connection.QueryAsync(_getAllQuery); 47 | 48 | // Merge all the payloads into a json array 49 | var array = $"[{string.Join(",", payloads)}]"; 50 | 51 | return System.Text.Json.JsonSerializer.Deserialize>(array) 52 | ?? Enumerable.Empty().ToList(); 53 | } 54 | 55 | public async Task UpdateProjection(Guid id, T projection) 56 | { 57 | using var connection = new NpgsqlConnection(_postgresOptions.ConnectionString); 58 | await connection.ExecuteAsync(_updateCommand, param: new { id, payload = System.Text.Json.JsonSerializer.Serialize(projection) }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/API.Identity/UserController.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace Tournament.API.Identity; 6 | 7 | public class UserController : ControllerBase 8 | { 9 | private readonly ICreateUser _createUser; 10 | private readonly ILogger _logger; 11 | private readonly ILoginUser _loginUser; 12 | 13 | public UserController(ICreateUser createUser, ILoginUser loginUser, ILogger logger) 14 | { 15 | _createUser = createUser ?? throw new ArgumentNullException(nameof(createUser)); 16 | _loginUser = loginUser ?? throw new ArgumentNullException(nameof(loginUser)); 17 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 18 | } 19 | 20 | [AllowAnonymous] 21 | [HttpPost("api/user/create", Name = "Create user")] 22 | [ProducesResponseType(typeof(UserResponse), (int)HttpStatusCode.Created)] 23 | public async Task CreateUser([FromBody] UserRequest request) 24 | { 25 | try 26 | { 27 | var (email, password, claims) = request; 28 | var userId = await _createUser.Handle(new CreateUser(email, password, claims)); 29 | return Created($"api/user/{userId}", new UserResponse(userId)); 30 | } 31 | catch (Exception e) 32 | { 33 | _logger.LogInformation("Error creating user: {Message}", e.Message); 34 | return BadRequest(); 35 | } 36 | } 37 | 38 | [AllowAnonymous] 39 | [HttpPost("api/user/login", Name = "Login user")] 40 | [ProducesResponseType(typeof(LoginResponse), (int)HttpStatusCode.OK)] 41 | public async Task LoginUser([FromBody] LoginRequest request) 42 | { 43 | try 44 | { 45 | var (email, password) = request; 46 | var token = await _loginUser.Handle(new Login(email, password)); 47 | return Ok(new LoginResponse(token)); 48 | } 49 | catch (Exception e) 50 | { 51 | _logger.LogInformation("Error login user: {Message}", e.Message); 52 | return BadRequest(); 53 | } 54 | } 55 | } 56 | 57 | public readonly record struct UserRequest(string Email, string Password, IList Claims); 58 | 59 | public readonly record struct UserResponse(Guid UserId); 60 | 61 | public readonly record struct LoginRequest(string Email, string Password); 62 | 63 | public readonly record struct LoginResponse(string Token); -------------------------------------------------------------------------------- /src/API.Identity/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using Microsoft.AspNetCore.Authentication.JwtBearer; 4 | using Microsoft.IdentityModel.Tokens; 5 | using Tournament.API.Identity; 6 | 7 | var builder = WebApplication.CreateBuilder(args); 8 | 9 | // Parse environment variables 10 | IConfiguration configuration = builder.Configuration; 11 | var buildVersion = configuration["BUILD_VERSION"]; 12 | var postgresConnection = new ConnectionString(configuration["POSTGRES_CONNECTION"]); 13 | var jwtIssuer = configuration["JWT_ISSUER"]; 14 | var jwtAudience = configuration["JWT_AUDIENCE"]; 15 | var jwtSigningKey = Encoding.UTF8.GetBytes(configuration["JWT_SIGNING_KEY"]); 16 | 17 | var jwtConfiguration = new JwtConfiguration(jwtIssuer, jwtAudience, new SymmetricSecurityKey(jwtSigningKey)); 18 | 19 | builder 20 | .Services 21 | .AddLogging(e => 22 | e.AddJsonConsole(options => 23 | { 24 | options.IncludeScopes = true; 25 | options.TimestampFormat = "dd/MM/yyyy hh:mm:ss"; 26 | options.JsonWriterOptions = new JsonWriterOptions 27 | { 28 | Indented = true 29 | }; 30 | }) 31 | .AddFilter(level => level >= LogLevel.Information) 32 | ) 33 | .AddSingleton(jwtConfiguration) 34 | .AddSingleton(postgresConnection) 35 | .AddSingleton() 36 | .AddSingleton() 37 | .AddControllers(); 38 | 39 | builder 40 | .Services 41 | .AddAuthorization() 42 | .AddAuthentication(options => 43 | { 44 | options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; 45 | options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; 46 | options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; 47 | }) 48 | .AddJwtBearer(options => options.TokenValidationParameters = new TokenValidationParameters 49 | { 50 | ValidateIssuer = true, 51 | ValidIssuer = jwtConfiguration.Issuer, 52 | ValidateAudience = true, 53 | ValidAudience = jwtConfiguration.Audience, 54 | IssuerSigningKey = jwtConfiguration.SigningKey, 55 | ValidateIssuerSigningKey = true, 56 | ValidateLifetime = true 57 | }); 58 | 59 | var app = builder.Build(); 60 | 61 | app.UseRouting(); 62 | app.UseAuthentication(); 63 | app.UseAuthorization(); 64 | app.MapGet("/version", () => Results.Ok(new { BuildVersion = buildVersion })); 65 | app.UseEndpoints(e => e.MapControllers()); 66 | app.Run(); -------------------------------------------------------------------------------- /kubernetes/api-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: api-deployment 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: api 10 | tier: backend 11 | role: client 12 | strategy: 13 | rollingUpdate: 14 | maxSurge: 1 15 | maxUnavailable: 1 16 | type: RollingUpdate 17 | template: 18 | metadata: 19 | labels: 20 | app: api 21 | tier: backend 22 | role: client 23 | spec: 24 | containers: 25 | - name: ot-api 26 | image: ot-api:local 27 | imagePullPolicy: IfNotPresent 28 | ports: 29 | - name: http 30 | containerPort: 80 31 | envFrom: 32 | - configMapRef: 33 | name: api-config 34 | env: 35 | - name: POSTGRES_CONNECTION 36 | valueFrom: 37 | secretKeyRef: 38 | name: postgres-connection 39 | key: value 40 | - name: JWT_SIGNING_KEY 41 | valueFrom: 42 | secretKeyRef: 43 | name: jwt-issuer-key 44 | key: value 45 | lifecycle: 46 | preStop: 47 | httpGet: 48 | path: "leave" 49 | port: 80 50 | restartPolicy: Always 51 | terminationGracePeriodSeconds: 60 52 | --- 53 | apiVersion: apps/v1 54 | kind: Deployment 55 | metadata: 56 | name: api-identity-deployment 57 | spec: 58 | replicas: 1 59 | selector: 60 | matchLabels: 61 | app: api 62 | tier: backend 63 | role: identity 64 | strategy: 65 | rollingUpdate: 66 | maxSurge: 1 67 | maxUnavailable: 1 68 | type: RollingUpdate 69 | template: 70 | metadata: 71 | labels: 72 | app: api 73 | tier: backend 74 | role: identity 75 | spec: 76 | containers: 77 | - name: ot-api-identity 78 | image: ot-api-identity:local 79 | imagePullPolicy: IfNotPresent 80 | ports: 81 | - name: http 82 | containerPort: 80 83 | envFrom: 84 | - configMapRef: 85 | name: api-identity-config 86 | env: 87 | - name: POSTGRES_CONNECTION 88 | valueFrom: 89 | secretKeyRef: 90 | name: postgres-connection 91 | key: value 92 | - name: JWT_SIGNING_KEY 93 | valueFrom: 94 | secretKeyRef: 95 | name: jwt-issuer-key 96 | key: value 97 | restartPolicy: Always 98 | terminationGracePeriodSeconds: 60 99 | -------------------------------------------------------------------------------- /utils/WebSockets.Client/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Net.WebSockets; 2 | using System.Text; 3 | 4 | Console.WriteLine("Insert token: "); 5 | var accessToken = Console.ReadLine(); 6 | 7 | Console.WriteLine("Choose environment (K)ind / (L)ocalhost: "); 8 | var env = Console.ReadLine(); 9 | 10 | if (string.IsNullOrEmpty(env)) 11 | return; 12 | 13 | var uri = new Uri("ws://localhost:7003/ws"); 14 | 15 | if (env.ToUpperInvariant() == "K") 16 | uri = new Uri("ws://localhost:30703/ws"); 17 | else 18 | return; 19 | 20 | Console.WriteLine("Starting -> Press q to quit"); 21 | var client = new ClientWebSocket(); 22 | client.Options.SetRequestHeader("Authorization", $"Bearer {accessToken}"); 23 | var cts = new CancellationTokenSource(); 24 | 25 | await Task.Factory.StartNew(async () => 26 | { 27 | await client.ConnectAsync(uri, CancellationToken.None); 28 | 29 | if (client.State != WebSocketState.Open) 30 | { 31 | Console.WriteLine("Could not connect"); 32 | return; 33 | } 34 | 35 | Console.WriteLine("Connected!"); 36 | 37 | while (true) 38 | { 39 | var buffer = new byte[1024 * 4]; 40 | var segment = new ArraySegment(buffer); 41 | var result = await client.ReceiveAsync(segment, CancellationToken.None); 42 | 43 | if (result.MessageType == WebSocketMessageType.Close) 44 | break; 45 | 46 | if (result.MessageType == WebSocketMessageType.Text) 47 | { 48 | var count = result.Count; 49 | while (!result.EndOfMessage) 50 | { 51 | if (count >= buffer.Length) 52 | { 53 | await client.CloseAsync( 54 | WebSocketCloseStatus.InvalidPayloadData, 55 | "That's too long", 56 | CancellationToken.None); 57 | 58 | break; 59 | } 60 | 61 | segment = new ArraySegment(buffer, count, buffer.Length - count); 62 | result = await client.ReceiveAsync(segment, CancellationToken.None); 63 | count += result.Count; 64 | } 65 | 66 | var message = Encoding.UTF8.GetString(buffer, 0, count); 67 | Console.WriteLine(message); 68 | Console.WriteLine("\n-------\n"); 69 | } 70 | } 71 | }, 72 | cts.Token, 73 | TaskCreationOptions.LongRunning, 74 | TaskScheduler.Default); 75 | 76 | while (Console.ReadKey(true).Key != ConsoleKey.Q) 77 | { 78 | } 79 | 80 | Console.WriteLine("Ending!"); 81 | 82 | cts.Cancel(); 83 | await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", CancellationToken.None); -------------------------------------------------------------------------------- /src/API/Teams/Controller.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Security.Claims; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Orleans; 6 | using Tournament.Domain.Teams; 7 | using Tournament.Projections.Teams; 8 | 9 | namespace Tournament.API.Teams; 10 | 11 | public class Controller : ControllerBase 12 | { 13 | private readonly IClusterClient _clusterClient; 14 | private readonly ITeamQueryHandler _teamQueryHandler; 15 | 16 | public Controller( 17 | IClusterClient clusterClient, 18 | ITeamQueryHandler teamQueryHandler) 19 | { 20 | _clusterClient = clusterClient; 21 | _teamQueryHandler = teamQueryHandler; 22 | } 23 | 24 | private Guid GetUserId 25 | => new Guid(User.Claims.Single(e => e.Type == ClaimTypes.NameIdentifier).Value); 26 | 27 | [Authorize(Roles = "write")] 28 | [HttpPost("api/team/create", Name = "Create team")] 29 | [ProducesResponseType(typeof(ResourceResponse), (int)HttpStatusCode.OK)] 30 | public IActionResult CreateTeam([FromBody] CreateTeamModel model) 31 | { 32 | var teamId = Guid.NewGuid(); 33 | var traceId = Guid.NewGuid(); 34 | var team = _clusterClient.GetGrain(teamId); 35 | 36 | team.CreateAsync(new CreateTeam(model.Name, teamId, traceId, GetUserId)); 37 | 38 | return Created(teamId.ToString(), new ResourceResponse(teamId, traceId)); 39 | } 40 | 41 | [Authorize(Roles = "write")] 42 | [HttpPut("api/team/{teamId:Guid}/players", Name = "Add player to team")] 43 | [ProducesResponseType(typeof(TraceResponse), (int)HttpStatusCode.OK)] 44 | public IActionResult AddPlayers([FromRoute] Guid teamId, [FromBody] AddPlayerModel model) 45 | { 46 | var traceId = Guid.NewGuid(); 47 | var team = _clusterClient.GetGrain(teamId); 48 | 49 | model.Names 50 | .Select(e => new AddPlayer(e, teamId, traceId, GetUserId)) 51 | .ToList() 52 | .ForEach(e => team.AddPlayerAsync(e)); 53 | 54 | return Ok(new TraceResponse(traceId)); 55 | } 56 | 57 | [Authorize(Roles = "read")] 58 | [HttpGet("api/team/{teamId:Guid}", Name = "Get team")] 59 | [ProducesResponseType((int)HttpStatusCode.BadRequest)] 60 | [ProducesResponseType(typeof(TeamResponse), (int)HttpStatusCode.OK)] 61 | public async Task GetTeam([FromRoute] Guid teamId) 62 | { 63 | try 64 | { 65 | var projection = await _teamQueryHandler.GetTeamAsync(teamId); 66 | return Ok(TeamResponse.From(projection)); 67 | } 68 | catch 69 | { 70 | return NotFound(); 71 | } 72 | } 73 | 74 | [Authorize(Roles = "read")] 75 | [HttpGet("api/teams", Name = "Get teams")] 76 | [ProducesResponseType((int)HttpStatusCode.BadRequest)] 77 | [ProducesResponseType(typeof(TeamResponse[]), (int)HttpStatusCode.OK)] 78 | public async Task GetTeams() 79 | { 80 | var projection = await _teamQueryHandler.GetTeamsAsync(); 81 | return Ok(TeamResponse.From(projection)); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/API/Middlewares/WebSocketPubSubMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.WebSockets; 3 | using System.Security.Claims; 4 | using System.Text; 5 | using Microsoft.AspNetCore.Authentication; 6 | using Orleans; 7 | using Orleans.Streams; 8 | using Tournament.Domain; 9 | using Tournament.WebSockets; 10 | 11 | namespace Tournament.API.Middlewares; 12 | 13 | public class WebSocketPubSubMiddleware 14 | { 15 | private readonly IClusterClient _clusterClient; 16 | private readonly ILogger _logger; 17 | 18 | public WebSocketPubSubMiddleware( 19 | RequestDelegate _, 20 | IClusterClient clusterClient, 21 | ILogger logger) 22 | { 23 | _logger = logger; 24 | _clusterClient = clusterClient; 25 | } 26 | 27 | public async Task Invoke(HttpContext context) 28 | { 29 | if (!context.WebSockets.IsWebSocketRequest) 30 | { 31 | context.Response.StatusCode = (int)HttpStatusCode.BadRequest; 32 | return; 33 | } 34 | 35 | var auth = await context.AuthenticateAsync(); 36 | 37 | if (!auth.Succeeded) 38 | { 39 | context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; 40 | return; 41 | } 42 | 43 | var subscription = default(StreamSubscriptionHandle); 44 | var userId = new Guid(auth.Ticket.Principal.Claims.Single(e => e.Type == ClaimTypes.NameIdentifier).Value); 45 | 46 | try 47 | { 48 | var webSocket = await context.WebSockets.AcceptWebSocketAsync(); 49 | _logger.LogInformation("[Websocket] opened connection for UserId: {userId}", userId); 50 | 51 | subscription = await 52 | _clusterClient 53 | .GetStreamProvider(Constants.InMemoryStream) 54 | .GetStream(userId, Constants.WebSocketNamespace) 55 | .SubscribeAsync(async (evt, st) => 56 | { 57 | var bytes = Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(evt)); 58 | var msgBuffer = new ArraySegment(bytes, 0, bytes.Length); 59 | await webSocket.SendAsync(msgBuffer, WebSocketMessageType.Text, true, CancellationToken.None); 60 | }); 61 | 62 | var buffer = new byte[1024 * 4]; 63 | 64 | while (webSocket.CloseStatus.HasValue == false) 65 | await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); 66 | 67 | await webSocket.CloseAsync( 68 | webSocket.CloseStatus.Value, 69 | webSocket.CloseStatusDescription, CancellationToken.None); 70 | 71 | _logger.LogInformation("[Websocket] closed connection for TraceId: {traceId}", userId); 72 | } 73 | catch (Exception e) 74 | { 75 | _logger.LogError("[Websocket] disconnect error -> {exception}", e); 76 | } 77 | finally 78 | { 79 | if (subscription != null) 80 | await subscription.UnsubscribeAsync(); 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/Projections/Teams/TeamSubscriber.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using Orleans; 3 | using Orleans.Streams; 4 | using Tournament.Domain; 5 | using Tournament.Domain.Abstractions; 6 | using Tournament.Domain.Abstractions.Grains; 7 | using Tournament.Domain.Teams; 8 | using Tournament.Projections.Tournaments; 9 | 10 | namespace Tournament.Projections.Teams; 11 | 12 | [ImplicitStreamSubscription(Constants.TeamNamespace)] 13 | public class TeamSubscriber : SubscriberGrain 14 | { 15 | private readonly ITournamentQueryHandler _tournamentQueryHandler; 16 | private readonly ProjectionManager _projectionManager; 17 | 18 | public TeamSubscriber( 19 | PostgresOptions postgresOptions, 20 | ITournamentQueryHandler tournamentQueryHandler) 21 | : base( 22 | new StreamConfig(Constants.InMemoryStream, Constants.TeamNamespace)) 23 | { 24 | _tournamentQueryHandler = tournamentQueryHandler; 25 | _projectionManager = new ProjectionManager("read", "team_projection", postgresOptions); 26 | } 27 | 28 | public override async Task HandleAsync(object evt, StreamSequenceToken token) 29 | { 30 | switch (evt) 31 | { 32 | case TeamCreated obj: 33 | return await Handle(obj); 34 | 35 | case PlayerAdded obj: 36 | return await Handle(obj); 37 | 38 | case TournamentJoined obj: 39 | return await Handle(obj); 40 | 41 | case ErrorHasOccurred _: 42 | return true; 43 | 44 | default: 45 | //PrefixLogger.LogError( 46 | // "unhandled event of type [{evtType}] for resource id: [{grainId}]", evt.GetType().Name, this.GetPrimaryKey()); 47 | return false; 48 | } 49 | } 50 | 51 | private async Task Handle(TeamCreated evt) 52 | { 53 | var projection = new TeamProjection( 54 | evt.TeamId, 55 | evt.Name, 56 | Enumerable.Empty().ToImmutableList(), 57 | Enumerable.Empty().ToImmutableList()); 58 | 59 | await _projectionManager.UpdateProjection(this.GetPrimaryKey(), projection); 60 | return true; 61 | } 62 | 63 | private async Task Handle(PlayerAdded evt) 64 | { 65 | var projection = await _projectionManager.GetProjectionAsync(this.GetPrimaryKey()); 66 | 67 | await _projectionManager.UpdateProjection( 68 | this.GetPrimaryKey(), 69 | projection with { Players = projection.Players.Add(evt.Name) }); 70 | 71 | return true; 72 | } 73 | 74 | private async Task Handle(TournamentJoined evt) 75 | { 76 | var tournament = await _tournamentQueryHandler.GetTournamentAsync(evt.TournamentId); 77 | var tournamentObj = new Tournament(tournament.Id, tournament.Name); 78 | 79 | var projection = await _projectionManager.GetProjectionAsync(this.GetPrimaryKey()); 80 | 81 | await _projectionManager.UpdateProjection( 82 | this.GetPrimaryKey(), 83 | projection with { Tournaments = projection.Tournaments.Add(tournamentObj) }); 84 | 85 | return true; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Domain/Tournaments/ValueObjects.cs: -------------------------------------------------------------------------------- 1 | namespace Tournament.Domain.Tournaments; 2 | 3 | public record MatchResult(int LocalGoals, int AwayGoals, bool Played) 4 | { 5 | public static MatchResult Empty 6 | => new MatchResult(0, 0, false); 7 | } 8 | 9 | public record Match(Guid LocalTeamId, Guid AwayTeamId, MatchResult MatchResult) 10 | { 11 | public Match SetResult(MatchResult result) => 12 | this with { MatchResult = result }; 13 | } 14 | 15 | public record Phase(List Matches, bool Played) 16 | { 17 | public bool IsEmpty => !Matches.Any(); 18 | 19 | public Phase SetMatchResult(Match match) 20 | { 21 | if (Played) 22 | return this; 23 | 24 | // Find match index 25 | var index = Matches.FindIndex(e => 26 | e.LocalTeamId == match.LocalTeamId && 27 | e.AwayTeamId == match.AwayTeamId); 28 | 29 | 30 | Matches[index] = match; 31 | 32 | return this with 33 | { 34 | Played = Matches.All(e => e.MatchResult.Played) 35 | }; 36 | } 37 | 38 | private static Phase GeneratePhase(List teams) 39 | { 40 | var matches = new List(); 41 | 42 | for (var i = 0; i < teams.Count; i++) 43 | { 44 | var local = teams[i]; 45 | i++; 46 | var away = teams[i]; 47 | 48 | matches.Add(new Match(local, away, MatchResult.Empty)); 49 | } 50 | 51 | return new Phase(matches, false); 52 | } 53 | 54 | public static Phase Empty 55 | => new Phase(Enumerable.Empty().ToList(), false); 56 | 57 | public static Phase GenerateRandomPhase(List teams, int seed) 58 | { 59 | var rnd = new Random(seed); 60 | return GeneratePhase(teams.OrderBy(e => rnd.Next()).ToList()); 61 | } 62 | 63 | public static Phase MaybeGenerateNewPhaseFromCurrent(Phase current) 64 | { 65 | if (!current.Played) 66 | return Phase.Empty; 67 | 68 | var winners = current.Matches.Select(e => 69 | e.MatchResult.LocalGoals > e.MatchResult.AwayGoals 70 | ? e.LocalTeamId 71 | : e.AwayTeamId 72 | ).ToList(); 73 | 74 | return GeneratePhase(winners); 75 | } 76 | } 77 | 78 | public record Fixture(Phase Quarter, Phase Semi, Phase Final) 79 | { 80 | public static Fixture Empty 81 | => new(Phase.Empty, Phase.Empty, Phase.Empty); 82 | public static Fixture Create(List teams, int seed) 83 | => new(Phase.GenerateRandomPhase(teams, seed), Phase.Empty, Phase.Empty); 84 | 85 | public Fixture SetMatchResult(Match match) 86 | { 87 | if (!Final.IsEmpty) 88 | { 89 | return this with { Final = Final.SetMatchResult(match) }; 90 | } 91 | else if (!Semi.IsEmpty) 92 | { 93 | var phase = Semi.SetMatchResult(match); 94 | return this with 95 | { 96 | Final = Phase.MaybeGenerateNewPhaseFromCurrent(phase), 97 | Semi = phase 98 | }; 99 | } 100 | else 101 | { 102 | var phase = Quarter.SetMatchResult(match); 103 | return this with 104 | { 105 | Semi = Phase.MaybeGenerateNewPhaseFromCurrent(phase), 106 | Quarter = phase 107 | }; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Domain/Tournaments/Grain.cs: -------------------------------------------------------------------------------- 1 | using Orleans; 2 | using Tournament.Domain.Abstractions; 3 | using Tournament.Domain.Abstractions.Grains; 4 | using Tournament.Domain.Teams; 5 | 6 | namespace Tournament.Domain.Tournaments; 7 | 8 | public interface ITournamentGrain : IGrainWithGuidKey 9 | { 10 | // Commands 11 | Task CreateAsync(CreateTournament cmd); 12 | 13 | Task AddTeamAsync(AddTeam cmd); 14 | 15 | Task StartAsync(StartTournament cmd); 16 | 17 | Task SetMatchResultAsync(SetMatchResult cmd); 18 | } 19 | 20 | public class TournamentGrain : EventSourcedGrain, ITournamentGrain 21 | { 22 | public TournamentGrain() 23 | : base(new StreamConfig(Constants.InMemoryStream, Constants.TournamentNamespace)) 24 | { } 25 | 26 | public async Task CreateAsync(CreateTournament cmd) 27 | { 28 | var task = State.TournamentDoesNotExists() switch 29 | { 30 | Results.Unit => PersistPublishAsync(new TournamentCreated(cmd.Name, cmd.TournamentId, cmd.TraceId, cmd.InvokerUserId)), 31 | Results x => PublishErrorAsync(new ErrorHasOccurred((int)x, x.ToString(), cmd.TraceId, cmd.InvokerUserId)) 32 | }; 33 | 34 | await task; 35 | } 36 | 37 | public async Task AddTeamAsync(AddTeam cmd) 38 | { 39 | // Check if the team already exists 40 | var teamGrain = GrainFactory.GetGrain(cmd.TeamId); 41 | var exists = await teamGrain.TeamExistAsync(); 42 | if (!exists) 43 | await PublishErrorAsync(new ErrorHasOccurred((int)Results.TeamDoesNotExist, nameof(AddTeam), cmd.TraceId, cmd.InvokerUserId)); 44 | 45 | // Check other preconditions 46 | var result = ResultsUtil.Eval( 47 | State.TournamentExists(), 48 | State.TeamIsNotAdded(cmd.TeamId), 49 | State.LessThanEightTeams()); 50 | 51 | var task = result switch 52 | { 53 | Results.Unit => PersistPublishAsync(new TeamAdded(cmd.TeamId, cmd.TournamentId, cmd.TraceId, cmd.InvokerUserId)), 54 | Results x => PublishErrorAsync(new ErrorHasOccurred((int)x, x.ToString(), cmd.TraceId, cmd.InvokerUserId)) 55 | }; 56 | 57 | await task; 58 | } 59 | 60 | public async Task StartAsync(StartTournament cmd) 61 | { 62 | var result = ResultsUtil.Eval( 63 | State.TournamentExists(), 64 | State.TournamentDidNotStart(), 65 | State.EightTeamsToStartTournament()); 66 | 67 | // Randomize seed 68 | var seed = DateTime.Now.Millisecond; 69 | 70 | var task = result switch 71 | { 72 | Results.Unit => PersistPublishAsync(new TournamentStarted(cmd.TournamentId, State.Teams, seed, cmd.TraceId, cmd.InvokerUserId)), 73 | Results x => PublishErrorAsync(new ErrorHasOccurred((int)x, x.ToString(), cmd.TraceId, cmd.InvokerUserId)) 74 | }; 75 | 76 | await task; 77 | } 78 | 79 | public async Task SetMatchResultAsync(SetMatchResult cmd) 80 | { 81 | var result = ResultsUtil.Eval( 82 | State.TournamentExists(), 83 | State.TournamentStarted(), 84 | State.MatchIsNotDraw(cmd.Match.MatchResult), 85 | State.MatchExistsAndIsNotPlayed(cmd.Match)); 86 | 87 | var task = result switch 88 | { 89 | Results.Unit => PersistPublishAsync(new MatchResultSet(cmd.TournamentId, cmd.Match, cmd.TraceId, cmd.InvokerUserId)), 90 | Results x => PublishErrorAsync(new ErrorHasOccurred((int)x, x.ToString(), cmd.TraceId, cmd.InvokerUserId)) 91 | }; 92 | 93 | await task; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Silo/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Orleans; 3 | using Orleans.CodeGeneration; 4 | using Orleans.Configuration; 5 | using Orleans.Hosting; 6 | using Orleans.Statistics; 7 | using Tournament.Domain; 8 | using Tournament.Projections; 9 | using Tournament.Projections.Teams; 10 | using Tournament.Projections.Tournaments; 11 | using Results = Microsoft.AspNetCore.Http.Results; 12 | 13 | [assembly: KnownAssembly(typeof(Constants))] 14 | var builder = WebApplication.CreateBuilder(args); 15 | 16 | // Parse environment variables 17 | IConfiguration configuration = builder.Configuration; 18 | 19 | var siloPort = configuration.GetValue("SILO_PORT"); 20 | var gatewayPort = configuration.GetValue("GATEWAY_PORT"); 21 | 22 | var clusterId = configuration["CLUSTER_ID"]; 23 | var serviceId = configuration["SERVICE_ID"]; 24 | var buildVersion = configuration["BUILD_VERSION"]; 25 | var postgresConnection = configuration["POSTGRES_CONNECTION"]; 26 | 27 | var postgresOptions = new PostgresOptions(postgresConnection); 28 | 29 | // Orleans cluster silo 30 | var clusterSilo = new SiloHostBuilder() 31 | .Configure(options => 32 | { 33 | options.ClusterId = clusterId; 34 | options.ServiceId = serviceId; 35 | }) 36 | .UseAdoNetClustering(opt => 37 | { 38 | opt.Invariant = "Npgsql"; 39 | opt.ConnectionString = postgresConnection; 40 | }) 41 | .AddAdoNetGrainStorageAsDefault(opt => 42 | { 43 | opt.Invariant = "Npgsql"; 44 | opt.ConnectionString = postgresConnection; 45 | //opt.UseJsonFormat = true; // TODO: After restart this does not apply the events 46 | }) 47 | .AddLogStorageBasedLogConsistencyProviderAsDefault() 48 | .ConfigureEndpoints(siloPort, gatewayPort) 49 | .ConfigureApplicationParts(parts => parts.AddFromDependencyContext().WithReferences()) 50 | .AddMemoryGrainStorage("PubSubStore") 51 | .AddSimpleMessageStreamProvider(Constants.InMemoryStream) 52 | .ConfigureLogging(e => 53 | e.AddJsonConsole(options => 54 | { 55 | options.IncludeScopes = true; 56 | options.TimestampFormat = "dd/MM/yyyy hh:mm:ss"; 57 | options.JsonWriterOptions = new JsonWriterOptions { Indented = true }; 58 | }) 59 | .AddFilter(level => level >= LogLevel.Warning) 60 | ) 61 | // These dependencies are resolved by Orleans runtime so they are 62 | // configured inside the cluster creation 63 | .ConfigureServices(services => 64 | { 65 | services 66 | .AddSingleton(postgresOptions) 67 | .AddSingleton() 68 | .AddSingleton(); 69 | }) 70 | .UseLinuxEnvironmentStatistics() 71 | .UseDashboard(options => 72 | { 73 | options.HostSelf = false; 74 | options.CounterUpdateIntervalMs = 5000; 75 | }) 76 | .Build(); 77 | 78 | builder 79 | .Services 80 | .AddLogging(e => 81 | e.AddJsonConsole(options => 82 | { 83 | options.IncludeScopes = true; 84 | options.TimestampFormat = "dd/MM/yyyy hh:mm:ss"; 85 | options.JsonWriterOptions = new JsonWriterOptions { Indented = true }; 86 | }) 87 | .AddFilter(level => level >= LogLevel.Information) 88 | ) 89 | .AddSingleton(clusterSilo); 90 | 91 | var app = builder.Build(); 92 | 93 | app.MapGet("/version", () => Results.Ok(new { BuildVersion = buildVersion })); 94 | app.MapGet("/leave", async () => 95 | { 96 | // Stop the Silo 97 | await clusterSilo.StopAsync(); 98 | await clusterSilo.Stopped; 99 | Results.Ok(); 100 | }); 101 | 102 | // Start the Silo 103 | await clusterSilo.StartAsync(); 104 | 105 | // Starting API 106 | await app.RunAsync(); 107 | -------------------------------------------------------------------------------- /src/Projections/Tournaments/TournamentSubscriber.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using Orleans; 3 | using Orleans.Streams; 4 | using Tournament.Domain; 5 | using Tournament.Domain.Abstractions; 6 | using Tournament.Domain.Abstractions.Grains; 7 | using Tournament.Domain.Tournaments; 8 | using Tournament.Projections.Teams; 9 | 10 | namespace Tournament.Projections.Tournaments; 11 | 12 | [ImplicitStreamSubscription(Constants.TournamentNamespace)] 13 | public class TournamentSubscriber : SubscriberGrain 14 | { 15 | private readonly ITeamQueryHandler _teamQueryHandler; 16 | private readonly ProjectionManager _projectionManager; 17 | 18 | public TournamentSubscriber( 19 | PostgresOptions postgresOptions, 20 | ITeamQueryHandler teamQueryHandler) 21 | : base( 22 | new StreamConfig(Constants.InMemoryStream, Constants.TournamentNamespace)) 23 | { 24 | _projectionManager = new ProjectionManager("read", "tournament_projection", postgresOptions); 25 | _teamQueryHandler = teamQueryHandler; 26 | } 27 | 28 | public override async Task HandleAsync(object evt, StreamSequenceToken token) 29 | { 30 | switch (evt) 31 | { 32 | case TournamentCreated obj: 33 | return await Handle(obj); 34 | 35 | case TeamAdded obj: 36 | return await Handle(obj); 37 | 38 | case TournamentStarted obj: 39 | return await Handle(obj); 40 | 41 | case MatchResultSet obj: 42 | return await Handle(obj); 43 | 44 | case ErrorHasOccurred _: 45 | return true; 46 | 47 | default: 48 | //logger.LogError( 49 | // "unhandled event of type [{evtType}] for resource id: [{grainId}]", evt.GetType().Name, this.GetPrimaryKey()); 50 | return false; 51 | } 52 | } 53 | 54 | private async Task Handle(TournamentCreated evt) 55 | { 56 | var projection = new TournamentProjection( 57 | evt.TournamentId, 58 | evt.Name, 59 | Enumerable.Empty().ToImmutableList(), 60 | Fixture.Empty); 61 | 62 | await _projectionManager.UpdateProjection(this.GetPrimaryKey(), projection); 63 | return true; 64 | } 65 | 66 | private async Task Handle(TeamAdded evt) 67 | { 68 | var team = await _teamQueryHandler.GetTeamAsync(evt.TeamId); 69 | var teamObj = new Team(team.Id, team.Name); 70 | 71 | var projection = await _projectionManager.GetProjectionAsync(this.GetPrimaryKey()); 72 | 73 | await _projectionManager.UpdateProjection( 74 | this.GetPrimaryKey(), 75 | projection with { Teams = projection.Teams.Add(teamObj) }); 76 | 77 | return true; 78 | } 79 | 80 | // The idea is to serve the data in a friendly way to the user, in this case 81 | // I am using the same value object that the state is using but it can be changed here 82 | private async Task Handle(TournamentStarted evt) 83 | { 84 | var projection = await _projectionManager.GetProjectionAsync(this.GetPrimaryKey()); 85 | 86 | await _projectionManager.UpdateProjection( 87 | this.GetPrimaryKey(), 88 | projection with { Fixture = Fixture.Create(evt.Teams, evt.Seed) }); 89 | 90 | return true; 91 | } 92 | 93 | private async Task Handle(MatchResultSet evt) 94 | { 95 | var projection = await _projectionManager.GetProjectionAsync(this.GetPrimaryKey()); 96 | 97 | await _projectionManager.UpdateProjection( 98 | this.GetPrimaryKey(), 99 | projection with { Fixture = projection.Fixture.SetMatchResult(evt.Match) }); 100 | 101 | return true; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/API/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using Microsoft.AspNetCore.Authentication.JwtBearer; 4 | using Microsoft.IdentityModel.Tokens; 5 | using Orleans; 6 | using Orleans.CodeGeneration; 7 | using Orleans.Configuration; 8 | using Orleans.Hosting; 9 | using Tournament.API.Middlewares; 10 | using Tournament.Domain; 11 | using Tournament.Projections; 12 | using Tournament.Projections.Teams; 13 | using Tournament.Projections.Tournaments; 14 | using Results = Microsoft.AspNetCore.Http.Results; 15 | 16 | [assembly: KnownAssembly(typeof(Constants))] 17 | var builder = WebApplication.CreateBuilder(args); 18 | 19 | // Parse environment variables 20 | IConfiguration configuration = builder.Configuration; 21 | var clusterId = configuration["CLUSTER_ID"]; 22 | var serviceId = configuration["SERVICE_ID"]; 23 | var buildVersion = configuration["BUILD_VERSION"]; 24 | var postgresConnection = configuration["POSTGRES_CONNECTION"]; 25 | var jwtIssuer = configuration["JWT_ISSUER"]; 26 | var jwtAudience = configuration["JWT_AUDIENCE"]; 27 | var jwtSigningKey = Encoding.UTF8.GetBytes(configuration["JWT_SIGNING_KEY"]); 28 | 29 | var postgresOptions = new PostgresOptions(postgresConnection); 30 | 31 | // Orleans cluster connection 32 | var clusterClient = new ClientBuilder() 33 | .Configure(options => 34 | { 35 | options.ClusterId = clusterId; 36 | options.ServiceId = serviceId; 37 | }) 38 | .UseAdoNetClustering(opt => 39 | { 40 | opt.Invariant = "Npgsql"; 41 | opt.ConnectionString = postgresConnection; 42 | }) 43 | .ConfigureLogging(e => 44 | e.AddJsonConsole(options => 45 | { 46 | options.IncludeScopes = true; 47 | options.TimestampFormat = "dd/MM/yyyy hh:mm:ss"; 48 | options.JsonWriterOptions = new JsonWriterOptions { Indented = true }; 49 | }) 50 | .AddFilter(level => level >= LogLevel.Warning) 51 | ) 52 | .AddSimpleMessageStreamProvider(Constants.InMemoryStream) 53 | .ConfigureApplicationParts(parts => parts.AddFromDependencyContext().WithReferences()) 54 | .Build(); 55 | 56 | builder 57 | .Services 58 | .AddLogging(e => 59 | e.AddJsonConsole(options => 60 | { 61 | options.IncludeScopes = true; 62 | options.TimestampFormat = "dd/MM/yyyy hh:mm:ss"; 63 | options.JsonWriterOptions = new JsonWriterOptions { Indented = true }; 64 | }) 65 | .AddFilter(level => level >= LogLevel.Information) 66 | ) 67 | .AddSingleton(clusterClient) 68 | .AddSingleton(postgresOptions) 69 | .AddSingleton() 70 | .AddSingleton() 71 | .AddControllers(); 72 | 73 | builder 74 | .Services 75 | .AddAuthorization() 76 | .AddAuthentication(options => 77 | { 78 | options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; 79 | options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; 80 | options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; 81 | }) 82 | .AddJwtBearer(options => options.TokenValidationParameters = new TokenValidationParameters 83 | { 84 | ValidateIssuer = true, 85 | ValidIssuer = jwtIssuer, 86 | ValidateAudience = true, 87 | ValidAudience = jwtAudience, 88 | IssuerSigningKey = new SymmetricSecurityKey(jwtSigningKey), 89 | ValidateIssuerSigningKey = true, 90 | ValidateLifetime = true 91 | }); 92 | 93 | var app = builder.Build(); 94 | 95 | app.UseRouting(); 96 | app.UseAuthentication(); 97 | app.UseAuthorization(); 98 | 99 | app.MapGet("/version", () => Results.Ok(new { BuildVersion = buildVersion })); 100 | app.MapGet("/leave", async () => 101 | { 102 | await clusterClient.Close(); 103 | Results.Ok(); 104 | }); 105 | 106 | app.UseWebSockets(); 107 | app.Map("/ws", ws => ws.UseMiddleware()); 108 | 109 | app.UseEndpoints(e => e.MapControllers()); 110 | 111 | // Connect to cluster 112 | await clusterClient.Connect(async e => 113 | { 114 | await Task.Delay(TimeSpan.FromSeconds(2)); 115 | return true; 116 | }); 117 | 118 | // Starting API 119 | await app.RunAsync(); 120 | -------------------------------------------------------------------------------- /src/API/Tournaments/Controller.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Security.Claims; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Orleans; 6 | using Tournament.Domain.Tournaments; 7 | using Tournament.Projections.Tournaments; 8 | 9 | namespace Tournament.API.Tournaments; 10 | 11 | public class Controller : ControllerBase 12 | { 13 | private readonly IClusterClient _clusterClient; 14 | private readonly ITournamentQueryHandler _tournamentQueryHandler; 15 | 16 | public Controller( 17 | IClusterClient clusterClient, 18 | ITournamentQueryHandler tournamentQueryHandler) 19 | { 20 | _clusterClient = clusterClient; 21 | _tournamentQueryHandler = tournamentQueryHandler; 22 | } 23 | 24 | private Guid GetUserId 25 | => new Guid(User.Claims.Single(e => e.Type == ClaimTypes.NameIdentifier).Value); 26 | 27 | [Authorize(Roles = "write")] 28 | [HttpPost("api/tournament/create", Name = "Create tournament")] 29 | [ProducesResponseType(typeof(ResourceResponse), (int)HttpStatusCode.OK)] 30 | public IActionResult CreateTournament([FromBody] CreateTournamentModel model) 31 | { 32 | var tournamentId = Guid.NewGuid(); 33 | var traceId = Guid.NewGuid(); 34 | var tournament = _clusterClient.GetGrain(tournamentId); 35 | 36 | tournament.CreateAsync(new CreateTournament(model.Name, tournamentId, traceId, GetUserId)); 37 | 38 | return Created(tournamentId.ToString(), new ResourceResponse(tournamentId, traceId)); 39 | } 40 | 41 | [Authorize(Roles = "write")] 42 | [HttpPut("api/tournament/{tournamentId:Guid}/team", Name = "Add team to tournament")] 43 | [ProducesResponseType(typeof(TraceResponse), (int)HttpStatusCode.OK)] 44 | public IActionResult AddTeams([FromRoute] Guid tournamentId, [FromBody] AddTeamModel model) 45 | { 46 | var traceId = Guid.NewGuid(); 47 | var tournament = _clusterClient.GetGrain(tournamentId); 48 | 49 | tournament.AddTeamAsync(new AddTeam(tournamentId, model.TeamId, traceId, GetUserId)); 50 | return Ok(new TraceResponse(traceId)); 51 | } 52 | 53 | [Authorize(Roles = "write")] 54 | [HttpPut("api/tournament/{tournamentId:Guid}/start", Name = "Start tournament")] 55 | [ProducesResponseType(typeof(TraceResponse), (int)HttpStatusCode.OK)] 56 | public IActionResult StartTournament([FromRoute] Guid tournamentId) 57 | { 58 | var traceId = Guid.NewGuid(); 59 | var tournament = _clusterClient.GetGrain(tournamentId); 60 | 61 | tournament.StartAsync(new StartTournament(tournamentId, traceId, GetUserId)); 62 | return Ok(new TraceResponse(traceId)); 63 | } 64 | 65 | [Authorize(Roles = "write")] 66 | [HttpPut("api/tournament/{tournamentId:Guid}/setMatchResult", Name = "Set Match Result")] 67 | [ProducesResponseType(typeof(TraceResponse), (int)HttpStatusCode.OK)] 68 | public IActionResult SetMatchResult([FromRoute] Guid tournamentId, [FromBody] SetMatchResultModel model) 69 | { 70 | var traceId = Guid.NewGuid(); 71 | var tournament = _clusterClient.GetGrain(tournamentId); 72 | 73 | var matchInfo = new Match(model.LocalTeamId, model.AwayTeamId, new MatchResult(model.LocalGoals, model.AwayGoals, true)); 74 | 75 | tournament.SetMatchResultAsync(new SetMatchResult(tournamentId, matchInfo, traceId, GetUserId)); 76 | return Ok(new TraceResponse(traceId)); 77 | } 78 | 79 | [Authorize(Roles = "read")] 80 | [HttpGet("api/tournament/{tournamentId:Guid}", Name = "Get tournament")] 81 | [ProducesResponseType((int)HttpStatusCode.BadRequest)] 82 | [ProducesResponseType(typeof(TournamentResponse), (int)HttpStatusCode.OK)] 83 | public async Task GetTournament([FromRoute] Guid tournamentId) 84 | { 85 | try 86 | { 87 | var projection = await _tournamentQueryHandler.GetTournamentAsync(tournamentId); 88 | return Ok(TournamentResponse.From(projection)); 89 | } 90 | catch 91 | { 92 | return NotFound(); 93 | } 94 | } 95 | 96 | [Authorize(Roles = "read")] 97 | [HttpGet("api/tournaments", Name = "Get tournaments")] 98 | [ProducesResponseType((int)HttpStatusCode.BadRequest)] 99 | [ProducesResponseType(typeof(TournamentResponse), (int)HttpStatusCode.OK)] 100 | public async Task GetTournaments() 101 | { 102 | var projection = await _tournamentQueryHandler.GetTournamentsAsync(); 103 | return Ok(TournamentResponse.From(projection)); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/API.Identity/UserAuthentication.cs: -------------------------------------------------------------------------------- 1 | using System.IdentityModel.Tokens.Jwt; 2 | using System.Security.Claims; 3 | using System.Text.RegularExpressions; 4 | using Dapper; 5 | using Microsoft.IdentityModel.Tokens; 6 | using Npgsql; 7 | 8 | namespace Tournament.API.Identity; 9 | 10 | public record Login(string Email, string Password); 11 | 12 | public record CreateUser(string Email, string Password, IList Claims); 13 | 14 | public interface ICreateUser 15 | { 16 | Task Handle(CreateUser request); 17 | } 18 | 19 | public interface ILoginUser 20 | { 21 | Task Handle(Login request); 22 | } 23 | 24 | public record ConnectionString(string Value); 25 | public record JwtConfiguration(string Issuer, string Audience, SymmetricSecurityKey SigningKey); 26 | 27 | public class UserAuthentication : ICreateUser, ILoginUser 28 | { 29 | private readonly ConnectionString _connectionString; 30 | private readonly JwtConfiguration _jwtConfiguration; 31 | 32 | public UserAuthentication(ConnectionString connectionString, JwtConfiguration jwtConfiguration) 33 | { 34 | _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); 35 | _jwtConfiguration = jwtConfiguration ?? throw new ArgumentNullException(nameof(jwtConfiguration)); 36 | } 37 | 38 | public async Task Handle(CreateUser request) 39 | { 40 | const string emailPattern = 41 | @"\A(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\Z"; 42 | var isEmail = Regex.IsMatch(request.Email, emailPattern, RegexOptions.IgnoreCase); 43 | if (isEmail == false) 44 | throw new Exception("the email is not valid"); 45 | 46 | var email = request.Email.ToUpperInvariant(); 47 | await using var dbConnection = new NpgsqlConnection(_connectionString.Value); 48 | 49 | var fetchedEmail = await dbConnection.QueryFirstOrDefaultAsync( 50 | "SELECT email FROM auth.user WHERE email = @email", new { email }); 51 | 52 | if (!string.IsNullOrEmpty(fetchedEmail)) 53 | throw new Exception("the user already exists"); 54 | 55 | var userId = Guid.NewGuid(); 56 | var (passwordHash, saltKey) = GetPasswordHash(request.Password); 57 | 58 | await dbConnection.ExecuteAsync( 59 | "INSERT INTO auth.user (id, email, password_hash, salt_key, claims) VALUES (@userId, @email, @passwordHash, @saltKey, @claims)", 60 | new { userId, email, passwordHash, saltKey, request.Claims }); 61 | 62 | return userId; 63 | } 64 | 65 | public async Task Handle(Login request) 66 | { 67 | await using var dbConnection = new NpgsqlConnection(_connectionString.Value); 68 | 69 | var email = request.Email.ToUpperInvariant(); 70 | var user = await dbConnection.QueryFirstOrDefaultAsync( 71 | @"SELECT 72 | id AS Id, 73 | claims AS Claims, 74 | salt_key AS SaltKey, 75 | password_hash AS PasswordHash 76 | FROM auth.user where email = @email", new { email }); 77 | 78 | if (user is null) 79 | throw new Exception("user does not exists"); 80 | 81 | var hash = BCrypt.Net.BCrypt.HashPassword(request.Password, user.SaltKey); 82 | if (hash != user.PasswordHash) 83 | throw new Exception("invalid password"); 84 | 85 | var credentials = new SigningCredentials(_jwtConfiguration.SigningKey, SecurityAlgorithms.HmacSha512); 86 | var jwtTokenHandler = new JwtSecurityTokenHandler(); 87 | var tokenDescriptor = new SecurityTokenDescriptor 88 | { 89 | Subject = new ClaimsIdentity(new[] 90 | { 91 | new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), 92 | new Claim(JwtRegisteredClaimNames.Email, request.Email), 93 | new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) 94 | }.Concat(user.Claims.Cast().Select(e => new Claim(ClaimTypes.Role, e)))), 95 | Expires = DateTime.Now.AddMinutes(5), 96 | Audience = _jwtConfiguration.Audience, 97 | Issuer = _jwtConfiguration.Issuer, 98 | SigningCredentials = credentials 99 | }; 100 | 101 | var token = jwtTokenHandler.CreateToken(tokenDescriptor); 102 | var jwtToken = jwtTokenHandler.WriteToken(token); 103 | return jwtToken; 104 | } 105 | 106 | private static (string hash, string saltKey) GetPasswordHash(string password) 107 | { 108 | var saltKey = BCrypt.Net.BCrypt.GenerateSalt(); 109 | var hash = BCrypt.Net.BCrypt.HashPassword(password, saltKey); 110 | return (hash, saltKey); 111 | } 112 | } 113 | 114 | internal record UserDatabase(Guid Id, Array Claims, string SaltKey, string PasswordHash); -------------------------------------------------------------------------------- /postgres/03_orleans_persistence.sql: -------------------------------------------------------------------------------- 1 | -- https://github.com/dotnet/orleans/blob/3.x/src/AdoNet/Orleans.Persistence.AdoNet/PostgreSQL-Persistence.sql 2 | -- https://github.com/dotnet/orleans/blob/3.x/src/AdoNet/Orleans.Persistence.AdoNet/Migrations/PostgreSQL-Persistence-3.6.0.sql 3 | CREATE TABLE OrleansStorage 4 | ( 5 | grainidhash integer NOT NULL, 6 | grainidn0 bigint NOT NULL, 7 | grainidn1 bigint NOT NULL, 8 | graintypehash integer NOT NULL, 9 | graintypestring character varying(512) NOT NULL, 10 | grainidextensionstring character varying(512) , 11 | serviceid character varying(150) NOT NULL, 12 | payloadbinary bytea, 13 | payloadxml xml, 14 | payloadjson text, 15 | modifiedon timestamptz NOT NULL, 16 | version integer 17 | ); 18 | 19 | CREATE INDEX ix_orleansstorage 20 | ON orleansstorage USING btree 21 | (grainidhash, graintypehash); 22 | 23 | CREATE OR REPLACE FUNCTION writetostorage( 24 | _grainidhash integer, 25 | _grainidn0 bigint, 26 | _grainidn1 bigint, 27 | _graintypehash integer, 28 | _graintypestring character varying, 29 | _grainidextensionstring character varying, 30 | _serviceid character varying, 31 | _grainstateversion integer, 32 | _payloadbinary bytea, 33 | _payloadjson text, 34 | _payloadxml xml) 35 | RETURNS TABLE(newgrainstateversion integer) 36 | LANGUAGE 'plpgsql' 37 | AS $function$ 38 | DECLARE 39 | _newGrainStateVersion integer := _GrainStateVersion; 40 | RowCountVar integer := 0; 41 | 42 | BEGIN 43 | 44 | -- Grain state is not null, so the state must have been read from the storage before. 45 | -- Let's try to update it. 46 | -- 47 | -- When Orleans is running in normal, non-split state, there will 48 | -- be only one grain with the given ID and type combination only. This 49 | -- grain saves states mostly serially if Orleans guarantees are upheld. Even 50 | -- if not, the updates should work correctly due to version number. 51 | -- 52 | -- In split brain situations there can be a situation where there are two or more 53 | -- grains with the given ID and type combination. When they try to INSERT 54 | -- concurrently, the table needs to be locked pessimistically before one of 55 | -- the grains gets @GrainStateVersion = 1 in return and the other grains will fail 56 | -- to update storage. The following arrangement is made to reduce locking in normal operation. 57 | -- 58 | -- If the version number explicitly returned is still the same, Orleans interprets it so the update did not succeed 59 | -- and throws an InconsistentStateException. 60 | -- 61 | -- See further information at https://dotnet.github.io/orleans/Documentation/Core-Features/Grain-Persistence.html. 62 | IF _GrainStateVersion IS NOT NULL 63 | THEN 64 | UPDATE OrleansStorage 65 | SET 66 | PayloadBinary = _PayloadBinary, 67 | PayloadJson = _PayloadJson, 68 | PayloadXml = _PayloadXml, 69 | ModifiedOn = (now() at time zone 'utc'), 70 | Version = Version + 1 71 | 72 | WHERE 73 | GrainIdHash = _GrainIdHash AND _GrainIdHash IS NOT NULL 74 | AND GrainTypeHash = _GrainTypeHash AND _GrainTypeHash IS NOT NULL 75 | AND GrainIdN0 = _GrainIdN0 AND _GrainIdN0 IS NOT NULL 76 | AND GrainIdN1 = _GrainIdN1 AND _GrainIdN1 IS NOT NULL 77 | AND GrainTypeString = _GrainTypeString AND _GrainTypeString IS NOT NULL 78 | AND ((_GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString = _GrainIdExtensionString) OR _GrainIdExtensionString IS NULL AND GrainIdExtensionString IS NULL) 79 | AND ServiceId = _ServiceId AND _ServiceId IS NOT NULL 80 | AND Version IS NOT NULL AND Version = _GrainStateVersion AND _GrainStateVersion IS NOT NULL; 81 | 82 | GET DIAGNOSTICS RowCountVar = ROW_COUNT; 83 | IF RowCountVar > 0 84 | THEN 85 | _newGrainStateVersion := _GrainStateVersion + 1; 86 | END IF; 87 | END IF; 88 | 89 | -- The grain state has not been read. The following locks rather pessimistically 90 | -- to ensure only one INSERT succeeds. 91 | IF _GrainStateVersion IS NULL 92 | THEN 93 | INSERT INTO OrleansStorage 94 | ( 95 | GrainIdHash, 96 | GrainIdN0, 97 | GrainIdN1, 98 | GrainTypeHash, 99 | GrainTypeString, 100 | GrainIdExtensionString, 101 | ServiceId, 102 | PayloadBinary, 103 | PayloadJson, 104 | PayloadXml, 105 | ModifiedOn, 106 | Version 107 | ) 108 | SELECT 109 | _GrainIdHash, 110 | _GrainIdN0, 111 | _GrainIdN1, 112 | _GrainTypeHash, 113 | _GrainTypeString, 114 | _GrainIdExtensionString, 115 | _ServiceId, 116 | _PayloadBinary, 117 | _PayloadJson, 118 | _PayloadXml, 119 | (now() at time zone 'utc'), 120 | 1 121 | WHERE NOT EXISTS 122 | ( 123 | -- There should not be any version of this grain state. 124 | SELECT 1 125 | FROM OrleansStorage 126 | WHERE 127 | GrainIdHash = _GrainIdHash AND _GrainIdHash IS NOT NULL 128 | AND GrainTypeHash = _GrainTypeHash AND _GrainTypeHash IS NOT NULL 129 | AND GrainIdN0 = _GrainIdN0 AND _GrainIdN0 IS NOT NULL 130 | AND GrainIdN1 = _GrainIdN1 AND _GrainIdN1 IS NOT NULL 131 | AND GrainTypeString = _GrainTypeString AND _GrainTypeString IS NOT NULL 132 | AND ((_GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString = _GrainIdExtensionString) OR _GrainIdExtensionString IS NULL AND GrainIdExtensionString IS NULL) 133 | AND ServiceId = _ServiceId AND _ServiceId IS NOT NULL 134 | ); 135 | 136 | GET DIAGNOSTICS RowCountVar = ROW_COUNT; 137 | IF RowCountVar > 0 138 | THEN 139 | _newGrainStateVersion := 1; 140 | END IF; 141 | END IF; 142 | 143 | RETURN QUERY SELECT _newGrainStateVersion AS NewGrainStateVersion; 144 | END 145 | 146 | $function$; 147 | 148 | INSERT INTO OrleansQuery(QueryKey, QueryText) 149 | VALUES 150 | ( 151 | 'WriteToStorageKey',' 152 | select * from WriteToStorage(@GrainIdHash, @GrainIdN0, @GrainIdN1, @GrainTypeHash, @GrainTypeString, @GrainIdExtensionString, @ServiceId, @GrainStateVersion, @PayloadBinary, @PayloadJson, CAST(@PayloadXml AS xml)); 153 | '); 154 | 155 | INSERT INTO OrleansQuery(QueryKey, QueryText) 156 | VALUES 157 | ( 158 | 'ReadFromStorageKey',' 159 | SELECT 160 | PayloadBinary, 161 | PayloadXml, 162 | PayloadJson, 163 | (now() at time zone ''utc''), 164 | Version 165 | FROM 166 | OrleansStorage 167 | WHERE 168 | GrainIdHash = @GrainIdHash 169 | AND GrainTypeHash = @GrainTypeHash AND @GrainTypeHash IS NOT NULL 170 | AND GrainIdN0 = @GrainIdN0 AND @GrainIdN0 IS NOT NULL 171 | AND GrainIdN1 = @GrainIdN1 AND @GrainIdN1 IS NOT NULL 172 | AND GrainTypeString = @GrainTypeString AND GrainTypeString IS NOT NULL 173 | AND ((@GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString = @GrainIdExtensionString) OR @GrainIdExtensionString IS NULL AND GrainIdExtensionString IS NULL) 174 | AND ServiceId = @ServiceId AND @ServiceId IS NOT NULL 175 | '); 176 | 177 | INSERT INTO OrleansQuery(QueryKey, QueryText) 178 | VALUES 179 | ( 180 | 'ClearStorageKey',' 181 | UPDATE OrleansStorage 182 | SET 183 | PayloadBinary = NULL, 184 | PayloadJson = NULL, 185 | PayloadXml = NULL, 186 | Version = Version + 1 187 | WHERE 188 | GrainIdHash = @GrainIdHash AND @GrainIdHash IS NOT NULL 189 | AND GrainTypeHash = @GrainTypeHash AND @GrainTypeHash IS NOT NULL 190 | AND GrainIdN0 = @GrainIdN0 AND @GrainIdN0 IS NOT NULL 191 | AND GrainIdN1 = @GrainIdN1 AND @GrainIdN1 IS NOT NULL 192 | AND GrainTypeString = @GrainTypeString AND @GrainTypeString IS NOT NULL 193 | AND ((@GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString IS NOT NULL AND GrainIdExtensionString = @GrainIdExtensionString) OR @GrainIdExtensionString IS NULL AND GrainIdExtensionString IS NULL) 194 | AND ServiceId = @ServiceId AND @ServiceId IS NOT NULL 195 | AND Version IS NOT NULL AND Version = @GrainStateVersion AND @GrainStateVersion IS NOT NULL 196 | Returning Version as NewGrainStateVersion 197 | '); 198 | -------------------------------------------------------------------------------- /Orleans.Tournament.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.32112.339 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9F5EA531-C4D9-4063-A782-CC0D4AD26844}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Silo", "src\Silo\Silo.csproj", "{D2A953E7-DB1B-465E-AEBA-EEF188379B0E}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_misc", "_misc", "{DA678CE1-A6E5-4F52-9486-10C1EEE2888D}" 11 | ProjectSection(SolutionItems) = preProject 12 | kind-cluster.yaml = kind-cluster.yaml 13 | Makefile = Makefile 14 | readme.md = readme.md 15 | EndProjectSection 16 | EndProject 17 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "API", "src\API\API.csproj", "{4407E188-9BCD-4AF8-B21B-BF4BC55CFFC7}" 18 | EndProject 19 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_postgres", "_postgres", "{79862966-AD18-4425-BEA0-3E8B61D48E0B}" 20 | ProjectSection(SolutionItems) = preProject 21 | postgres\01_orleans_clustering_table.sql = postgres\01_orleans_clustering_table.sql 22 | postgres\02_orleans_clustering_data.sql = postgres\02_orleans_clustering_data.sql 23 | postgres\03_orleans_persistence.sql = postgres\03_orleans_persistence.sql 24 | postgres\04_read_schema.sql = postgres\04_read_schema.sql 25 | postgres\05_auth_schema.sql = postgres\05_auth_schema.sql 26 | postgres\Dockerfile = postgres\Dockerfile 27 | EndProjectSection 28 | EndProject 29 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_kubernetes", "_kubernetes", "{CDFC1C35-F669-4397-9543-F4500376CF56}" 30 | ProjectSection(SolutionItems) = preProject 31 | kubernetes\api-config-maps.yaml = kubernetes\api-config-maps.yaml 32 | kubernetes\api-deployment.yaml = kubernetes\api-deployment.yaml 33 | kubernetes\api-service.yaml = kubernetes\api-service.yaml 34 | kubernetes\postgres-deployment.yaml = kubernetes\postgres-deployment.yaml 35 | kubernetes\postgres-service.yaml = kubernetes\postgres-service.yaml 36 | kubernetes\silo-config-maps.yaml = kubernetes\silo-config-maps.yaml 37 | kubernetes\silo-deployment.yaml = kubernetes\silo-deployment.yaml 38 | kubernetes\silo-service.yaml = kubernetes\silo-service.yaml 39 | EndProjectSection 40 | EndProject 41 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{7566F4C4-F404-4350-9FC9-3C7EF042720B}" 42 | EndProject 43 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "application", "application", "{3AC1680A-85E5-4E50-9ACF-03284A0A5B39}" 44 | EndProject 45 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Projections", "src\Projections\Projections.csproj", "{19C0E3F7-419E-4AA3-B48D-C4655D345E9F}" 46 | EndProject 47 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSockets", "src\WebSockets\WebSockets.csproj", "{333D1632-ACF8-485B-A110-76F4F18DB277}" 48 | EndProject 49 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.Abstractions", "src\Domain.Abstractions\Domain.Abstractions.csproj", "{3974D7FF-A47C-40D7-9B9D-F1ABB983F1C2}" 50 | EndProject 51 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "API.Identity", "src\API.Identity\API.Identity.csproj", "{EBEE5B9C-6AD2-45D4-B847-40CE3C800221}" 52 | EndProject 53 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4281688B-8FF3-4CB8-8623-068E13EE97EB}" 54 | EndProject 55 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Silo.Dashboard", "src\Silo.Dashboard\Silo.Dashboard.csproj", "{BA428209-2DA2-401D-8475-BE4CFDF3C3C4}" 56 | EndProject 57 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{1EEF9340-E207-46D2-8935-F534F4142EA6}" 58 | EndProject 59 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.Tests", "tests\Domain.Tests\Domain.Tests.csproj", "{6B5166F4-5B7D-4046-B6AB-5167B021B7A0}" 60 | EndProject 61 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{5EB73322-1D5E-4D93-93AF-C8B912D75EF9}" 62 | ProjectSection(SolutionItems) = preProject 63 | img\projections.drawio.png = img\projections.drawio.png 64 | img\websockets.drawio.png = img\websockets.drawio.png 65 | EndProjectSection 66 | EndProject 67 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "utils", "utils", "{5153AECB-4CC8-4889-B98D-FF828D5CE4E4}" 68 | EndProject 69 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSockets.Client", "utils\WebSockets.Client\WebSockets.Client.csproj", "{CDE89AE1-1A06-4ECB-8C73-31450FADDC3A}" 70 | EndProject 71 | Global 72 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 73 | Debug|Any CPU = Debug|Any CPU 74 | Release|Any CPU = Release|Any CPU 75 | EndGlobalSection 76 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 77 | {D2A953E7-DB1B-465E-AEBA-EEF188379B0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 78 | {D2A953E7-DB1B-465E-AEBA-EEF188379B0E}.Debug|Any CPU.Build.0 = Debug|Any CPU 79 | {D2A953E7-DB1B-465E-AEBA-EEF188379B0E}.Release|Any CPU.ActiveCfg = Release|Any CPU 80 | {D2A953E7-DB1B-465E-AEBA-EEF188379B0E}.Release|Any CPU.Build.0 = Release|Any CPU 81 | {4407E188-9BCD-4AF8-B21B-BF4BC55CFFC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 82 | {4407E188-9BCD-4AF8-B21B-BF4BC55CFFC7}.Debug|Any CPU.Build.0 = Debug|Any CPU 83 | {4407E188-9BCD-4AF8-B21B-BF4BC55CFFC7}.Release|Any CPU.ActiveCfg = Release|Any CPU 84 | {4407E188-9BCD-4AF8-B21B-BF4BC55CFFC7}.Release|Any CPU.Build.0 = Release|Any CPU 85 | {19C0E3F7-419E-4AA3-B48D-C4655D345E9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 86 | {19C0E3F7-419E-4AA3-B48D-C4655D345E9F}.Debug|Any CPU.Build.0 = Debug|Any CPU 87 | {19C0E3F7-419E-4AA3-B48D-C4655D345E9F}.Release|Any CPU.ActiveCfg = Release|Any CPU 88 | {19C0E3F7-419E-4AA3-B48D-C4655D345E9F}.Release|Any CPU.Build.0 = Release|Any CPU 89 | {333D1632-ACF8-485B-A110-76F4F18DB277}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 90 | {333D1632-ACF8-485B-A110-76F4F18DB277}.Debug|Any CPU.Build.0 = Debug|Any CPU 91 | {333D1632-ACF8-485B-A110-76F4F18DB277}.Release|Any CPU.ActiveCfg = Release|Any CPU 92 | {333D1632-ACF8-485B-A110-76F4F18DB277}.Release|Any CPU.Build.0 = Release|Any CPU 93 | {3974D7FF-A47C-40D7-9B9D-F1ABB983F1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 94 | {3974D7FF-A47C-40D7-9B9D-F1ABB983F1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU 95 | {3974D7FF-A47C-40D7-9B9D-F1ABB983F1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU 96 | {3974D7FF-A47C-40D7-9B9D-F1ABB983F1C2}.Release|Any CPU.Build.0 = Release|Any CPU 97 | {EBEE5B9C-6AD2-45D4-B847-40CE3C800221}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 98 | {EBEE5B9C-6AD2-45D4-B847-40CE3C800221}.Debug|Any CPU.Build.0 = Debug|Any CPU 99 | {EBEE5B9C-6AD2-45D4-B847-40CE3C800221}.Release|Any CPU.ActiveCfg = Release|Any CPU 100 | {EBEE5B9C-6AD2-45D4-B847-40CE3C800221}.Release|Any CPU.Build.0 = Release|Any CPU 101 | {BA428209-2DA2-401D-8475-BE4CFDF3C3C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 102 | {BA428209-2DA2-401D-8475-BE4CFDF3C3C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 103 | {BA428209-2DA2-401D-8475-BE4CFDF3C3C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 104 | {BA428209-2DA2-401D-8475-BE4CFDF3C3C4}.Release|Any CPU.Build.0 = Release|Any CPU 105 | {1EEF9340-E207-46D2-8935-F534F4142EA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 106 | {1EEF9340-E207-46D2-8935-F534F4142EA6}.Debug|Any CPU.Build.0 = Debug|Any CPU 107 | {1EEF9340-E207-46D2-8935-F534F4142EA6}.Release|Any CPU.ActiveCfg = Release|Any CPU 108 | {1EEF9340-E207-46D2-8935-F534F4142EA6}.Release|Any CPU.Build.0 = Release|Any CPU 109 | {6B5166F4-5B7D-4046-B6AB-5167B021B7A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 110 | {6B5166F4-5B7D-4046-B6AB-5167B021B7A0}.Debug|Any CPU.Build.0 = Debug|Any CPU 111 | {6B5166F4-5B7D-4046-B6AB-5167B021B7A0}.Release|Any CPU.ActiveCfg = Release|Any CPU 112 | {6B5166F4-5B7D-4046-B6AB-5167B021B7A0}.Release|Any CPU.Build.0 = Release|Any CPU 113 | {CDE89AE1-1A06-4ECB-8C73-31450FADDC3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 114 | {CDE89AE1-1A06-4ECB-8C73-31450FADDC3A}.Debug|Any CPU.Build.0 = Debug|Any CPU 115 | {CDE89AE1-1A06-4ECB-8C73-31450FADDC3A}.Release|Any CPU.ActiveCfg = Release|Any CPU 116 | {CDE89AE1-1A06-4ECB-8C73-31450FADDC3A}.Release|Any CPU.Build.0 = Release|Any CPU 117 | EndGlobalSection 118 | GlobalSection(SolutionProperties) = preSolution 119 | HideSolutionNode = FALSE 120 | EndGlobalSection 121 | GlobalSection(NestedProjects) = preSolution 122 | {D2A953E7-DB1B-465E-AEBA-EEF188379B0E} = {3AC1680A-85E5-4E50-9ACF-03284A0A5B39} 123 | {4407E188-9BCD-4AF8-B21B-BF4BC55CFFC7} = {3AC1680A-85E5-4E50-9ACF-03284A0A5B39} 124 | {7566F4C4-F404-4350-9FC9-3C7EF042720B} = {9F5EA531-C4D9-4063-A782-CC0D4AD26844} 125 | {3AC1680A-85E5-4E50-9ACF-03284A0A5B39} = {9F5EA531-C4D9-4063-A782-CC0D4AD26844} 126 | {19C0E3F7-419E-4AA3-B48D-C4655D345E9F} = {7566F4C4-F404-4350-9FC9-3C7EF042720B} 127 | {333D1632-ACF8-485B-A110-76F4F18DB277} = {7566F4C4-F404-4350-9FC9-3C7EF042720B} 128 | {3974D7FF-A47C-40D7-9B9D-F1ABB983F1C2} = {9F5EA531-C4D9-4063-A782-CC0D4AD26844} 129 | {EBEE5B9C-6AD2-45D4-B847-40CE3C800221} = {3AC1680A-85E5-4E50-9ACF-03284A0A5B39} 130 | {BA428209-2DA2-401D-8475-BE4CFDF3C3C4} = {3AC1680A-85E5-4E50-9ACF-03284A0A5B39} 131 | {1EEF9340-E207-46D2-8935-F534F4142EA6} = {9F5EA531-C4D9-4063-A782-CC0D4AD26844} 132 | {6B5166F4-5B7D-4046-B6AB-5167B021B7A0} = {4281688B-8FF3-4CB8-8623-068E13EE97EB} 133 | {5EB73322-1D5E-4D93-93AF-C8B912D75EF9} = {DA678CE1-A6E5-4F52-9486-10C1EEE2888D} 134 | {CDE89AE1-1A06-4ECB-8C73-31450FADDC3A} = {5153AECB-4CC8-4889-B98D-FF828D5CE4E4} 135 | EndGlobalSection 136 | GlobalSection(ExtensibilityGlobals) = postSolution 137 | SolutionGuid = {C2CAF119-A4CB-4DE8-9F3C-CFD9D8757724} 138 | EndGlobalSection 139 | EndGlobal 140 | -------------------------------------------------------------------------------- /postgres/02_orleans_clustering.sql: -------------------------------------------------------------------------------- 1 | -- https://github.com/dotnet/orleans/blob/3.x/src/AdoNet/Orleans.Clustering.AdoNet/PostgreSQL-Clustering.sql 2 | -- For each deployment, there will be only one (active) membership version table version column which will be updated periodically. 3 | CREATE TABLE OrleansMembershipVersionTable 4 | ( 5 | DeploymentId varchar(150) NOT NULL, 6 | Timestamp timestamptz(3) NOT NULL DEFAULT now(), 7 | Version integer NOT NULL DEFAULT 0, 8 | 9 | CONSTRAINT PK_OrleansMembershipVersionTable_DeploymentId PRIMARY KEY(DeploymentId) 10 | ); 11 | 12 | -- Every silo instance has a row in the membership table. 13 | CREATE TABLE OrleansMembershipTable 14 | ( 15 | DeploymentId varchar(150) NOT NULL, 16 | Address varchar(45) NOT NULL, 17 | Port integer NOT NULL, 18 | Generation integer NOT NULL, 19 | SiloName varchar(150) NOT NULL, 20 | HostName varchar(150) NOT NULL, 21 | Status integer NOT NULL, 22 | ProxyPort integer NULL, 23 | SuspectTimes varchar(8000) NULL, 24 | StartTime timestamptz(3) NOT NULL, 25 | IAmAliveTime timestamptz(3) NOT NULL, 26 | 27 | CONSTRAINT PK_MembershipTable_DeploymentId PRIMARY KEY(DeploymentId, Address, Port, Generation), 28 | CONSTRAINT FK_MembershipTable_MembershipVersionTable_DeploymentId FOREIGN KEY (DeploymentId) REFERENCES OrleansMembershipVersionTable (DeploymentId) 29 | ); 30 | 31 | CREATE FUNCTION update_i_am_alive_time( 32 | deployment_id OrleansMembershipTable.DeploymentId%TYPE, 33 | address_arg OrleansMembershipTable.Address%TYPE, 34 | port_arg OrleansMembershipTable.Port%TYPE, 35 | generation_arg OrleansMembershipTable.Generation%TYPE, 36 | i_am_alive_time OrleansMembershipTable.IAmAliveTime%TYPE) 37 | RETURNS void AS 38 | $func$ 39 | BEGIN 40 | -- This is expected to never fail by Orleans, so return value 41 | -- is not needed nor is it checked. 42 | UPDATE OrleansMembershipTable as d 43 | SET 44 | IAmAliveTime = i_am_alive_time 45 | WHERE 46 | d.DeploymentId = deployment_id AND deployment_id IS NOT NULL 47 | AND d.Address = address_arg AND address_arg IS NOT NULL 48 | AND d.Port = port_arg AND port_arg IS NOT NULL 49 | AND d.Generation = generation_arg AND generation_arg IS NOT NULL; 50 | END 51 | $func$ LANGUAGE plpgsql; 52 | 53 | INSERT INTO OrleansQuery(QueryKey, QueryText) 54 | VALUES 55 | ( 56 | 'UpdateIAmAlivetimeKey',' 57 | -- This is expected to never fail by Orleans, so return value 58 | -- is not needed nor is it checked. 59 | SELECT * from update_i_am_alive_time( 60 | @DeploymentId, 61 | @Address, 62 | @Port, 63 | @Generation, 64 | @IAmAliveTime 65 | ); 66 | '); 67 | 68 | CREATE FUNCTION insert_membership_version( 69 | DeploymentIdArg OrleansMembershipTable.DeploymentId%TYPE 70 | ) 71 | RETURNS TABLE(row_count integer) AS 72 | $func$ 73 | DECLARE 74 | RowCountVar int := 0; 75 | BEGIN 76 | 77 | BEGIN 78 | 79 | INSERT INTO OrleansMembershipVersionTable 80 | ( 81 | DeploymentId 82 | ) 83 | SELECT DeploymentIdArg 84 | ON CONFLICT (DeploymentId) DO NOTHING; 85 | 86 | GET DIAGNOSTICS RowCountVar = ROW_COUNT; 87 | 88 | ASSERT RowCountVar <> 0, 'no rows affected, rollback'; 89 | 90 | RETURN QUERY SELECT RowCountVar; 91 | EXCEPTION 92 | WHEN assert_failure THEN 93 | RETURN QUERY SELECT RowCountVar; 94 | END; 95 | 96 | END 97 | $func$ LANGUAGE plpgsql; 98 | 99 | INSERT INTO OrleansQuery(QueryKey, QueryText) 100 | VALUES 101 | ( 102 | 'InsertMembershipVersionKey',' 103 | SELECT * FROM insert_membership_version( 104 | @DeploymentId 105 | ); 106 | '); 107 | 108 | CREATE FUNCTION insert_membership( 109 | DeploymentIdArg OrleansMembershipTable.DeploymentId%TYPE, 110 | AddressArg OrleansMembershipTable.Address%TYPE, 111 | PortArg OrleansMembershipTable.Port%TYPE, 112 | GenerationArg OrleansMembershipTable.Generation%TYPE, 113 | SiloNameArg OrleansMembershipTable.SiloName%TYPE, 114 | HostNameArg OrleansMembershipTable.HostName%TYPE, 115 | StatusArg OrleansMembershipTable.Status%TYPE, 116 | ProxyPortArg OrleansMembershipTable.ProxyPort%TYPE, 117 | StartTimeArg OrleansMembershipTable.StartTime%TYPE, 118 | IAmAliveTimeArg OrleansMembershipTable.IAmAliveTime%TYPE, 119 | VersionArg OrleansMembershipVersionTable.Version%TYPE) 120 | RETURNS TABLE(row_count integer) AS 121 | $func$ 122 | DECLARE 123 | RowCountVar int := 0; 124 | BEGIN 125 | 126 | BEGIN 127 | INSERT INTO OrleansMembershipTable 128 | ( 129 | DeploymentId, 130 | Address, 131 | Port, 132 | Generation, 133 | SiloName, 134 | HostName, 135 | Status, 136 | ProxyPort, 137 | StartTime, 138 | IAmAliveTime 139 | ) 140 | SELECT 141 | DeploymentIdArg, 142 | AddressArg, 143 | PortArg, 144 | GenerationArg, 145 | SiloNameArg, 146 | HostNameArg, 147 | StatusArg, 148 | ProxyPortArg, 149 | StartTimeArg, 150 | IAmAliveTimeArg 151 | ON CONFLICT (DeploymentId, Address, Port, Generation) DO 152 | NOTHING; 153 | 154 | 155 | GET DIAGNOSTICS RowCountVar = ROW_COUNT; 156 | 157 | UPDATE OrleansMembershipVersionTable 158 | SET 159 | Timestamp = now(), 160 | Version = Version + 1 161 | WHERE 162 | DeploymentId = DeploymentIdArg AND DeploymentIdArg IS NOT NULL 163 | AND Version = VersionArg AND VersionArg IS NOT NULL 164 | AND RowCountVar > 0; 165 | 166 | GET DIAGNOSTICS RowCountVar = ROW_COUNT; 167 | 168 | ASSERT RowCountVar <> 0, 'no rows affected, rollback'; 169 | 170 | 171 | RETURN QUERY SELECT RowCountVar; 172 | EXCEPTION 173 | WHEN assert_failure THEN 174 | RETURN QUERY SELECT RowCountVar; 175 | END; 176 | 177 | END 178 | $func$ LANGUAGE plpgsql; 179 | 180 | INSERT INTO OrleansQuery(QueryKey, QueryText) 181 | VALUES 182 | ( 183 | 'InsertMembershipKey',' 184 | SELECT * FROM insert_membership( 185 | @DeploymentId, 186 | @Address, 187 | @Port, 188 | @Generation, 189 | @SiloName, 190 | @HostName, 191 | @Status, 192 | @ProxyPort, 193 | @StartTime, 194 | @IAmAliveTime, 195 | @Version 196 | ); 197 | '); 198 | 199 | CREATE FUNCTION update_membership( 200 | DeploymentIdArg OrleansMembershipTable.DeploymentId%TYPE, 201 | AddressArg OrleansMembershipTable.Address%TYPE, 202 | PortArg OrleansMembershipTable.Port%TYPE, 203 | GenerationArg OrleansMembershipTable.Generation%TYPE, 204 | StatusArg OrleansMembershipTable.Status%TYPE, 205 | SuspectTimesArg OrleansMembershipTable.SuspectTimes%TYPE, 206 | IAmAliveTimeArg OrleansMembershipTable.IAmAliveTime%TYPE, 207 | VersionArg OrleansMembershipVersionTable.Version%TYPE 208 | ) 209 | RETURNS TABLE(row_count integer) AS 210 | $func$ 211 | DECLARE 212 | RowCountVar int := 0; 213 | BEGIN 214 | 215 | BEGIN 216 | 217 | UPDATE OrleansMembershipVersionTable 218 | SET 219 | Timestamp = now(), 220 | Version = Version + 1 221 | WHERE 222 | DeploymentId = DeploymentIdArg AND DeploymentIdArg IS NOT NULL 223 | AND Version = VersionArg AND VersionArg IS NOT NULL; 224 | 225 | 226 | GET DIAGNOSTICS RowCountVar = ROW_COUNT; 227 | 228 | UPDATE OrleansMembershipTable 229 | SET 230 | Status = StatusArg, 231 | SuspectTimes = SuspectTimesArg, 232 | IAmAliveTime = IAmAliveTimeArg 233 | WHERE 234 | DeploymentId = DeploymentIdArg AND DeploymentIdArg IS NOT NULL 235 | AND Address = AddressArg AND AddressArg IS NOT NULL 236 | AND Port = PortArg AND PortArg IS NOT NULL 237 | AND Generation = GenerationArg AND GenerationArg IS NOT NULL 238 | AND RowCountVar > 0; 239 | 240 | 241 | GET DIAGNOSTICS RowCountVar = ROW_COUNT; 242 | 243 | ASSERT RowCountVar <> 0, 'no rows affected, rollback'; 244 | 245 | 246 | RETURN QUERY SELECT RowCountVar; 247 | EXCEPTION 248 | WHEN assert_failure THEN 249 | RETURN QUERY SELECT RowCountVar; 250 | END; 251 | 252 | END 253 | $func$ LANGUAGE plpgsql; 254 | 255 | INSERT INTO OrleansQuery(QueryKey, QueryText) 256 | VALUES 257 | ( 258 | 'UpdateMembershipKey',' 259 | SELECT * FROM update_membership( 260 | @DeploymentId, 261 | @Address, 262 | @Port, 263 | @Generation, 264 | @Status, 265 | @SuspectTimes, 266 | @IAmAliveTime, 267 | @Version 268 | ); 269 | '); 270 | 271 | INSERT INTO OrleansQuery(QueryKey, QueryText) 272 | VALUES 273 | ( 274 | 'MembershipReadRowKey',' 275 | SELECT 276 | v.DeploymentId, 277 | m.Address, 278 | m.Port, 279 | m.Generation, 280 | m.SiloName, 281 | m.HostName, 282 | m.Status, 283 | m.ProxyPort, 284 | m.SuspectTimes, 285 | m.StartTime, 286 | m.IAmAliveTime, 287 | v.Version 288 | FROM 289 | OrleansMembershipVersionTable v 290 | -- This ensures the version table will returned even if there is no matching membership row. 291 | LEFT OUTER JOIN OrleansMembershipTable m ON v.DeploymentId = m.DeploymentId 292 | AND Address = @Address AND @Address IS NOT NULL 293 | AND Port = @Port AND @Port IS NOT NULL 294 | AND Generation = @Generation AND @Generation IS NOT NULL 295 | WHERE 296 | v.DeploymentId = @DeploymentId AND @DeploymentId IS NOT NULL; 297 | '); 298 | 299 | INSERT INTO OrleansQuery(QueryKey, QueryText) 300 | VALUES 301 | ( 302 | 'MembershipReadAllKey',' 303 | SELECT 304 | v.DeploymentId, 305 | m.Address, 306 | m.Port, 307 | m.Generation, 308 | m.SiloName, 309 | m.HostName, 310 | m.Status, 311 | m.ProxyPort, 312 | m.SuspectTimes, 313 | m.StartTime, 314 | m.IAmAliveTime, 315 | v.Version 316 | FROM 317 | OrleansMembershipVersionTable v LEFT OUTER JOIN OrleansMembershipTable m 318 | ON v.DeploymentId = m.DeploymentId 319 | WHERE 320 | v.DeploymentId = @DeploymentId AND @DeploymentId IS NOT NULL; 321 | '); 322 | 323 | INSERT INTO OrleansQuery(QueryKey, QueryText) 324 | VALUES 325 | ( 326 | 'DeleteMembershipTableEntriesKey',' 327 | DELETE FROM OrleansMembershipTable 328 | WHERE DeploymentId = @DeploymentId AND @DeploymentId IS NOT NULL; 329 | DELETE FROM OrleansMembershipVersionTable 330 | WHERE DeploymentId = @DeploymentId AND @DeploymentId IS NOT NULL; 331 | '); 332 | 333 | INSERT INTO OrleansQuery(QueryKey, QueryText) 334 | VALUES 335 | ( 336 | 'GatewaysQueryKey',' 337 | SELECT 338 | Address, 339 | ProxyPort, 340 | Generation 341 | FROM 342 | OrleansMembershipTable 343 | WHERE 344 | DeploymentId = @DeploymentId AND @DeploymentId IS NOT NULL 345 | AND Status = @Status AND @Status IS NOT NULL 346 | AND ProxyPort > 0; 347 | '); 348 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Tournament Demo Project 2 | 3 | This project is a backend oriented demo with websocket capabilities that relies heavily in the Actor Model Framework implementation of Microsoft Orleans. I gave myself the luxury of experimenting with some technologies and practices that might be useful on a real world distributed system. 4 | 5 | | **Table of Contents** | 6 | |---| 7 | | [Demo](#demo) | 8 | | [Domain](#domain) | 9 | | [DDD and immutability](#ddd-and-immutability) | 10 | | [CQRS, projections, event sourcing and eventual consistency](#cqrs-projections-event-sourcing-and-eventual-consistency) | 11 | | [Sagas](#sagas) | 12 | | [Async communication via websockets](#async-communication-via-websockets) | 13 | | [Authentication](#authentication) | 14 | | [Infrastructure (Kubernetes)](#infrastructure-kubernetes) | 15 | | [How to run it](#how-to-run-it) | 16 | | [Use the websockets client](#use-the-websockets-client) | 17 | | [Tests](#tests) | 18 | 19 | ## Demo 20 | 21 | Click to view it on Loom with sound. 22 | 23 | [![demo](https://cdn.loom.com/sessions/thumbnails/295a8f4dd71a474cb59b09388422deb9-with-play.gif)](https://www.loom.com/share/295a8f4dd71a474cb59b09388422deb9) 24 | 25 | ## Domain 26 | 27 | The domain contains two aggregate roots: `Teams` and `Tournaments`. 28 | 29 | A `Team` contains a name, a list of players (strings) and a list of tournament IDs on which the team is registered. 30 | 31 | A `Tournament` contains a name, a list of team IDs participating on it, and a `Fixture` that contains information of each different `Phase`: quarter finals, semi finals and finals. 32 | 33 | For a `Tournament` to start, the list of teams must have a count of 8. When it starts, the participating teams are shuffled randomly, and the `Fixture`'s quarter `Phase` is populated. 34 | 35 | A `Phase` is a container for a list of `Match`es that belong to the bracket. Each `Match` contains the `LocalTeamId`, the `AwayTeamId` and a `MatchResult`. In order not to use null values, the `MatchResult` is created with default values of 0 goals for each team and the property `Played` set to false. 36 | 37 | When the user intents the command `SetMatchResult` the correct result will be assigned to the corresponding `Match` on the corresponding `Phase`. When all the `Match`es on a `Phase` are played, the next `Phase` is generated. Meanwhile the quarter finals are generated by a random shuffle of the participating teams, the semifinals and the finals are not generated randomly but considering the results of previous brackets. 38 | 39 | ## DDD and immutability 40 | 41 | In Actor Model Framework, each Actor (instance of an aggregate root) contains a state that mutates when applying events. Orleans is not different, and each Grain (Actor) acts the same way. 42 | 43 | 1. Each `TournamentGrain` contains a `TournamentState`. 44 | 2. When a `TournamentGrain` receives a `Command`, it first checks if the command is valid business wise. 45 | 3. If the `Command` is valid, it will publish an `Event` informing what happened and modifying the `State` accordingly. 46 | 47 | The `TournamentState` is not coupled to Orleans framework. This means that the class is just a plain one that exposes methods that acts upon certain events. This allows for replayability of events and event sourcing as a whole. The state is then mutable, but that does not mean that the properties exposed by it are mutable as well. For example the `TournamentState` contains the `Fixture`. 48 | 49 | The `Fixture` is a value object implemented with C# records. This means that the methods exposed by it does not cause side effects and instead returns a new `Fixture` with the changes reflected. This enables an easier testability and predictability as all the methods are deterministic; you can execute a method a hundred times and the result is the same. 50 | 51 | ## CQRS, projections, event sourcing and eventual consistency 52 | 53 | One of the advantages of using an Actor Model Framework is the innate segregation of commands and queries. The commands are executed against a certain Grain and causes side effects, such as publishing an Event. However, the queries do not go against a Grain, instead they go against a read database that gets populated via projections. 54 | 55 | ![CQRS and projections.](/img/projections.drawio.png) 56 | 57 | This is, in fact, eventual consistency. There are some milliseconds between the source of truth changes (Grain) are reflected in the projections which one can query. This should not be a problem, but one needs to make sure to enable retry strategies in case the database is down while consuming an event from the stream, etc. 58 | 59 | In the scenario of a Grain being killed because of inactivity, or the Silo resetting and losing the in memory state of the Grain; each Grain can be recovered by applying the events in order: 60 | 61 | 1. `TournamentGrain` is not on memory. 62 | 2. Initialize the `TournamentGrain` by replaying the Events stored in the write database. 63 | 3. Executes the `SetMatchResult` command, etc. 64 | 65 | As you can see, the Grain will never use the read state as the source of truth, and instead it will rely on the event sourcing mechanism, to apply all the events again one after another until the state is up to date. 66 | 67 | ## Sagas 68 | 69 | `Saga` is a pattern for a distributed transaction that impacts more than one aggregate. In this case, lets look at the scenario when a `Team` wants to join a `Tournament`. 70 | 71 | 1. `TeamGrain` receives the command `CreateTeam` and the `TeamState` is initialized. 72 | 2. `TournamentGrain` receives the command `AddTeam` for the team created above, validations kick in: 73 | - Does the team exist? (Note: here you are not supposed to query your read database, as it not the source of truth, but actually send a message to the `TeamGrain`). 74 | - Does the tournament contain less than 8 teams? 75 | - Is the tournament already started? 76 | 3. If validations are ok, publishes the `TeamAdded` event. 77 | 78 | So far, the `TournamentGrain` is aware of the Team joining, but the `TeamGrain` is not aware of the participation on the Tournament. Enter the `TeamAddedSaga` Grain. 79 | 80 | 1. `TeamAddedSaga` subscribes implicitly to an Orleans Stream looking for `TeamAdded` events. 81 | 2. When it receives an event, it gets a reference for the `TeamGrain` with the corresponding ID. 82 | 3. Sends the `JoinTournament` command to the `TeamGrain`. 83 | 5. There are no extra validations needed, as everything was already validated before. 84 | 6. `TeamGrain` publishes the `TeamJoinedTournament` event. 85 | 86 | In this case there is not a rollback strategy implemented but these capabilities can definitely be handled with an "intermediate" Grain such as the Saga. 87 | 88 | ## Async communication via websockets 89 | 90 | Given the async nature of Orleans, the commands executed by a user should not return results on how does the resource looks after a command succeeded, not even IF the command succeeded. 91 | 92 | Instead the response will contain a `TraceId` as a GUID representation on the intent of the user. The user will receive a message via websockets indicating what happened for that `TraceId`. The frontend can reflect the changes accordingly. 93 | 94 | ![Websockets](/img/websockets.drawio.png) 95 | 96 | For the graph above the work, the user should establish a websocket connection with the API. The user will only get the results for those commands he/she invoked and not for everything happening in the system. 97 | 98 | ## Authentication 99 | 100 | As not all users should receive the results for all the commands, there is a need for distinguishing users. For this purpose a super simple authentication and authorization mechanism is implemented. If you need to implement auth in a real world app, please do not reinvent the wheel and check existing solutions such as AD or Okta. 101 | 102 | As mentioned before, each command executed will result on a `TraceId`, but internally there is also an `InvokerUserId` propagated that can also serve as an audit log for each event. 103 | 104 | The user will only get websocket messages for those events on which the `InvokerUserId` matches with the one on the JWT Token. 105 | 106 | **Fallback mechanism** 107 | 108 | It makes sense to have a fallback mechanism in case a websocket event did not arrive due to network issues. This could be a key value database where a user can search for a `TraceId` to find the response in order to react to a command executed. This is not currently implemented but will be added later. 109 | 110 | ## Infrastructure (Kubernetes) 111 | 112 | The solution consists on different projects that need to be executed at the same time: 113 | 114 | - API.Identity: Create user and generate token. 115 | - API: Entrypoint for the user to invoke commands, connects to the Orleans cluster as a client. 116 | - Silo: Orleans cluster, handles everything related to Orleans such as placement, streams, etc. 117 | - Silo.Dashboard: Orleans client that displays metrics and information. 118 | 119 | It also requires an instance of Postgres to be running and accepting connections. 120 | 121 | Taking in consideration that the API could be split in smaller pieces and the Silos count being scalable, I decided to create Kubernetes manifests to host this application. As it is not a common practice to have your own Kubernetes cluster locally, I also decided on using `Kind` which uses the docker engine to host a single node of Kubernetes locally. 122 | 123 | I am not going to enter into details on how this works, but just so you know that `Docker` and `Kind` are required to be installed to run this solution. Of course, one can choose to run locally instead, bear in mind that in that scenario you need to be able to connect to a Postgres instance as mentioned above. 124 | 125 | All the Kubernetes configuration files can be found on the `kubernetes` folder. **NOTE:** *There are also "secrets" on the mentioned folder, for a real world application please do not store your secrets in plain text on your repository*. 126 | 127 | ## How to run it 128 | 129 | As mentioned before, it is required to have `docker` installed as well as `kind` command line and `kubectl`. It is also suggested to have `make` support, so you don't have to manually go through the commands one by one. 130 | 131 | Initial bootstrap: 132 | 133 | ``` 134 | make run 135 | ``` 136 | 137 | Trigger rebuild of images and recreation of all the pods: 138 | 139 | ``` 140 | make restart 141 | ``` 142 | 143 | If you see something like this, everything should be up and running: 144 | 145 | ![running terminal](/img/running.png) 146 | 147 | > Use `kubectl` instead of `kb` as it is an alias that I am used to. 148 | 149 | Now you can access with the following endpoints: 150 | 151 | - [API (Port 30703)](http://localhost:30703/version) 152 | - [API Identity (Port 30704)](http://localhost:30704/version) 153 | - [Silo Dashboard (Port 30702)](http://localhost:30702) 154 | 155 | You can use the Postman collection below to try out the functionality as you can see on the demo video. This collection assigns values from previous responses so you don't have to modify the requests manually but just execute them in order. 156 | 157 | [![Run in Postman](https://run.pstmn.io/button.svg)](https://god.gw.postman.com/run-collection/7368453-13049026-2905-4a52-979d-4e619e8c806c?action=collection%2Ffork&collection-url=entityId%3D7368453-13049026-2905-4a52-979d-4e619e8c806c%26entityType%3Dcollection%26workspaceId%3De2e94935-58da-4b5e-9215-d61b78393014#?env%5BOrleans%20Tournament%5D=W3sia2V5IjoiQXBpSWRlbnRpdHlVcmwiLCJ2YWx1ZSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzA3MDQiLCJlbmFibGVkIjp0cnVlLCJzZXNzaW9uVmFsdWUiOiJodHRwOi8vbG9jYWxob3N0OjMwNzA0Iiwic2Vzc2lvbkluZGV4IjowfSx7ImtleSI6IkFwaVVybCIsInZhbHVlIjoiaHR0cDovL2xvY2FsaG9zdDozMDcwMyIsImVuYWJsZWQiOnRydWUsInNlc3Npb25WYWx1ZSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzA3MDMiLCJzZXNzaW9uSW5kZXgiOjF9LHsia2V5IjoiU2lsb0Rhc2hib2FyZFVybCIsInZhbHVlIjoiaHR0cDovL2xvY2FsaG9zdDozMDcwMiIsImVuYWJsZWQiOnRydWUsInNlc3Npb25WYWx1ZSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzA3MDIiLCJzZXNzaW9uSW5kZXgiOjJ9LHsia2V5IjoiU2lsb1VybCIsInZhbHVlIjoiaHR0cDovL2xvY2FsaG9zdDozMDcwMSIsImVuYWJsZWQiOnRydWUsInNlc3Npb25WYWx1ZSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzA3MDEiLCJzZXNzaW9uSW5kZXgiOjN9XQ==) 158 | 159 | ## Use the websockets client 160 | 161 | As this client is not containerized, `dotnet` sdk is required. After creating a user and getting the token, you can execute on the `utils/Websockets.Client` directory: 162 | 163 | ``` 164 | dotnet run 165 | ``` 166 | 167 | Paste your user token, and when choosing an environment type `K` and press enter key. A message saying connected should appear. Now you should be able to get the results of all the requests fired through the API using that user token. 168 | 169 | ## Tests 170 | 171 | Tests are not yet implemented, but you can see a simple example on the `Domain.Tests` project. I would like the unit tests just to cover the domain functionality: 172 | 173 | - Given a state, apply an event, assert the modified state. 174 | - Given a value object, invoke a method, assert the returned record. 175 | --------------------------------------------------------------------------------