Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'master' into 130-add-php-fpm-dashboard
Browse files Browse the repository at this point in the history
marein committed Apr 3, 2023

Verified

This commit was signed with the committer’s verified signature.
tisonkun tison
2 parents fbf2756 + 805c240 commit 96d49a3
Showing 26 changed files with 218 additions and 241 deletions.
4 changes: 3 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -18,7 +18,9 @@ APP_CHAT_RUN_MIGRATIONS=1
############################
# Connect Four Context #
############################
APP_CONNECT_FOUR_DOCTRINE_DBAL_URL=mysqli://root:password@mysql:3306/connect-four?persistent=1
APP_CONNECT_FOUR_DOCTRINE_DBAL_URL=mysqli://root:password@mysql:3306?persistent=1
APP_CONNECT_FOUR_DOCTRINE_DBAL_DATABASE=connect-four
APP_CONNECT_FOUR_DOCTRINE_DBAL_SHARDS=connect-four
APP_CONNECT_FOUR_RUN_MIGRATIONS=1
APP_CONNECT_FOUR_PREDIS_CLIENT_URL=redis://redis:6379?persistent=1

19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -308,18 +308,23 @@ In the next step we've to scale the databases. We've to divide this into two par
1. First we want to scale the databases for reading purposes.
Since there should not be a concurrency problem in this application, we can add replicas for the MySQL and Redis stores.
2. Then we want to scale the databases for writing purposes.
__Example for connect four__: I've put much effort to allow scaling the Connect Four context properly.
Because we use the CQRS pattern to decouple the queries that span multiple games,
such as counting running games or listing open games, we can ignore this queries in this case.
To fetch the command model we exclusively need a game id.
With this in mind, we can leverage a technique called
__Example for connect four__: This context already can be scaled-out by
[sharding](https://en.wikipedia.org/wiki/Shard_(database_architecture))
for this. The shard key is in our case the game id.
the database, since queries that span multiple games have been offloaded. How this is made possible is described
[in this section](#connect-four).
Only the game id is needed for the execution of the command model,
which is why it's well suited for the sharding key.
Sharding is done at the application level, more specifically in the
[repository](/src/ConnectFour/Port/Adapter/Persistence/Repository/DoctrineJsonGameRepository.php).
The application uses schema-based sharding and is aware of all existing logical shards,
while it's only aware of one physical connection. To actually forward queries to separate physical shards,
a proxy such as ProxySQL can be used. An example will be added with
[#118](https://github.com/marein/php-gaming-website/issues/118).
__Example for chat__: Currently there shouldn't be queries that span multiple chats.
To invoke a chat operation (either writing or reading) we exclusively need a chat id.
As in connect four context, we can use
[sharding](https://en.wikipedia.org/wiki/Shard_(database_architecture))
for the chat context.
for the chat context, where the chat id is the sharding key.

You may have seen that all contexts uses only one MySQL and one Redis instance.
This could be different for the production environment depending on the scale.
20 changes: 12 additions & 8 deletions bin/connect-four/onEntrypoint
Original file line number Diff line number Diff line change
@@ -4,12 +4,16 @@ set -e

if [ "${APP_CONNECT_FOUR_RUN_MIGRATIONS}" = "1" ]
then
bin/console doctrine:database:create \
--connection=connect_four \
--if-not-exists
bin/console doctrine:migrations:migrate \
--configuration=config/connect-four/migrations.yml \
--conn=connect_four \
--allow-no-migration \
--no-interaction
IFS=, read -ra values <<< "${APP_CONNECT_FOUR_DOCTRINE_DBAL_SHARDS}"
for value in "${values[@]}"
do
APP_CONNECT_FOUR_DOCTRINE_DBAL_DATABASE=${value} bin/console doctrine:database:create \
--connection=connect_four \
--if-not-exists
APP_CONNECT_FOUR_DOCTRINE_DBAL_DATABASE=${value} bin/console doctrine:migrations:migrate \
--configuration=config/connect-four/migrations.yml \
--conn=connect_four \
--allow-no-migration \
--no-interaction
done
fi
1 change: 1 addition & 0 deletions config/connect-four/config.yml
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ doctrine:
connections:
connect-four:
url: '%env(APP_CONNECT_FOUR_DOCTRINE_DBAL_URL)%'
dbname: '%env(APP_CONNECT_FOUR_DOCTRINE_DBAL_DATABASE)%'
server_version: 8.0
charset: utf8mb4
default_table_options:
9 changes: 1 addition & 8 deletions config/connect-four/services/command_bus.yml
Original file line number Diff line number Diff line change
@@ -12,17 +12,10 @@ services:
class: Gaming\Common\Bus\RetryBus
public: false
arguments:
- '@connect-four.doctrine-transactional-command-bus'
- '@connect-four.routing-command-bus'
- 3
- 'Gaming\Common\Domain\Exception\ConcurrencyException'

connect-four.doctrine-transactional-command-bus:
class: Gaming\Common\Port\Adapter\Bus\DoctrineTransactionalBus
public: false
arguments:
- '@connect-four.routing-command-bus'
- '@connect-four.doctrine-dbal'

# This is pretty ugly. We can use tags, or create this via a factory in php.
connect-four.routing-command-bus:
class: Gaming\Common\Bus\RoutingBus
16 changes: 6 additions & 10 deletions config/connect-four/services/game.yml
Original file line number Diff line number Diff line change
@@ -20,24 +20,20 @@ services:
- '@connect-four.doctrine-dbal'
- 'game'
- '@connect-four.domain-event-publisher'
- '@connect-four.event-store'
- '@connect-four.normalizer'
- !service
class: Gaming\Common\Sharding\Integration\Crc32ModShards
arguments:
- '%env(csv:APP_CONNECT_FOUR_DOCTRINE_DBAL_SHARDS)%'

connect-four.game-store:
class: Gaming\ConnectFour\Port\Adapter\Persistence\Repository\PredisGameStore
public: false
arguments:
- '@connect-four.predis'
- 'game.'
- '@connect-four.event-store-game-finder'

connect-four.game-finder:
alias: 'connect-four.game-store'

connect-four.event-store-game-finder:
class: Gaming\ConnectFour\Port\Adapter\Persistence\Repository\EventStoreGameFinder
public: false
arguments:
- '@connect-four.event-store'
- '@connect-four.game-repository'

connect-four.games-by-player-store:
class: Gaming\ConnectFour\Port\Adapter\Persistence\Repository\PredisGamesByPlayerStore
2 changes: 1 addition & 1 deletion config/connect-four/services/query_bus.yml
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ services:
class: Gaming\ConnectFour\Application\Game\Query\GameHandler
public: false
arguments:
- '@connect-four.game-finder'
- '@connect-four.game-store'

connect-four.query.games-by-player-handler:
class: Gaming\ConnectFour\Application\Game\Query\GamesByPlayerHandler
4 changes: 3 additions & 1 deletion docker-compose.production.yml
Original file line number Diff line number Diff line change
@@ -12,7 +12,9 @@ x-php-container:
APP_RABBIT_MQ_DSN: "amqp://guest:guest@rabbit-mq:5672?receive_method=basic_consume&qos_prefetch_count=10&heartbeat=60"
APP_CHAT_DOCTRINE_DBAL_URL: "mysqli://root:password@mysql:3306/chat?persistent=1"
APP_CHAT_RUN_MIGRATIONS: "1"
APP_CONNECT_FOUR_DOCTRINE_DBAL_URL: "mysqli://root:password@mysql:3306/connect-four?persistent=1"
APP_CONNECT_FOUR_DOCTRINE_DBAL_URL: "mysqli://root:password@mysql:3306?persistent=1"
APP_CONNECT_FOUR_DOCTRINE_DBAL_DATABASE: "connect-four"
APP_CONNECT_FOUR_DOCTRINE_DBAL_SHARDS: "connect-four"
APP_CONNECT_FOUR_RUN_MIGRATIONS: "1"
APP_CONNECT_FOUR_PREDIS_CLIENT_URL: "redis://redis:6379?persistent=1"
APP_IDENTITY_DOCTRINE_DBAL_URL: "mysqli://root:password@mysql:3306/identity?persistent=1"
11 changes: 11 additions & 0 deletions src/Common/Sharding/Exception/ShardingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Gaming\Common\Sharding\Exception;

use Exception;

final class ShardingException extends Exception
{
}
37 changes: 37 additions & 0 deletions src/Common/Sharding/Integration/Crc32ModShards.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Gaming\Common\Sharding\Integration;

use Gaming\Common\Sharding\Exception\ShardingException;
use Gaming\Common\Sharding\Shards;

/**
* This implementation causes problems in the event of re-sharding,
* because it's very likely that values will be reassigned to another shard.
* This can lead to more network calls until everything is back in place.
* It's therefore not suitable for every use case and should be used with caution.
* A better alternative would be to use a consistent hashing algorithm,
* which would reduce the likelihood of values being reassigned to another shard.
*/
final class Crc32ModShards implements Shards
{
/**
* @param string[] $shards
*
* @throws ShardingException
*/
public function __construct(
private readonly array $shards
) {
if (count($this->shards) === 0) {
throw new ShardingException('At least one shard must be specified.');
}
}

public function lookup(string $value): string
{
return $this->shards[crc32($value) % count($this->shards)];
}
}
15 changes: 15 additions & 0 deletions src/Common/Sharding/Shards.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Gaming\Common\Sharding;

use Gaming\Common\Sharding\Exception\ShardingException;

interface Shards
{
/**
* @throws ShardingException
*/
public function lookup(string $value): string;
}
10 changes: 5 additions & 5 deletions src/ConnectFour/Application/Game/Command/AbortHandler.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

namespace Gaming\ConnectFour\Application\Game\Command;

use Gaming\ConnectFour\Domain\Game\Game;
use Gaming\ConnectFour\Domain\Game\GameId;
use Gaming\ConnectFour\Domain\Game\Games;

@@ -18,10 +19,9 @@ public function __construct(Games $games)

public function __invoke(AbortCommand $command): void
{
$game = $this->games->get(GameId::fromString($command->gameId()));

$game->abort($command->playerId());

$this->games->save($game);
$this->games->update(
GameId::fromString($command->gameId()),
static fn(Game $game) => $game->abort($command->playerId())
);
}
}
10 changes: 5 additions & 5 deletions src/ConnectFour/Application/Game/Command/AssignChatHandler.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

namespace Gaming\ConnectFour\Application\Game\Command;

use Gaming\ConnectFour\Domain\Game\Game;
use Gaming\ConnectFour\Domain\Game\GameId;
use Gaming\ConnectFour\Domain\Game\Games;

@@ -18,10 +19,9 @@ public function __construct(Games $games)

public function __invoke(AssignChatCommand $command): void
{
$game = $this->games->get(GameId::fromString($command->gameId()));

$game->assignChat($command->chatId());

$this->games->save($game);
$this->games->update(
GameId::fromString($command->gameId()),
static fn(Game $game) => $game->assignChat($command->chatId())
);
}
}
10 changes: 5 additions & 5 deletions src/ConnectFour/Application/Game/Command/JoinHandler.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

namespace Gaming\ConnectFour\Application\Game\Command;

use Gaming\ConnectFour\Domain\Game\Game;
use Gaming\ConnectFour\Domain\Game\GameId;
use Gaming\ConnectFour\Domain\Game\Games;

@@ -18,10 +19,9 @@ public function __construct(Games $games)

public function __invoke(JoinCommand $command): void
{
$game = $this->games->get(GameId::fromString($command->gameId()));

$game->join($command->playerId());

$this->games->save($game);
$this->games->update(
GameId::fromString($command->gameId()),
static fn(Game $game) => $game->join($command->playerId())
);
}
}
10 changes: 5 additions & 5 deletions src/ConnectFour/Application/Game/Command/MoveHandler.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

namespace Gaming\ConnectFour\Application\Game\Command;

use Gaming\ConnectFour\Domain\Game\Game;
use Gaming\ConnectFour\Domain\Game\GameId;
use Gaming\ConnectFour\Domain\Game\Games;

@@ -18,10 +19,9 @@ public function __construct(Games $games)

public function __invoke(MoveCommand $command): void
{
$game = $this->games->get(GameId::fromString($command->gameId()));

$game->move($command->playerId(), $command->column());

$this->games->save($game);
$this->games->update(
GameId::fromString($command->gameId()),
static fn(Game $game) => $game->move($command->playerId(), $command->column())
);
}
}
2 changes: 1 addition & 1 deletion src/ConnectFour/Application/Game/Command/OpenHandler.php
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ public function __invoke(OpenCommand $command): string
$command->playerId()
);

