diff --git a/fakes/LoopFake.php b/fakes/LoopFake.php new file mode 100644 index 0000000..493fc54 --- /dev/null +++ b/fakes/LoopFake.php @@ -0,0 +1,89 @@ +timers[] = ['seconds' => $interval, 'callback' => $callback]; + } + + public function runTimers(?int $seconds = null) + { + $timers = $this->timers; + $this->timers = []; + + if ($seconds === null) { + foreach ($timers as $i => $timer) { + $timer['callback'](); + } + + return; + } + + foreach ($timers as $i => &$timer) { + $timer['seconds'] -= $seconds; + + if ($timer['seconds'] <= 0) { + $timer['callback'](); + unset($timers[$i]); + } + } + + $this->timers = [...$timers, ...$this->timers]; + } + + public function addPeriodicTimer($interval, $callback) + { + } + + public function cancelTimer(TimerInterface $timer) + { + } + + public function futureTick($listener) + { + } + + public function addSignal($signal, $listener) + { + } + + public function removeSignal($signal, $listener) + { + } + + public function run() + { + } + + public function stop() + { + } +} diff --git a/src/Orchestration/Orchestrator.php b/src/Orchestration/Orchestrator.php new file mode 100644 index 0000000..bacc0f5 --- /dev/null +++ b/src/Orchestration/Orchestrator.php @@ -0,0 +1,137 @@ + + */ + public function init(): PromiseInterface + { + return $this->gateway->getBot()->then(function (GatewayBot $bot) { + $this->totalShards = $this->totalShardsOverride ?? $bot->shards; + + $this->shards = range( + 0, + $this->totalShards - 1 + ); + + return $this->orchestrate($bot->session_start_limit); + }); + } + + /** + * @return PromiseInterface + */ + private function orchestrate(SessionStartLimit $sessionStartLimits): PromiseInterface + { + return $this->startSpawning( + $sessionStartLimits->remaining, + $sessionStartLimits->max_concurrency, + Carbon::createFromTimestamp($sessionStartLimits->reset_after), + )->then(function () use ($sessionStartLimits) { + $wait = max( + $sessionStartLimits->reset_after - (Carbon::now())->getTimestamp(), + 1 + ); + + return new Promise(function (callable $resolve) use ($wait) { + $this->loop->addTimer( + $wait, + fn () => $this->continueOrchestration()->then(fn () => $resolve(null)) + ); + }); + }); + } + + /** + * @return PromiseInterface + */ + private function continueOrchestration(): PromiseInterface + { + return new Promise(function (callable $resolve) { + if (!empty($this->shards)) { + return $this->gateway->getBot()->then( + fn (GatewayBot $bot) => $this->orchestrate($bot->session_start_limit) + ->then(fn () => $resolve(null)) + ); + } + }); + } + + /** + * @return PromiseInterface + */ + private function startSpawning(int $remaining, int $concurrency, DateTimeInterface $resetAfter): PromiseInterface + { + return new Promise(function (callable $resolve) use ($remaining, $concurrency, $resetAfter) { + $currentDate = Carbon::now(); + + if ($currentDate > $resetAfter) { + $resolve(null); + + return; + } + + if ($remaining === 0) { + $resolve(null); + return; + } + + $shardsToSpawn = $remaining > $concurrency + ? $concurrency + : $remaining; + + $remaining = max($remaining - $concurrency, 0); + + $shards = array_splice($this->shards, 0, $shardsToSpawn); + + $this->allowSpawning($shards); + + $this->loop->addTimer(self::CONCURRENCY_TIMESPAN_SECONDS, function () use ($remaining, $concurrency, $resetAfter, $resolve) { + $this->startSpawning($remaining, $concurrency, $resetAfter)->then(fn () => $resolve(null)); + }); + }); + } + + /** + * @param int[] $shards + */ + private function allowSpawning(array $shards): void + { + foreach ($shards as $shard) { + $this->emit(self::ALLOW_SPAWN, [$shard, $this->totalShards]); + } + } +} diff --git a/src/Parts/Gateway.php b/src/Parts/Gateway.php new file mode 100644 index 0000000..52f82cd --- /dev/null +++ b/src/Parts/Gateway.php @@ -0,0 +1,10 @@ +mapPromise( + $this->http->get( + Endpoint::GATEWAY + ), + PartsGateway::class, + ); + } + + public function getBot(): PromiseInterface + { + return $this->mapPromise( + $this->http->get( + Endpoint::GATEWAY_BOT + ), + GatewayBot::class, + ); + } +} diff --git a/src/Rest/Rest.php b/src/Rest/Rest.php index b45dacb..1278594 100644 --- a/src/Rest/Rest.php +++ b/src/Rest/Rest.php @@ -30,6 +30,7 @@ class Rest public readonly GlobalCommand $globalCommand; public readonly Webhook $webhook; public readonly Guild $guild; + public readonly Gateway $gateway; public function __construct(private Http $http, private DataMapper $dataMapper, private LoggerInterface $logger) { @@ -52,5 +53,6 @@ public function __construct(private Http $http, private DataMapper $dataMapper, $this->globalCommand = new GlobalCommand(...$args); $this->webhook = new Webhook(...$args); $this->guild = new Guild(...$args); + $this->gateway = new Gateway(...$args); } } diff --git a/tests/Orchestration/OrchestratorTest.php b/tests/Orchestration/OrchestratorTest.php new file mode 100644 index 0000000..caf58a6 --- /dev/null +++ b/tests/Orchestration/OrchestratorTest.php @@ -0,0 +1,285 @@ +shouldReceive() + ->getBot() + ->andReturnUsing(function () use (&$gatewayBots) { + $this->assertNotEmpty($gatewayBots, 'GatewayBot requested unexpectedly.'); + + return PromiseFake::get(array_shift($gatewayBots)); + }); + + $spawned = []; + $orchestrator->on(Orchestrator::ALLOW_SPAWN, function (int $shard, int $totalShards) use (&$spawned) { + $spawned[] = [$shard, $totalShards]; + }); + + $orchestrator->init(); + + foreach ($expectations as $expectation) { + switch ($expectation['type']) { + case self::EXPECT_SPAWN: { + $this->assertEquals($expectation['data'], $spawned); + $spawned = []; + break; + } + + case self::EXPECT_RUN_TIMERS: { + $loop->runTimers($expectation['data'] ?? null); + break; + } + } + } + + $this->assertEmpty($spawned); + } + + private static function getGatewayBot( + int $shards, + int $sessionStartLimitTotal, + int $sessionStartLimitRemaining, + DateTimeInterface $sessionStartLimitResetAfter, + int $sessionStartLimitConcurrency, + string $url = '::url::' + ): GatewayBot { + $gatewayBot = new GatewayBot(); + $gatewayBot->url = $url; + $gatewayBot->shards = $shards; + + $gatewayBot->session_start_limit = new SessionStartLimit(); + $gatewayBot->session_start_limit->total = $sessionStartLimitTotal; + $gatewayBot->session_start_limit->remaining = $sessionStartLimitRemaining; + $gatewayBot->session_start_limit->reset_after = $sessionStartLimitResetAfter->getTimestamp(); + $gatewayBot->session_start_limit->max_concurrency = $sessionStartLimitConcurrency; + + return $gatewayBot; + } + + public static function orchestrationDataProvider() + { + return [ + 'Ideal conditions, total % concurrency == 0' => [ + 'gatewayBots' => [ + self::getGatewayBot( + 15, + 15, + 15, + Carbon::now()->addMinutes(5), + 5, + ), + ], + 'expectations' => [ + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [0, 15], + [1, 15], + [2, 15], + [3, 15], + [4, 15], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [5, 15], + [6, 15], + [7, 15], + [8, 15], + [9, 15], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [10, 15], + [11, 15], + [12, 15], + [13, 15], + [14, 15], + ] + ], + ], + ], + + 'Less ideal conditions, total % concurrency == 3' => [ + 'gatewayBots' => [ + self::getGatewayBot( + 18, + 18, + 18, + Carbon::now()->addMinutes(5), + 5, + ), + ], + 'expectations' => [ + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [0, 18], + [1, 18], + [2, 18], + [3, 18], + [4, 18], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [5, 18], + [6, 18], + [7, 18], + [8, 18], + [9, 18], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [10, 18], + [11, 18], + [12, 18], + [13, 18], + [14, 18], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [15, 18], + [16, 18], + [17, 18], + ] + ], + ], + ], + + 'It spawns only to the limit of remaining before waiting for reset, and fetching new allowance' => [ + 'gatewayBots' => [ + self::getGatewayBot( + 28, + 28, + 15, + Carbon::createFromTimestamp(20), + 5, + ), + + self::getGatewayBot( + 28, + 28, + 15, + Carbon::createFromTimestamp(50), + 5, + ), + ], + 'expectations' => [ + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [0, 28], + [1, 28], + [2, 28], + [3, 28], + [4, 28], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [5, 28], + [6, 28], + [7, 28], + [8, 28], + [9, 28], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [10, 28], + [11, 28], + [12, 28], + [13, 28], + [14, 28], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + ['type' => self::EXPECT_RUN_TIMERS, 'data' => 15], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [] + ], + ['type' => self::EXPECT_RUN_TIMERS, 'data' => 10], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [15, 28], + [16, 28], + [17, 28], + [18, 28], + [19, 28], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [20, 28], + [21, 28], + [22, 28], + [23, 28], + [24, 28], + ] + ], + ['type' => self::EXPECT_RUN_TIMERS], + [ + 'type' => self::EXPECT_SPAWN, + 'data' => [ + [25, 28], + [26, 28], + [27, 28], + ] + ], + ], + ], + ]; + } +}