diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index f516a30..c69339f 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -19,6 +19,7 @@ class TournamentSnapshotBuilder playoff_teams_amount: tournament.playoff_teams_amount, instant_finalists_amount: tournament.instant_finalists_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, + sync_generated_at: Time.current.iso8601(6), timer_end: tournament.timer_end&.iso8601, teams: teams_payload, stages: stages_payload, diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index ef75fcb..e960ea5 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -23,6 +23,7 @@ class TournamentSnapshotImporter def import! Tournament.transaction do tournament.lock! + return tournament if stale_snapshot? existing_teams = tournament.teams.to_a existing_stages = tournament.stages.to_a @@ -59,6 +60,7 @@ class TournamentSnapshotImporter root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) root_attrs[:timer_end] = parse_time(snapshot[:timer_end]) root_attrs[:sync_source_tournament_id] = snapshot[:id] + root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at tournament.update!(root_attrs) end @@ -162,6 +164,17 @@ class TournamentSnapshotImporter Time.zone.parse(value) end + def snapshot_generated_at + parse_time(snapshot[:sync_generated_at]) + end + + def stale_snapshot? + return false if snapshot_generated_at.nil? + return false if tournament.sync_last_imported_snapshot_at.nil? + + snapshot_generated_at <= tournament.sync_last_imported_snapshot_at + end + def code_taken_by_another_tournament?(code) return false if code.blank? diff --git a/db/migrate/20260414103000_add_sync_snapshot_tracking_to_tournaments.rb b/db/migrate/20260414103000_add_sync_snapshot_tracking_to_tournaments.rb new file mode 100644 index 0000000..d0d453b --- /dev/null +++ b/db/migrate/20260414103000_add_sync_snapshot_tracking_to_tournaments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSyncSnapshotTrackingToTournaments < ActiveRecord::Migration[7.0] + def change + add_column :tournaments, :sync_last_imported_snapshot_at, :datetime + end +end diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index d5fcc44..1c2efa7 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -28,13 +28,16 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do let(:follower_anonymous_client) { TurniereE2E::ApiClient.new(base_url: follower_base_url) } it 'pushes full source lifecycle into read only follower tournament' do + log_phase!('seed unrelated tournaments') source_noise = seed_unrelated_tournaments!(client: source_owner_client, prefix: 'Source Noise', range: 2..4) follower_noise = seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Noise', range: 5..7) + log_phase!('create source and follower tournaments') source = create_group_stage_tournament(client: source_owner_client, public: false, name_prefix: 'Sync Source') follower = create_follower_tournament(client: follower_owner_client, public: true, name_prefix: 'Sync Follower') expect(source.fetch(:id)).not_to eq(follower.fetch(:id)) + log_phase!('configure sync and verify initial replication') configure_sync = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state", sync_auth_token: sync_token @@ -47,6 +50,7 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + log_phase!('tighten playoff cutoff and rename teams') update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { playoff_teams_amount: 4, instant_finalists_amount: 4, @@ -68,9 +72,11 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Midway Noise', range: 3..4)) exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) + log_phase!('play group stage with deciders') play_group_with_decider_lifecycle!(source_id: source.fetch(:id), groups: source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }) exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) + log_phase!('verify playoff generation replicated') source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(tournament_signature(follower)).to eq(tournament_signature(source)) @@ -87,9 +93,11 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do follower_noise.concat(seed_unrelated_tournaments!(client: follower_owner_client, prefix: 'Follower Playoff Noise', range: 2..5)) exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) + log_phase!('finish playoff bracket') finish_playoff_bracket!(source_id: source.fetch(:id)) exercise_unrelated_requests!(source_noise: source_noise, follower_noise: follower_noise) + log_phase!('verify final follower state and takeover') source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) expect(tournament_signature(follower)).to eq(tournament_signature(source)) @@ -104,6 +112,11 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(takeover_update[:status]).to eq(200) end + def log_phase!(message) + puts "phase #{message}" + $stdout.flush + end + def login_client(base_url:, email:, password:) client = TurniereE2E::ApiClient.new(base_url: base_url) response = client.login!(email: email, password: password) diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 0f4b197..129d6f0 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -20,5 +20,22 @@ RSpec.describe TournamentSnapshotImporter do expect(follower.matches.count).to eq(source.matches.count) expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) }) end + + it 'ignores stale snapshots that arrive after a newer one' do + follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret') + + newer_source = create(:tournament, name: 'Newer name') + older_source = create(:tournament, name: 'Older name') + + newer_snapshot = TournamentSnapshotBuilder.build(newer_source).merge(sync_generated_at: '2026-04-14T10:30:02Z') + older_snapshot = TournamentSnapshotBuilder.build(older_source).merge(sync_generated_at: '2026-04-14T10:30:01Z') + + described_class.import!(tournament: follower, snapshot: newer_snapshot) + described_class.import!(tournament: follower, snapshot: older_snapshot) + + follower.reload + expect(follower.name).to eq('Newer name') + expect(follower.sync_last_imported_snapshot_at).to eq(Time.zone.parse('2026-04-14T10:30:02Z')) + end end end