$this->games->save($game);
$this->games->add($game);

return $game->id()->toString();
}
10 changes: 5 additions & 5 deletions src/ConnectFour/Application/Game/Command/ResignHandler.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

namespace Gaming\ConnectFour\Application\Game\Command;

use Gaming\ConnectFour\Domain\Game\Game;
use Gaming\ConnectFour\Domain\Game\GameId;
use Gaming\ConnectFour\Domain\Game\Games;

@@ -18,10 +19,9 @@ public function __construct(Games $games)

public function __invoke(ResignCommand $command): void
{
$game = $this->games->get(GameId::fromString($command->gameId()));

$game->resign($command->playerId());

$this->games->save($game);
$this->games->update(
GameId::fromString($command->gameId()),
static fn(Game $game) => $game->resign($command->playerId())
);
}
}

This file was deleted.

7 changes: 3 additions & 4 deletions src/ConnectFour/Application/Game/Query/GameHandler.php
Original file line number Diff line number Diff line change
@@ -4,9 +4,10 @@

namespace Gaming\ConnectFour\Application\Game\Query;

use Gaming\ConnectFour\Application\Game\Query\Exception\GameNotFoundException;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\Game;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\GameFinder;
use Gaming\ConnectFour\Domain\Game\Exception\GameNotFoundException;
use Gaming\ConnectFour\Domain\Game\GameId;

