Skip to content

Commit

Permalink
Merge branch 'next' into test
Browse files Browse the repository at this point in the history
  • Loading branch information
sei-bstein committed Oct 30, 2023
2 parents 5d10a9a + f3f13db commit 2babc0c
Show file tree
Hide file tree
Showing 16 changed files with 313 additions and 87 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ string userId
A.Fake<IChallengeSyncService>(),
fakeGameEngineService,
A.Fake<IGuidService>(),
A.Fake<IHttpContextAccessor>(),
A.Fake<IJsonService>(),
A.Fake<LinkGenerator>(),
A.Fake<ILogger<ChallengeService>>(),
A.Fake<IMapper>(),
A.Fake<IMediator>(),
Expand Down Expand Up @@ -230,9 +228,7 @@ string userId
A.Fake<IChallengeSyncService>(),
fakeGameEngineService,
A.Fake<IGuidService>(),
A.Fake<IHttpContextAccessor>(),
A.Fake<IJsonService>(),
A.Fake<LinkGenerator>(),
A.Fake<ILogger<ChallengeService>>(),
A.Fake<IMapper>(),
A.Fake<IMediator>(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Gameboard.Api.Common;
using Gameboard.Api.Features.GameEngine;
using Gameboard.Api.Features.Games;
using Gameboard.Api.Features.Games.External;
using Microsoft.Extensions.Logging;

namespace Gameboard.Api.Tests.Unit;

public class ExternalGameDeployBatchServiceTests
{
[Theory, GameboardAutoData]
public void BuildDeployBatches_WithFixedBatchSizeAndChallengeCount_ReturnsCorrectBatchCount(IFixture fixture)
{
// given a deploy request with 17 challenges and a batch size of 6
var challengeCount = 17;
var request = BuildFakeGameModeStartRequest(challengeCount, fixture);

// create sut and its options
var sut = new ExternalGameDeployBatchService
(
new CoreOptions { GameEngineDeployBatchSize = 6 },
A.Fake<IGameEngineService>(),
A.Fake<IGameHubBus>(),
A.Fake<ILogger<ExternalGameDeployBatchService>>()
);

// when batches are built
var result = sut.BuildDeployBatches(request);

// we expect three batches
result.Count().ShouldBe(3);
// and the last should have 5 tasks in it
result.Last().Count().ShouldBe(5);
}

[Theory, GameboardAutoData]
public void BuildDeployBatches_WithNoConfiguredBatchSize_ReturnsExpectedBatchCount(IFixture fixture)
{
// given a deploy request with any challenge count and no set batch size
var challengeCount = fixture.Create<int>();
var request = BuildFakeGameModeStartRequest(challengeCount, fixture);

var sut = new ExternalGameDeployBatchService
(
new CoreOptions { GameEngineDeployBatchSize = 0 },
A.Fake<IGameEngineService>(),
A.Fake<IGameHubBus>(),
A.Fake<ILogger<ExternalGameDeployBatchService>>()
);

// when batches are built
var result = sut.BuildDeployBatches(request);

// we expect one batch with length equal to the challenge count
result.Count().ShouldBe(1);
result.First().Count().ShouldBe(challengeCount);
}

private GameModeStartRequest BuildFakeGameModeStartRequest(int challengeCount, IFixture fixture)
{
var gameId = fixture.Create<string>();

var request = new GameModeStartRequest
{
GameId = fixture.Create<string>(),
State = new GameStartState
{
Game = new SimpleEntity { Id = gameId, Name = "game" },
ChallengesTotal = challengeCount,
Now = DateTimeOffset.UtcNow
},
Context = new GameModeStartRequestContext
{
SessionLengthMinutes = fixture.Create<int>(),
SpecIds = fixture.CreateMany<string>(challengeCount)
}
};

request.State.ChallengesCreated.AddRange(fixture.CreateMany<GameStartStateChallenge>(challengeCount).ToList());
return request;
}
}
2 changes: 1 addition & 1 deletion src/Gameboard.Api/Common/Services/FireAndForgetService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public void Fire(Func<IServiceScope, Task> doWork, CancellationToken cancellatio
try
{
using var scope = _serviceScopeFactory.CreateScope();
await doWork(_serviceScopeFactory.CreateScope());
await doWork(scope);
}
catch (Exception ex)
{
Expand Down
3 changes: 1 addition & 2 deletions src/Gameboard.Api/Features/Challenge/ChallengeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -296,16 +296,15 @@ public async Task<IEnumerable<GameEngineSectionSubmission>> Audit([FromRoute] st
public async Task<ConsoleSummary> GetConsole([FromBody] ConsoleRequest model)
{
await Validate(new Entity { Id = model.SessionId });

var isTeamMember = await ChallengeService.UserIsTeamPlayer(model.SessionId, Actor.Id);
Logger.LogInformation($"""Console access attempt on console "{model.Id}": User {Actor.Id}, roles {Actor.Role}, is team member? {isTeamMember}.""");

AuthorizeAny(
() => Actor.IsDirector,
() => Actor.IsObserver,
() => Actor.IsSupport,
() => isTeamMember
);

var result = await ChallengeService.GetConsole(model, isTeamMember.Equals(false));

if (isTeamMember)
Expand Down
5 changes: 5 additions & 0 deletions src/Gameboard.Api/Features/Challenge/ChallengeExceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ internal class GamespaceLimitReached : GameboardException
{
public GamespaceLimitReached(string gameId, string teamId) : base($""" Team(s) {teamId} are already at the maximum number of gamespaces permitted for game "{gameId}." """) { }
}

internal class GraderUrlResolutionError : GameboardException
{
public GraderUrlResolutionError() : base("Gameboard was unable to resolve a grader URL.") { }
}
83 changes: 83 additions & 0 deletions src/Gameboard.Api/Features/Challenge/ChallengeGraderUrlService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;

namespace Gameboard.Api.Features.Challenges;

public interface IChallengeGraderUrlService
{
public string BuildGraderUrl();
}

internal class ChallengeGraderUrlService : IChallengeGraderUrlService
{
private readonly HttpContext _httpContext;
private readonly ILogger<ChallengeGraderUrlService> _logger;
private readonly LinkGenerator _linkGenerator;
private readonly IServer _server;

public ChallengeGraderUrlService
(
IHttpContextAccessor httpContextAccessor,
LinkGenerator linkGenerator,
ILogger<ChallengeGraderUrlService> logger,
IServer server
)
{
_httpContext = httpContextAccessor.HttpContext;
_linkGenerator = linkGenerator;
_logger = logger;
_server = server;
}

public string BuildGraderUrl()
{
// prefer to get this from the current request, but it can be null if the call
// is coming from a background service that runs without an active request.
// weirdly, HttpContext has a RequestAborted property of type CancellationToken,
// but that property isn't accessible if the HttpContext has been disposed.
// So we try/catch. If the context is disposed, we'll get an ObjectDisposed
// exception, but we catch all exceptions anyway in case we can recover
// with the server.Features.Get strategy.
try
{
return string.Join
(
'/',
_linkGenerator.GetUriByAction
(
_httpContext,
"Grade",
"Challenge",
null,
_httpContext.Request.Scheme,
_httpContext.Request.Host,
_httpContext.Request.PathBase
)
);
}
catch (ObjectDisposedException ex)
{
_logger.LogInformation($"Attempt to build grader URL with HttpContextAccessor failed: {ex.GetType().Name} :: {ex.Message} Attempting backup strategy...");

if (_server is not null)
{
var addresses = _server.Features.Get<IServerAddressesFeature>();

var rootUrl = addresses.Addresses.FirstOrDefault(a => a.Contains("https"));
if (rootUrl.IsEmpty())
rootUrl = addresses.Addresses.FirstOrDefault();

if (!rootUrl.IsEmpty())
return $"{rootUrl}/challenge/grade";
}
}

throw new GraderUrlResolutionError();
}
}
29 changes: 2 additions & 27 deletions src/Gameboard.Api/Features/Challenge/ChallengeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@
using Gameboard.Api.Features.Teams;
using Gameboard.Api.Features.Scores;
using MediatR;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Http;

namespace Gameboard.Api.Services;

Expand All @@ -29,9 +27,7 @@ public partial class ChallengeService : _Service
private readonly IChallengeStore _challengeStore;
private readonly IGameEngineService _gameEngine;
private readonly IGuidService _guids;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IJsonService _jsonService;
private readonly LinkGenerator _linkGenerator;
private readonly IMapper _mapper;
private readonly IMediator _mediator;
private readonly IMemoryCache _memCache;
Expand All @@ -42,17 +38,16 @@ public partial class ChallengeService : _Service
private readonly IStore _store;
private readonly ITeamService _teamService;

public ChallengeService(
public ChallengeService
(
ConsoleActorMap actorMap,
CoreOptions coreOptions,
IChallengeStore challengeStore,
IChallengeDocsService challengeDocsService,
IChallengeSyncService challengeSyncService,
IGameEngineService gameEngine,
IGuidService guids,
IHttpContextAccessor httpContextAccessor,
IJsonService jsonService,
LinkGenerator linkGenerator,
ILogger<ChallengeService> logger,
IMapper mapper,
IMediator mediator,
Expand All @@ -69,9 +64,7 @@ ITeamService teamService
_challengeSyncService = challengeSyncService;
_gameEngine = gameEngine;
_guids = guids;
_httpContextAccessor = httpContextAccessor;
_jsonService = jsonService;
_linkGenerator = linkGenerator;
_mapper = mapper;
_mediator = mediator;
_memCache = memCache;
Expand All @@ -81,24 +74,6 @@ ITeamService teamService
_teamService = teamService;
}

public string BuildGraderUrl()
{
var request = _httpContextAccessor.HttpContext.Request;

return string.Join('/', new string[]
{
_linkGenerator.GetUriByAction
(
_httpContextAccessor.HttpContext,
"Grade",
"Challenge",
null,
request.Scheme,
request.Host,request.PathBase
)
});
}

public async Task<Challenge> GetOrCreate(NewChallenge model, string actorId, string graderUrl)
{
var entity = await _challengeStore.Load(model);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Gameboard.Api.Features.GameEngine;
using Microsoft.Extensions.Logging;

namespace Gameboard.Api.Features.Games.External;

public interface IExternalGameDeployBatchService
{
/// <summary>
/// Create batches of gamespace deploy requests from challenges.
///
/// An external game can have many challenges, and each challenge has an associated gamespace. For
/// each gamespace, Gameboard must issue a request to the game engine that causes it to deploy
/// the gamespace. Requesting all of these at once can cause issues with request timeouts, so
/// we optionally allow Gameboard sysadmins to configure a batch size appropriate to their game engine.
///
/// By default, this value is 4, meaning that upon start of an external game, Gameboard will issue
/// requests to the game engine in batches of 4 until all gamespaces have been deployed. Configure this
/// with the Core__GameEngineDeployBatchSize setting in Gameboard's helm chart.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
IEnumerable<IEnumerable<Task<GameEngineGameState>>> BuildDeployBatches(GameModeStartRequest request);
}

internal class ExternalGameDeployBatchService : IExternalGameDeployBatchService
{
private readonly CoreOptions _coreOptions;
private readonly IGameEngineService _gameEngine;
private readonly IGameHubBus _gameHubBus;
private readonly ILogger<ExternalGameDeployBatchService> _logger;

public ExternalGameDeployBatchService
(
CoreOptions coreOptions,
IGameEngineService gameEngine,
IGameHubBus gameHubBus,
ILogger<ExternalGameDeployBatchService> logger
)
{
_coreOptions = coreOptions;
_gameEngine = gameEngine;
_gameHubBus = gameHubBus;
_logger = logger;
}

public IEnumerable<IEnumerable<Task<GameEngineGameState>>> BuildDeployBatches(GameModeStartRequest request)
{
// first, create a task for each gamespace to be deployed
var gamespaceTasks = request.State.ChallengesCreated.Select(async c =>
{
_logger.LogInformation(message: $"""Starting {c.GameEngineType} gamespace for challenge "{c.Challenge.Id}" (teamId "{c.TeamId}")...""");
var challengeState = await _gameEngine.StartGamespace(new GameEngineGamespaceStartRequest
{
ChallengeId = c.Challenge.Id,
GameEngineType = c.GameEngineType
});

request.State.GamespacesStarted.Add(challengeState);
await _gameHubBus.SendExternalGameGamespacesDeployProgressChange(request.State);
_logger.LogInformation(message: $"""Gamespace started for challenge "{c.Challenge.Id}".""");

// keep the state given to us by the engine
return challengeState;
}).ToArray();

// if the setting isn't configured or is a nonsense value, just return all the tasks in one batch
if (_coreOptions.GameEngineDeployBatchSize <= 1)
return new IEnumerable<Task<GameEngineGameState>>[] { gamespaceTasks.ToArray() };

// otherwise, create batches of the appropriate size plus an additional batch for any leftovers
var batchList = new List<IEnumerable<Task<GameEngineGameState>>>();
List<Task<GameEngineGameState>> currentBatch = null;

for (var challengeIndex = 0; challengeIndex < gamespaceTasks.Length; challengeIndex++)
{
if (challengeIndex % _coreOptions.GameEngineDeployBatchSize == 0)
{
currentBatch = new List<Task<GameEngineGameState>>();
batchList.Add(currentBatch);
}

currentBatch.Add(gamespaceTasks[challengeIndex]);
}

return batchList;
}
}
Loading

0 comments on commit 2babc0c

Please sign in to comment.