Add sync snapshot tracking

This commit is contained in:
Daniel Schädler 2026-04-14 22:36:39 +02:00
parent c4f182a6a8
commit 26679d87c2
5 changed files with 51 additions and 0 deletions

View File

@ -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,

View File

@ -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?

View File

@ -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

View File

@ -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)

View File

@ -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