final class GameHandler
{
@@ -22,8 +23,6 @@ public function __construct(GameFinder $gameFinder)
*/
public function __invoke(GameQuery $query): Game
{
return $this->gameFinder->find(
$query->gameId()
);
return $this->gameFinder->find(GameId::fromString($query->gameId()));
}
}
Original file line number Diff line number Diff line change
@@ -4,12 +4,13 @@

namespace Gaming\ConnectFour\Application\Game\Query\Model\Game;

use Gaming\ConnectFour\Application\Game\Query\Exception\GameNotFoundException;
use Gaming\ConnectFour\Domain\Game\Exception\GameNotFoundException;
use Gaming\ConnectFour\Domain\Game\GameId;

interface GameFinder
{
/**
* @throws GameNotFoundException
*/
public function find(string $gameId): Game;
public function find(GameId $gameId): Game;
}
16 changes: 11 additions & 5 deletions src/ConnectFour/Domain/Game/Games.php
Original file line number Diff line number Diff line change
@@ -4,20 +4,26 @@

namespace Gaming\ConnectFour\Domain\Game;

use Closure;
use Gaming\Common\Domain\Exception\ConcurrencyException;
use Gaming\ConnectFour\Domain\Game\Exception\GameNotFoundException;

/**
* This repository is very persistence oriented to highlight the transactional scope (technical-wise).
* Games can be sharded across multiple physical databases, hence the transactional scope is applied
* in the repository and not in the application layer.
*/
interface Games
{
public function nextIdentity(): GameId;

/**
* @throws ConcurrencyException
*/
public function save(Game $game): void;
public function add(Game $game): void;

/**
* @params Closure(Game): void $operation
*
* @throws ConcurrencyException
* @throws GameNotFoundException
*/
public function get(GameId $gameId): Game;
public function update(GameId $gameId, Closure $operation): void;
}
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@
use Gaming\ConnectFour\Application\Game\Query\Model\Game\Game;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\GameStore;
use Gaming\ConnectFour\Domain\Game\Event\GameOpened;
use Gaming\ConnectFour\Domain\Game\GameId;
use Gaming\ConnectFour\Port\Adapter\Persistence\Repository\InMemoryCacheGameStore;

