fix(data): serialize portfolio future reschedules

This commit is contained in:
2026-06-01 20:58:53 +03:00
parent a28b75dd5b
commit d762ecc377
7 changed files with 299 additions and 22 deletions
@@ -68,6 +68,17 @@
### Task 1: Add Portfolio Schema ### Task 1: Add Portfolio Schema
**Quality-review fix index**
- `d591e5e` `fix(data): protect portfolio publication invariant`
- `3c1a98b` `fix(data): harden portfolio publication concurrency`
- `76b3ff7` `fix(data): serialize portfolio publication validation`
- `6e7a0cb` `fix(data): enforce portfolio validation isolation`
- `f493836` `fix(data): reject stale portfolio trigger snapshots`
- `da0a306` `fix(data): enforce completed portfolio sessions`
- `a28b75d` `fix(data): align portfolio mutation lock order`
- Current fix cycle: `fix(data): serialize portfolio future reschedules`
**Files:** **Files:**
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs`
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs` - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs`
@@ -120,8 +131,9 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards(
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("SELECT s.scheduled_at INTO final_scheduled_at FROM sessions s WHERE s.id = NEW.id;", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal);
} }
``` ```
@@ -187,7 +199,7 @@ public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpubl
} }
``` ```
Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: save the `bot` project resource to a variable, expose its named port `8081` HTTP endpoint, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate. Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: use database resource name `.AddDatabase("gmrelaydb")`, save the `bot` project resource to a variable, expose its named port `8081` HTTP endpoint with `isProxied: false`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate without a proxy competing with its `HttpListener`.
- [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests** - [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests**
@@ -242,6 +254,12 @@ public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWit
[Fact] [Fact]
public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt() public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt()
[Fact]
public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt()
[Fact]
public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock()
[Fact] [Fact]
public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit()
@@ -262,7 +280,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s
public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit()
``` ```
The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A future reschedule must atomically unpublish a linked public card while preserving its first `published_at`. The `READ COMMITTED` concurrency scenarios must launch bounded tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. A session-delete versus future-reschedule race must use the common `sessions` then `portfolio_games` lock order, cover both first-session-lock orders through real blocking transactions, and finish with the card private and session deleted. The publish/reschedule race must finish with the future session committed and the card private. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including both draft-link deletion versus publication commit orders, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully. The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A final future reschedule must atomically unpublish linked public cards while preserving their first `published_at`; `past -> future -> past` in one transaction must leave the card public. Opposing-order batch reschedules must use an advisory test gate plus `pg_blocking_pids` observation with bounded timeouts, complete without card deadlock, and leave both cards private; do not rely on `pg_sleep` timing. The `READ COMMITTED` concurrency scenarios must launch bounded tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. A session-delete versus future-reschedule race must use the common `sessions` then `portfolio_games` lock order, cover both first-session-lock orders through real blocking transactions, and finish with the card private and session deleted. The publish/reschedule race must finish with the future session committed and the card private. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including both draft-link deletion versus publication commit orders, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully.
- [ ] **Step 3: Run the Task 1 tests to verify RED** - [ ] **Step 3: Run the Task 1 tests to verify RED**
@@ -467,7 +485,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending
WHERE moderation_status = 'Pending'; WHERE moderation_status = 'Pending';
``` ```
The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger atomically unpublishes linked public cards while preserving `published_at`; it updates the session before the card. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers use the same `sessions` then `portfolio_games` lock order: explicitly lock the target session row, unpublish linked cards, then delete the session. The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger re-reads the final session row, skips intermediate future values that end in the past, and for a final future value locks all currently public cards linked to any final-future session in `portfolio_games.id` order before one guarded unpublish update. This separate global row-lock pass avoids opposing batch order without adding the validator advisory lock before card locks. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers use the same `sessions` then `portfolio_games` lock order: explicitly lock the target session row, unpublish linked cards, then delete the session.
- [ ] **Step 5: Lock sessions before explicitly unpublishing linked cards in both session-deletion handlers** - [ ] **Step 5: Lock sessions before explicitly unpublishing linked cards in both session-deletion handlers**
@@ -503,7 +521,7 @@ Both handlers deliberately use `sessions` then `portfolio_games` locking before
Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization. Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization.
In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: save the `bot` project resource, add `.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health")`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator. In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: use `.AddDatabase("gmrelaydb")` to match application connection-string configuration, save the `bot` project resource, add `.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator or binding an Aspire proxy to the bot `HttpListener` port.
- [ ] **Step 6: Run the Task 1 tests to verify GREEN** - [ ] **Step 6: Run the Task 1 tests to verify GREEN**
@@ -513,13 +531,13 @@ Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests"
``` ```
Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after future reschedule, bounded `READ COMMITTED` publish/delete in both commit orders, publish/reschedule races, session-delete/reschedule serialization in both first-lock orders, and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including both draft-delete versus publish commit orders, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire HTTP health gating. Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after final future reschedule, preserved public state after `past -> future -> past`, opposing-order batch reschedules without card deadlock, bounded `READ COMMITTED` publish/delete in both commit orders, publish/reschedule races, session-delete/reschedule serialization in both first-lock orders, and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including both draft-delete versus publish commit orders, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire HTTP health gating with a non-proxied bot endpoint and matching `gmrelaydb` resource name.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```powershell ```powershell
git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs src/GmRelay.AppHost/Program.cs compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs src/GmRelay.AppHost/Program.cs compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs
git commit -m "fix(data): harden portfolio publication concurrency" git commit -m "fix(data): serialize portfolio future reschedules"
``` ```
--- ---
@@ -81,7 +81,7 @@ Application validation additionally requires at least one linked session, every
Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is rescheduled into the future, preserving the first `published_at`. Running that update at commit lets it re-check the current card row after a racing publication without taking the advisory lock before the row update. Session mutation paths use one row-lock order: `sessions` first, then linked `portfolio_games`. Normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. For a final future value it takes row locks for all currently public cards linked to any final-future session in `portfolio_games.id` order, then unpublishes the matching cards in one guarded update. The low-volume global pass gives batch reschedules one card-lock order without taking the publication validator advisory lock before card locks. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
### `portfolio_game_sessions` ### `portfolio_game_sessions`
@@ -336,7 +336,7 @@ Development configuration uses a local directory under the application content r
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user. The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate by explicitly exposing the bot project resource's port `8081` endpoint, attaching `.WithHttpHealthCheck("/health", endpointName: "health")`, and making its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource. The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource.
--- ---
@@ -356,7 +356,7 @@ Follow TDD for production changes.
### Schema And Contracts ### Schema And Contracts
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, session-first deletion locks, and the AppHost HTTP health gate. - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, session-first deletion locks, and the AppHost HTTP health gate.
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. - PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, opposing-order batch future reschedules without card deadlock using an observed advisory test gate instead of timing sleeps, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion.
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
- Existing showcase tests continue to assert the future-session catalog boundary. - Existing showcase tests continue to assert the future-session catalog boundary.
+2 -2
View File
@@ -2,12 +2,12 @@ var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres") var postgres = builder.AddPostgres("postgres")
.WithPgAdmin() .WithPgAdmin()
.AddDatabase("gmrelay-db"); .AddDatabase("gmrelaydb");
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot") var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
.WithReference(postgres) .WithReference(postgres)
.WaitFor(postgres) .WaitFor(postgres)
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health") .WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
.WithHttpHealthCheck("/health", endpointName: "health"); .WithHttpHealthCheck("/health", endpointName: "health");
builder.AddProject<Projects.GmRelay_DiscordBot>("discord") builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
@@ -119,16 +119,39 @@ CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
AS $$ AS $$
DECLARE
final_scheduled_at TIMESTAMPTZ;
BEGIN BEGIN
IF OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at SELECT s.scheduled_at
AND NEW.scheduled_at >= now() THEN INTO final_scheduled_at
FROM sessions s
WHERE s.id = NEW.id;
IF final_scheduled_at >= now() THEN
PERFORM pg.id
FROM portfolio_games pg
WHERE pg.is_public = true
AND EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
ORDER BY pg.id
FOR UPDATE OF pg;
UPDATE portfolio_games pg UPDATE portfolio_games pg
SET is_public = false, SET is_public = false,
updated_at = now() updated_at = now()
FROM portfolio_game_sessions pgs WHERE pg.is_public = true
WHERE pgs.portfolio_game_id = pg.id AND EXISTS (
AND pgs.session_id = NEW.id SELECT 1
AND pg.is_public = true; FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
);
END IF; END IF;
RETURN NULL; RETURN NULL;
@@ -344,6 +344,123 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
} }
[Fact]
public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var connection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(connection, isPublic: true);
await using var transaction = await connection.BeginTransactionAsync();
await ExecuteNonQueryAsync(
connection,
"UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId",
transaction,
new NpgsqlParameter("sessionId", seed.SessionIds[0]));
await ExecuteNonQueryAsync(
connection,
"UPDATE sessions SET scheduled_at = now() - interval '2 days' WHERE id = @sessionId",
transaction,
new NpgsqlParameter("sessionId", seed.SessionIds[0]));
await transaction.CommitAsync().WaitAsync(CommandTimeout);
Assert.True(await ExecuteScalarAsync<bool>(
connection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync<DateTime>(
connection,
"SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Fact]
public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var seedConnection = await database.OpenConnectionAsync();
var firstSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2);
var secondSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2);
await ExecuteNonQueryAsync(
seedConnection,
"""
CREATE FUNCTION wait_for_portfolio_card_unpublish_gate()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM pg_advisory_xact_lock(20260601, 108);
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_wait_for_portfolio_card_unpublish_gate
AFTER UPDATE OF is_public ON portfolio_games
FOR EACH ROW
WHEN (OLD.is_public = true AND NEW.is_public = false)
EXECUTE FUNCTION wait_for_portfolio_card_unpublish_gate();
""");
await using var firstConnection = await database.OpenConnectionAsync();
await using var secondConnection = await database.OpenConnectionAsync();
await using var gateConnection = await database.OpenConnectionAsync();
await using var observerConnection = await database.OpenConnectionAsync();
await using var firstTransaction = await firstConnection.BeginTransactionAsync();
await using var secondTransaction = await secondConnection.BeginTransactionAsync();
await using var gateTransaction = await gateConnection.BeginTransactionAsync();
var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction);
var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction);
var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction);
await AcquireBatchRescheduleGateAsync(gateConnection, gateTransaction);
await RescheduleSessionsAsync(
firstConnection,
firstTransaction,
firstSeed.SessionIds[0],
secondSeed.SessionIds[0]);
await RescheduleSessionsAsync(
secondConnection,
secondTransaction,
secondSeed.SessionIds[1],
firstSeed.SessionIds[1]);
var firstCommitTask = CommitAndCaptureSqlStateAsync(firstTransaction);
var secondCommitTask = CommitAndCaptureSqlStateAsync(secondTransaction);
var gateBlockedPid = await WaitUntilEitherBlockedByAsync(
observerConnection,
firstPid,
secondPid,
gatePid);
await WaitUntilBlockedByAnyAsync(
observerConnection,
gateBlockedPid == firstPid ? secondPid : firstPid,
gatePid,
gateBlockedPid);
await gateTransaction.CommitAsync().WaitAsync(CommandTimeout);
var commitStates = await Task.WhenAll(firstCommitTask, secondCommitTask).WaitAsync(CommandTimeout);
Assert.All(commitStates, Assert.Null);
await using var verificationConnection = await database.OpenConnectionAsync();
Assert.Equal(0, await ExecuteScalarAsync<long>(
verificationConnection,
"""
SELECT COUNT(*)
FROM portfolio_games
WHERE id IN (@firstPortfolioGameId, @secondPortfolioGameId)
AND is_public = true
""",
parameters:
[
new NpgsqlParameter("firstPortfolioGameId", firstSeed.PortfolioGameId),
new NpgsqlParameter("secondPortfolioGameId", secondSeed.PortfolioGameId)
]));
}
[Fact] [Fact]
public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit()
{ {
@@ -682,6 +799,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
transaction); transaction);
} }
private static Task<int> AcquireBatchRescheduleGateAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction)
{
return ExecuteNonQueryAsync(
connection,
"SELECT pg_advisory_xact_lock(20260601, 108)",
transaction);
}
private static Task<int> GetBackendPidAsync( private static Task<int> GetBackendPidAsync(
NpgsqlConnection connection, NpgsqlConnection connection,
NpgsqlTransaction transaction) NpgsqlTransaction transaction)
@@ -713,6 +840,28 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
new NpgsqlParameter("sessionId", sessionId)); new NpgsqlParameter("sessionId", sessionId));
} }
private static Task<int> RescheduleSessionsAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid firstSessionId,
Guid secondSessionId)
{
return ExecuteNonQueryAsync(
connection,
"""
UPDATE sessions
SET scheduled_at = now() + interval '1 day'
WHERE id = @firstSessionId;
UPDATE sessions
SET scheduled_at = now() + interval '1 day'
WHERE id = @secondSessionId;
""",
transaction,
new NpgsqlParameter("firstSessionId", firstSessionId),
new NpgsqlParameter("secondSessionId", secondSessionId));
}
private static async Task LockUnpublishDeleteAndCommitSessionAsync( private static async Task LockUnpublishDeleteAndCommitSessionAsync(
NpgsqlConnection connection, NpgsqlConnection connection,
NpgsqlTransaction transaction, NpgsqlTransaction transaction,
@@ -774,6 +923,74 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
$"PostgreSQL backend {blockedPid} was not blocked by backend {blockingPid} within {CommandTimeout}."); $"PostgreSQL backend {blockedPid} was not blocked by backend {blockingPid} within {CommandTimeout}.");
} }
private static async Task<int> WaitUntilEitherBlockedByAsync(
NpgsqlConnection observerConnection,
int firstBlockedPid,
int secondBlockedPid,
int blockingPid)
{
using var timeout = new CancellationTokenSource(CommandTimeout);
while (!timeout.IsCancellationRequested)
{
var blockedPid = await ExecuteScalarAsync<int>(
observerConnection,
"""
SELECT CASE
WHEN @blockingPid = ANY (pg_blocking_pids(@firstBlockedPid)) THEN @firstBlockedPid
WHEN @blockingPid = ANY (pg_blocking_pids(@secondBlockedPid)) THEN @secondBlockedPid
ELSE 0
END
""",
parameters:
[
new NpgsqlParameter("firstBlockedPid", firstBlockedPid),
new NpgsqlParameter("secondBlockedPid", secondBlockedPid),
new NpgsqlParameter("blockingPid", blockingPid)
]);
if (blockedPid != 0)
{
return blockedPid;
}
await Task.Yield();
}
throw new TimeoutException(
$"Neither PostgreSQL backend {firstBlockedPid} nor {secondBlockedPid} was blocked by backend {blockingPid} within {CommandTimeout}.");
}
private static async Task WaitUntilBlockedByAnyAsync(
NpgsqlConnection observerConnection,
int blockedPid,
int firstBlockingPid,
int secondBlockingPid)
{
using var timeout = new CancellationTokenSource(CommandTimeout);
while (!timeout.IsCancellationRequested)
{
if (await ExecuteScalarAsync<bool>(
observerConnection,
"""
SELECT @firstBlockingPid = ANY (pg_blocking_pids(@blockedPid))
OR @secondBlockingPid = ANY (pg_blocking_pids(@blockedPid))
""",
parameters:
[
new NpgsqlParameter("blockedPid", blockedPid),
new NpgsqlParameter("firstBlockingPid", firstBlockingPid),
new NpgsqlParameter("secondBlockingPid", secondBlockingPid)
]))
{
return;
}
await Task.Yield();
}
throw new TimeoutException(
$"PostgreSQL backend {blockedPid} was not blocked by backend {firstBlockingPid} or {secondBlockingPid} within {CommandTimeout}.");
}
private static async Task<int> ExecuteNonQueryAsync( private static async Task<int> ExecuteNonQueryAsync(
NpgsqlConnection connection, NpgsqlConnection connection,
string sql, string sql,
@@ -7,6 +7,12 @@ public sealed class PortfolioMigrationTests
{ {
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql");
var normalizedMigration = NormalizeSql(migration); var normalizedMigration = NormalizeSql(migration);
var unpublishFunctionStart = normalizedMigration.IndexOf(
"CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()",
StringComparison.Ordinal);
var unpublishFunctionEnd = normalizedMigration.IndexOf(
"CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule",
StringComparison.Ordinal);
Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal); Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal); Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal);
@@ -40,11 +46,16 @@ public sealed class PortfolioMigrationTests
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at AND NEW.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal); Assert.Contains("SELECT s.scheduled_at INTO final_scheduled_at FROM sessions s WHERE s.id = NEW.id;", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = NEW.id AND pg.is_public = true;", normalizedMigration, StringComparison.Ordinal); Assert.Contains("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("UPDATE portfolio_games pg SET is_public = false, updated_at = now() WHERE pg.is_public = true AND EXISTS (SELECT 1 FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id WHERE pgs.portfolio_game_id = pg.id AND s.scheduled_at >= now());", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal);
Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal);
Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain(
"pg_advisory_xact_lock",
normalizedMigration[unpublishFunctionStart..unpublishFunctionEnd],
StringComparison.Ordinal);
Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase);
Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal);
} }
@@ -17,7 +17,7 @@ public sealed class PortfolioSchemaGateSourceTests
var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs")); var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs"));
Assert.Contains( Assert.Contains(
"var bot = builder.AddProject<Projects.GmRelay_Bot>(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\") .WithHttpHealthCheck(\"/health\", endpointName: \"health\");", "var bot = builder.AddProject<Projects.GmRelay_Bot>(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\", isProxied: false) .WithHttpHealthCheck(\"/health\", endpointName: \"health\");",
appHost, appHost,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
@@ -30,6 +30,14 @@ public sealed class PortfolioSchemaGateSourceTests
StringComparison.Ordinal); StringComparison.Ordinal);
} }
[Fact]
public async Task Aspire_ShouldUseApplicationDatabaseConnectionStringName()
{
var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs"));
Assert.Contains(".AddDatabase(\"gmrelaydb\");", appHost, StringComparison.Ordinal);
}
private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName) private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName)
{ {
var serviceBlock = GetServiceBlock(compose, serviceName); var serviceBlock = GetServiceBlock(compose, serviceName);