-
Notifications
You must be signed in to change notification settings - Fork 2
Refuse a blank owner, and assemble the claim where the owner lives #1550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
domains/games/apis/one_d4/migrations/V004__owner_never_blank.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| -- owner_id names the claimant every fence keys on: a heartbeat, a progress | ||
| -- write and a terminal write all match on it, and a claim spends an attempt | ||
| -- only when the owner it presents differs from the one stored. A blank is a | ||
| -- claimant nobody's fence matches and one a re-claim under a blank spends | ||
| -- nothing on — so it is refused on both request tables, whoever the writer | ||
| -- is. NULL stays what it is: no owner. | ||
| -- | ||
| -- Blanks are nulled on the one execution that adds the constraint, so a | ||
| -- database holding one converges rather than failing the deploy. A nulled | ||
| -- owner with a lapsed lease is exactly what ClaimNext takes next. | ||
|
|
||
| DO $$ | ||
| DECLARE | ||
| target text; | ||
| constraint_name text; | ||
| BEGIN | ||
| FOREACH target IN ARRAY ARRAY['indexing_requests', 'reanalysis_requests'] | ||
| LOOP | ||
| constraint_name := target || '_owner_never_blank'; | ||
| IF NOT EXISTS ( | ||
| SELECT 1 FROM pg_constraint | ||
| WHERE conrelid = target::regclass AND conname = constraint_name | ||
| ) THEN | ||
| EXECUTE format('UPDATE %I SET owner_id = NULL WHERE owner_id = ''''', target); | ||
| EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I CHECK (owner_id <> '''')', | ||
| target, constraint_name); | ||
| END IF; | ||
| END LOOP; | ||
| END $$; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,3 +7,4 @@ | |
| V001__initial_schema | ||
| V002__player_titles | ||
| V003__platform_canonical | ||
| V004__owner_never_blank | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
163 changes: 163 additions & 0 deletions
163
...es/apis/one_d4/src/test/java/com/muchq/games/one_d4/db/OwnerNeverBlankConstraintTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| package com.muchq.games.one_d4.db; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatCode; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
|
||
| import java.sql.Connection; | ||
| import java.sql.SQLException; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| /** | ||
| * A blank owner is refused by the request tables, whoever the writer is. | ||
| * | ||
| * <p>Every fence keys on {@code owner_id}: a heartbeat, a progress write and a terminal write all | ||
| * match on it, and a claim spends an attempt only when the owner it presents differs from the one | ||
| * stored. A row claimed under {@code ''} is one no worker's fence matches and one a re-claim under | ||
| * {@code ''} does — so it never completes and never retires. {@code NULL} stays what it is: no | ||
| * owner. | ||
| */ | ||
| public class OwnerNeverBlankConstraintTest { | ||
|
|
||
| private static final List<String> REQUEST_TABLES = | ||
| List.of("indexing_requests", "reanalysis_requests"); | ||
|
|
||
| private TestDb testDb; | ||
|
|
||
| @BeforeEach | ||
| public void setUp() { | ||
| testDb = TestDb.create("ownerneverblank"); | ||
| } | ||
|
|
||
| @Test | ||
| public void everyRequestTableRefusesABlankOwner() throws Exception { | ||
| for (String table : REQUEST_TABLES) { | ||
| UUID id = insertRequest(table); | ||
| assertThatThrownBy(() -> setOwner(table, id, "")) | ||
| .as("%s should refuse a blank owner", table) | ||
| .isInstanceOf(SQLException.class) | ||
| .hasMessageContaining("owner_never_blank"); | ||
| } | ||
| } | ||
|
|
||
| /** The control: no owner and a named owner are both still storable. */ | ||
| @Test | ||
| public void aNullOrNamedOwnerIsStillAccepted() throws Exception { | ||
| for (String table : REQUEST_TABLES) { | ||
| UUID id = insertRequest(table); | ||
| assertThatCode(() -> setOwner(table, id, "cpp/host/1/9a1f")) | ||
| .as("%s should accept a named owner", table) | ||
| .doesNotThrowAnyException(); | ||
| assertThatCode(() -> setOwner(table, id, null)) | ||
| .as("%s should accept no owner", table) | ||
| .doesNotThrowAnyException(); | ||
| } | ||
| } | ||
|
|
||
| /** The constraints are named, so {@code Migration.verify()} notices a database missing them. */ | ||
| @Test | ||
| public void theConstraintsAreVisibleToBootVerification() { | ||
| assertThat(constraintNames()) | ||
| .containsExactlyInAnyOrder( | ||
| "indexing_requests_owner_never_blank", "reanalysis_requests_owner_never_blank"); | ||
| } | ||
|
|
||
| /** | ||
| * The normalise half. A database holding a blank before the constraint converges instead of | ||
| * failing the deploy: the step nulls blanks only on the execution that adds the constraint. | ||
| */ | ||
| @Test | ||
| public void rerunningTheMigrationClearsABlankOwnerStoredBeforeTheConstraint() throws Exception { | ||
| for (String table : REQUEST_TABLES) { | ||
| exec("ALTER TABLE " + table + " DROP CONSTRAINT " + table + "_owner_never_blank"); | ||
| UUID id = insertRequest(table); | ||
| setOwner(table, id, ""); | ||
|
|
||
| new Migration(testDb.dataSource()).run(); | ||
|
|
||
| assertThat(ownerOf(table, id)).as("%s blank owner after re-run", table).isNull(); | ||
| assertThat(constraintNames()).contains(table + "_owner_never_blank"); | ||
| } | ||
| } | ||
|
|
||
| /** Re-run safe, like every step here: the file executes on every deploy. */ | ||
| @Test | ||
| public void theStepIsIdempotent() { | ||
| new Migration(testDb.dataSource()).run(); | ||
| new Migration(testDb.dataSource()).run(); | ||
|
|
||
| assertThat(constraintNames()).hasSize(REQUEST_TABLES.size()); | ||
| } | ||
|
|
||
| /** A pending row on `table`. At most one reanalysis row per test: its live index allows one. */ | ||
| private UUID insertRequest(String table) throws SQLException { | ||
| UUID id = UUID.randomUUID(); | ||
| String sql = | ||
| switch (table) { | ||
| case "indexing_requests" -> | ||
| "INSERT INTO indexing_requests (id, player, platform, start_month, end_month)" | ||
| + " VALUES (?, ?, 'CHESS_COM', '2024-01', '2024-01')"; | ||
| case "reanalysis_requests" -> "INSERT INTO reanalysis_requests (id) VALUES (?)"; | ||
| default -> throw new IllegalArgumentException(table); | ||
| }; | ||
| try (Connection conn = testDb.dataSource().getConnection(); | ||
| var stmt = conn.prepareStatement(sql)) { | ||
| stmt.setObject(1, id); | ||
| if (table.equals("indexing_requests")) { | ||
| stmt.setString(2, UUID.randomUUID().toString()); | ||
| } | ||
| stmt.executeUpdate(); | ||
| } | ||
| return id; | ||
| } | ||
|
|
||
| private void setOwner(String table, UUID id, String owner) throws SQLException { | ||
| try (Connection conn = testDb.dataSource().getConnection(); | ||
| var stmt = conn.prepareStatement("UPDATE " + table + " SET owner_id = ? WHERE id = ?")) { | ||
| stmt.setString(1, owner); | ||
| stmt.setObject(2, id); | ||
| stmt.executeUpdate(); | ||
| } | ||
| } | ||
|
|
||
| private String ownerOf(String table, UUID id) throws SQLException { | ||
| try (Connection conn = testDb.dataSource().getConnection(); | ||
| var stmt = conn.prepareStatement("SELECT owner_id FROM " + table + " WHERE id = ?")) { | ||
| stmt.setObject(1, id); | ||
| try (var rs = stmt.executeQuery()) { | ||
| assertThat(rs.next()).as("row %s should still exist", id).isTrue(); | ||
| return rs.getString(1); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void exec(String sql) throws SQLException { | ||
| try (Connection conn = testDb.dataSource().getConnection(); | ||
| var stmt = conn.createStatement()) { | ||
| stmt.execute(sql); | ||
| } | ||
| } | ||
|
|
||
| private List<String> constraintNames() { | ||
| try (Connection conn = testDb.dataSource().getConnection(); | ||
| var stmt = | ||
| conn.prepareStatement( | ||
| "SELECT conname FROM pg_constraint c JOIN pg_namespace n ON n.oid =" | ||
| + " c.connamespace WHERE n.nspname = ? AND conname LIKE" | ||
| + " '%owner_never_blank'")) { | ||
| stmt.setString(1, testDb.schema()); | ||
| var names = new java.util.ArrayList<String>(); | ||
| try (var rs = stmt.executeQuery()) { | ||
| while (rs.next()) { | ||
| names.add(rs.getString(1)); | ||
| } | ||
| } | ||
| return names; | ||
| } catch (SQLException e) { | ||
| throw new RuntimeException(e); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should-fix (CI receipt): these suites — and
OwnerNeverBlankConstraintTest— have no execution receipt on this head's requiredbuild-and-test.On run 34859493099,
scripts/diff-buildreported 533 first-party impacted targets, then:The
bazel query "… set($TARGET_SET) …"failure is swallowed by| grep … || true, so the job exits 0 having run only//:buildifier_test. Postgres was up; nothing asked it to exercise the blank-owner or gate→COMPLETED paths. (#1549's green run had ~64 impacted → realone_d4_workersuites.)Not a defect in the tests themselves — they look correctly shaped. Either:
scripts/diff-buildto fail loud (and/or feed targets via--target_pattern_file/ query-from-file so ARG_MAX cannot empty the set), orasan on the same head is a separate infra miss (
rust-redist504 during Resolve target set) — also not a finding on this code, but another required check that never reached the new tests.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Receipt on the rebased head (https://github.com/muchq/MoonBase/actions/runs/34889778959/job/104129247549): 533 impacted → tests to run: 73, targets to build: 56, executed 73 of 73, all pass — pg_queue_test, reanalysis_queue_test and OwnerNeverBlankConstraintTest among them, against the postgres service.