final class GameProjection implements StoredEventSubscriber
@@ -32,7 +33,7 @@ public function handle(StoredEvent $storedEvent): void

$game = match ($domainEvent::class) {
GameOpened::class => new Game(),
default => $this->gameStore->find($domainEvent->aggregateId())
default => $this->gameStore->find(GameId::fromString($domainEvent->aggregateId()))
};

$game->apply($domainEvent);
Original file line number Diff line number Diff line change
@@ -4,131 +4,112 @@

namespace Gaming\ConnectFour\Port\Adapter\Persistence\Repository;

use Closure;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Gaming\Common\Domain\DomainEventPublisher;
use Gaming\Common\Domain\Exception\ConcurrencyException;
use Gaming\Common\EventStore\EventStore;
use Gaming\Common\Normalizer\Normalizer;
use Gaming\Common\Sharding\Shards;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\Game as GameQueryModel;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\GameFinder;
use Gaming\ConnectFour\Domain\Game\Exception\GameNotFoundException;
use Gaming\ConnectFour\Domain\Game\Game;
use Gaming\ConnectFour\Domain\Game\GameId;
use Gaming\ConnectFour\Domain\Game\Games;

final class DoctrineJsonGameRepository implements Games
final class DoctrineJsonGameRepository implements Games, GameFinder
{
/**
* The map is used to store data for optimistic locking.
* This array gets never cleared so this can be a memory leak
* in a long running process.
*
* @var array<string, array<string, mixed>>
*/
private array $identityMap;

public function __construct(
private readonly Connection $connection,
private readonly string $tableName,
private readonly DomainEventPublisher $domainEventPublisher,
private readonly Normalizer $normalizer
private readonly EventStore $eventStore,
private readonly Normalizer $normalizer,
private readonly Shards $shards
) {
$this->identityMap = [];
}

public function nextIdentity(): GameId
{
return GameId::generate();
}

/**
* @throw ConcurrencyException
*/
public function save(Game $game): void
public function add(Game $game): void
{
$id = $game->id()->toString();
$this->domainEventPublisher->publish(
$game->flushDomainEvents()
);
$this->switchShard($game->id());

if (isset($this->identityMap[$id])) {
$this->update($id, $game);
} else {
$this->insert($id, $game);
}
$this->connection->transactional(function () use ($game) {
$this->domainEventPublisher->publish($game->flushDomainEvents());

$this->connection->insert(
$this->tableName,
['id' => $game->id()->toString(), 'aggregate' => $this->normalizeGame($game), 'version' => 1],
['id' => 'uuid', 'aggregate' => Types::JSON, 'version' => Types::INTEGER]
);
});
}

public function get(GameId $id): Game
public function update(GameId $gameId, Closure $operation): void
{
$builder = $this->connection->createQueryBuilder();

$row = $builder
->select('*')
->from($this->tableName, 't')
->where('t.id = :id')
->setParameter('id', $id->toString(), 'uuid')
->executeQuery()
->fetchAssociative();

if ($row === false) {
throw new GameNotFoundException();
}

$gameAsArray = json_decode($row['aggregate'], true, 512, JSON_THROW_ON_ERROR);

$this->registerAggregateId($id->toString(), (int)$row['version']);

return $this->normalizer->denormalize($gameAsArray, Game::class);
$this->switchShard($gameId);

$this->connection->transactional(function () use ($gameId, $operation) {
$id = $gameId->toString();
$row = $this->connection->fetchAssociative(
'SELECT * FROM ' . $this->tableName . ' g WHERE g.id = ?',
[$id],
['uuid']
) ?: throw new GameNotFoundException();

$game = $this->denormalizeGame($row['aggregate']);
$operation($game);

$this->domainEventPublisher->publish($game->flushDomainEvents());

$this->connection->update(
$this->tableName,
['aggregate' => $this->normalizeGame($game), 'version' => $row['version'] + 1],
['id' => $id, 'version' => $row['version']],
['id' => 'uuid', 'aggregate' => Types::JSON, 'version' => Types::INTEGER]
) ?: throw new ConcurrencyException();
});
}

/**
* @throws ConcurrencyException
*/
private function update(string $id, Game $game): void
public function find(GameId $gameId): GameQueryModel
{
$version = $this->identityMap[$id]['version'];

$result = $this->connection->update(
$this->tableName,
[
'aggregate' => $this->normalizer->normalize($game, Game::class),
'version' => $version + 1
],
['id' => $id, 'version' => $version],
[
'id' => 'uuid',
'aggregate' => 'json',
'version' => 'integer'
]
);
$this->switchShard($gameId);

$storedEvents = $this->eventStore->byAggregateId(
$gameId->toString()
) ?: throw new GameNotFoundException();

if ($result === 0) {
throw new ConcurrencyException();
$game = new GameQueryModel();
foreach ($storedEvents as $storedEvent) {
$game->apply($storedEvent->domainEvent());
}

$this->registerAggregateId($id, $version + 1);
return $game;
}

private function insert(string $id, Game $game): void
private function switchShard(GameId $gameId): void
{
$this->connection->insert(
$this->tableName,
[
'id' => $id,
'aggregate' => $this->normalizer->normalize($game, Game::class),
'version' => 1
],
[
'id' => 'uuid',
'aggregate' => 'json',
'version' => 'integer'
]
$this->connection->executeStatement(
'USE ' . $this->connection->quoteIdentifier($this->shards->lookup($gameId->toString()))
);
}

$this->registerAggregateId($id, 1);
private function normalizeGame(Game $game): mixed
{
return $this->normalizer->normalize($game, Game::class);
}

private function registerAggregateId(string $id, int $version): void
private function denormalizeGame(mixed $game): Game
{
$this->identityMap[$id] = [
'version' => $version
];
return $this->normalizer->denormalize(
json_decode($game, true, 512, JSON_THROW_ON_ERROR),
Game::class
);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@

use Gaming\ConnectFour\Application\Game\Query\Model\Game\Game;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\GameStore;
use Gaming\ConnectFour\Domain\Game\GameId;

final class InMemoryCacheGameStore implements GameStore
{
@@ -25,13 +26,9 @@ public function __construct(GameStore $gameStore, int $cacheSize)
$this->cachedGames = [];
}

public function find(string $gameId): Game
public function find(GameId $gameId): Game
{
if (array_key_exists($gameId, $this->cachedGames)) {
return $this->cachedGames[$gameId];
}

return $this->gameStore->find($gameId);
return $this->cachedGames[$gameId->toString()] ?? $this->gameStore->find($gameId);
}

public function save(Game $game): void
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@
use Gaming\ConnectFour\Application\Game\Query\Model\Game\Game;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\GameFinder;
use Gaming\ConnectFour\Application\Game\Query\Model\Game\GameStore;
use Gaming\ConnectFour\Domain\Game\GameId;
use Predis\ClientInterface;

/**
@@ -25,7 +26,7 @@ public function __construct(
) {
}

public function find(string $gameId): Game
public function find(GameId $gameId): Game
{
$serializedGame = $this->predis->get(
$this->storageKeyPrefix . $gameId

0 comments on commit 96d49a3

Please sign in to comment.