jumbotron 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +5 -0
  4. data/app/adapters/jumbotron/adapters/base.rb +136 -0
  5. data/app/adapters/jumbotron/adapters/cadence.rb +42 -0
  6. data/app/adapters/jumbotron/adapters/discovery_definition.rb +7 -0
  7. data/app/adapters/jumbotron/adapters/espn/nfl.rb +169 -0
  8. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/acquisition_scope.rb +29 -0
  9. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/competition_odds.rb +185 -0
  10. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/lifecycle.rb +30 -0
  11. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/line_acquisition_scope.rb +26 -0
  12. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/progress.rb +54 -0
  13. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +146 -0
  14. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/season_phase.rb +33 -0
  15. data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +35 -0
  16. data/app/adapters/jumbotron/adapters/line_acquisition.rb +19 -0
  17. data/app/adapters/jumbotron/adapters/operation.rb +23 -0
  18. data/app/adapters/jumbotron/adapters/policy_definition.rb +14 -0
  19. data/app/adapters/jumbotron/adapters/registry.rb +78 -0
  20. data/app/adapters/jumbotron/adapters.rb +11 -0
  21. data/app/boot/jumbotron/boot/enqueue_discoveries.rb +41 -0
  22. data/app/clients/jumbotron/clients/espn/competition_odds/get.rb +26 -0
  23. data/app/clients/jumbotron/clients/espn/competition_odds/get_input.rb +16 -0
  24. data/app/clients/jumbotron/clients/espn/scoreboard/get.rb +18 -0
  25. data/app/clients/jumbotron/clients/espn/scoreboard/get_input.rb +17 -0
  26. data/app/clients/jumbotron/clients/espn/teams/get.rb +18 -0
  27. data/app/clients/jumbotron/clients/espn/teams/get_input.rb +15 -0
  28. data/app/clients/jumbotron/clients/espn.rb +44 -0
  29. data/app/clients/jumbotron/clients.rb +8 -0
  30. data/app/deserializers/jumbotron/deserializers/clients/espn/competition.rb +79 -0
  31. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/draw_odds.rb +31 -0
  32. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/get.rb +66 -0
  33. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/item.rb +60 -0
  34. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/listed_provider.rb +31 -0
  35. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/market_window.rb +31 -0
  36. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/point_spread.rb +27 -0
  37. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/price.rb +35 -0
  38. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/side_quote.rb +35 -0
  39. data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/team_odds.rb +40 -0
  40. data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +49 -0
  41. data/app/deserializers/jumbotron/deserializers/clients/espn/event.rb +81 -0
  42. data/app/deserializers/jumbotron/deserializers/clients/espn/scoreboard/get.rb +88 -0
  43. data/app/deserializers/jumbotron/deserializers/clients/espn/season.rb +27 -0
  44. data/app/deserializers/jumbotron/deserializers/clients/espn/status.rb +69 -0
  45. data/app/deserializers/jumbotron/deserializers/clients/espn/support.rb +61 -0
  46. data/app/deserializers/jumbotron/deserializers/clients/espn/team.rb +42 -0
  47. data/app/deserializers/jumbotron/deserializers/clients/espn/teams/get.rb +76 -0
  48. data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +34 -0
  49. data/app/deserializers/jumbotron/deserializers/clients/espn/week.rb +23 -0
  50. data/app/deserializers/jumbotron/deserializers/public/game.rb +26 -0
  51. data/app/deserializers/jumbotron/deserializers/public/game_ids.rb +28 -0
  52. data/app/deserializers/jumbotron/deserializers/public/includes.rb +32 -0
  53. data/app/deserializers/jumbotron/deserializers/public/schedule.rb +151 -0
  54. data/app/deserializers/jumbotron/deserializers/public/schedule_groups.rb +91 -0
  55. data/app/errors/jumbotron/error.rb +6 -0
  56. data/app/errors/jumbotron/errors/adapters.rb +143 -0
  57. data/app/errors/jumbotron/errors/canonical.rb +67 -0
  58. data/app/errors/jumbotron/errors/public.rb +27 -0
  59. data/app/errors/jumbotron/invalid_request_error.rb +6 -0
  60. data/app/errors/jumbotron/not_found_error.rb +6 -0
  61. data/app/errors/jumbotron/unsupported_capability_error.rb +6 -0
  62. data/app/jobs/jumbotron/application_job.rb +6 -0
  63. data/app/jobs/jumbotron/discovery_job.rb +14 -0
  64. data/app/jobs/jumbotron/game_update_job.rb +12 -0
  65. data/app/jobs/jumbotron/line_update_job.rb +12 -0
  66. data/app/models/jumbotron/application_record.rb +8 -0
  67. data/app/models/jumbotron/bookmaker.rb +11 -0
  68. data/app/models/jumbotron/canonical.rb +128 -0
  69. data/app/models/jumbotron/game.rb +92 -0
  70. data/app/models/jumbotron/game_participant.rb +10 -0
  71. data/app/models/jumbotron/historical_change.rb +9 -0
  72. data/app/models/jumbotron/league.rb +12 -0
  73. data/app/models/jumbotron/line_observation.rb +18 -0
  74. data/app/models/jumbotron/observation_batch.rb +14 -0
  75. data/app/models/jumbotron/provider_identity.rb +32 -0
  76. data/app/models/jumbotron/public/translate_outcome.rb +57 -0
  77. data/app/models/jumbotron/public.rb +88 -0
  78. data/app/models/jumbotron/schedule_group.rb +22 -0
  79. data/app/models/jumbotron/season.rb +13 -0
  80. data/app/models/jumbotron/season_phase.rb +12 -0
  81. data/app/models/jumbotron/sport.rb +10 -0
  82. data/app/models/jumbotron/team.rb +11 -0
  83. data/app/models/jumbotron/venue.rb +10 -0
  84. data/app/providers/jumbotron/providers/espn/cooldown.rb +88 -0
  85. data/app/providers/jumbotron/providers/espn/cooldown_active_error.rb +13 -0
  86. data/app/providers/jumbotron/providers/espn/provider_failure.rb +40 -0
  87. data/app/providers/jumbotron/providers/espn/resources/competition_odds.rb +29 -0
  88. data/app/providers/jumbotron/providers/espn/resources/scoreboard.rb +90 -0
  89. data/app/providers/jumbotron/providers/espn/resources/teams.rb +19 -0
  90. data/app/scheduling/jumbotron/scheduling/backends/sidekiq_cron.rb +70 -0
  91. data/app/scheduling/jumbotron/scheduling/backends/solid_queue.rb +92 -0
  92. data/app/scheduling/jumbotron/scheduling/clock.rb +61 -0
  93. data/app/scheduling/jumbotron/scheduling/desired_schedules.rb +51 -0
  94. data/app/scheduling/jumbotron/scheduling/detect_integration.rb +45 -0
  95. data/app/scheduling/jumbotron/scheduling/materializer.rb +13 -0
  96. data/app/scheduling/jumbotron/scheduling/owned.rb +19 -0
  97. data/app/scheduling/jumbotron/scheduling/schedule_definition.rb +7 -0
  98. data/app/serializers/jumbotron/serializers/clients/espn/competition_odds/get.rb +17 -0
  99. data/app/serializers/jumbotron/serializers/clients/espn/scoreboard/get.rb +22 -0
  100. data/app/serializers/jumbotron/serializers/clients/espn/teams/get.rb +20 -0
  101. data/app/services/jumbotron/services/adapters/build_acquisition_scopes.rb +37 -0
  102. data/app/services/jumbotron/services/adapters/build_line_acquisition_scopes.rb +39 -0
  103. data/app/services/jumbotron/services/adapters/ensure_league.rb +29 -0
  104. data/app/services/jumbotron/services/adapters/execute_endpoint.rb +49 -0
  105. data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +75 -0
  106. data/app/services/jumbotron/services/adapters/execute_synchronization_scope.rb +64 -0
  107. data/app/services/jumbotron/services/adapters/resolve_discovery.rb +53 -0
  108. data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +74 -0
  109. data/app/services/jumbotron/services/adapters/resolve_line_update_policy.rb +74 -0
  110. data/app/services/jumbotron/services/adapters/select_eligible_games.rb +41 -0
  111. data/app/services/jumbotron/services/adapters/validate_sync_request.rb +51 -0
  112. data/app/services/jumbotron/services/canonical/attach_provider_identities.rb +59 -0
  113. data/app/services/jumbotron/services/canonical/derive_consensus_for_games.rb +18 -0
  114. data/app/services/jumbotron/services/canonical/derive_consensus_line.rb +70 -0
  115. data/app/services/jumbotron/services/canonical/ensure_bookmaker.rb +70 -0
  116. data/app/services/jumbotron/services/canonical/ensure_schedule_group.rb +40 -0
  117. data/app/services/jumbotron/services/canonical/ensure_season.rb +22 -0
  118. data/app/services/jumbotron/services/canonical/ensure_season_phase.rb +22 -0
  119. data/app/services/jumbotron/services/canonical/persist_line_observations.rb +145 -0
  120. data/app/services/jumbotron/services/canonical/record_observation_evidence.rb +51 -0
  121. data/app/services/jumbotron/services/canonical/resolve_current_lines.rb +21 -0
  122. data/app/services/jumbotron/services/canonical/resolve_current_lines_for_games.rb +69 -0
  123. data/app/services/jumbotron/services/canonical/upsert_game.rb +186 -0
  124. data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +88 -0
  125. data/app/services/jumbotron/services/canonical/upsert_team.rb +90 -0
  126. data/app/services/jumbotron/services/canonical/upsert_venue.rb +92 -0
  127. data/app/services/jumbotron/services/public/assemble_public_game.rb +131 -0
  128. data/app/services/jumbotron/services/public/assemble_public_schedule.rb +63 -0
  129. data/app/services/jumbotron/services/public/derive_consensus_for_games.rb +21 -0
  130. data/app/services/jumbotron/services/public/load_games_by_ids.rb +18 -0
  131. data/app/services/jumbotron/services/public/load_included_lines.rb +47 -0
  132. data/app/services/jumbotron/services/public/map_public_consensus_lines.rb +44 -0
  133. data/app/services/jumbotron/services/public/map_public_current_lines.rb +27 -0
  134. data/app/services/jumbotron/services/public/read_game.rb +32 -0
  135. data/app/services/jumbotron/services/public/read_schedule.rb +116 -0
  136. data/app/services/jumbotron/services/public/read_schedule_groups.rb +90 -0
  137. data/app/services/jumbotron/services/public/resolve_current_lines_for_games.rb +21 -0
  138. data/app/synchronization/jumbotron/synchronization/lease.rb +127 -0
  139. data/app/workflows/jumbotron/workflows/execute_discovery_workflow.rb +131 -0
  140. data/app/workflows/jumbotron/workflows/execute_game_update_policy_workflow.rb +159 -0
  141. data/app/workflows/jumbotron/workflows/execute_line_update_policy_workflow.rb +156 -0
  142. data/app/workflows/jumbotron/workflows/public_read.rb +29 -0
  143. data/app/workflows/jumbotron/workflows/read_consensus_workflow.rb +37 -0
  144. data/app/workflows/jumbotron/workflows/read_current_lines_workflow.rb +27 -0
  145. data/app/workflows/jumbotron/workflows/read_game_workflow.rb +34 -0
  146. data/app/workflows/jumbotron/workflows/read_schedule_groups_workflow.rb +21 -0
  147. data/app/workflows/jumbotron/workflows/read_schedule_workflow.rb +48 -0
  148. data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +183 -0
  149. data/db/migrate/20260812000001_create_jumbotron_foundational_catalog.rb +68 -0
  150. data/db/migrate/20260812000002_create_jumbotron_game_graph.rb +70 -0
  151. data/db/migrate/20260812000003_add_freshness_to_jumbotron_provider_mutated_entities.rb +31 -0
  152. data/db/migrate/20260812000004_create_jumbotron_observation_evidence.rb +34 -0
  153. data/db/migrate/20260813000001_create_jumbotron_schedule_groups.rb +24 -0
  154. data/db/migrate/20260814000001_create_jumbotron_line_observations.rb +38 -0
  155. data/db/migrate/20260820000001_add_progress_to_jumbotron_games.rb +14 -0
  156. data/lib/jumbotron/client.rb +40 -0
  157. data/lib/jumbotron/engine.rb +32 -0
  158. data/lib/jumbotron/version.rb +5 -0
  159. data/lib/jumbotron.rb +13 -0
  160. data/lib/tasks/jumbotron_schedules.rake +12 -0
  161. metadata +239 -0
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Public
5
+ module TranslateOutcome
6
+ NOT_FOUND_CODES = %w[
7
+ not_found
8
+ game_not_found
9
+ league_not_found
10
+ season_not_found
11
+ season_phase_not_found
12
+ schedule_group_not_found
13
+ ].freeze
14
+
15
+ module_function
16
+
17
+ def call(result, payload_key:)
18
+ unless result.is_a?(CommandTower::Workflows::WorkflowResult)
19
+ raise Jumbotron::Error, "unexpected internal result"
20
+ end
21
+
22
+ return result.payload.fetch(payload_key) if result.success?
23
+
24
+ raise public_error_for(result)
25
+ end
26
+
27
+ def public_error_for(result)
28
+ code, message = error_code_and_message(result)
29
+ klass = exception_class_for(code)
30
+ klass.new(message)
31
+ end
32
+ module_function :public_error_for
33
+
34
+ def error_code_and_message(result)
35
+ error = Array(result.errors).first
36
+ case error
37
+ when CommandTower::Errors::ApplicationError
38
+ [error.code.to_s, error.message]
39
+ when Hash
40
+ [(error[:code] || error["code"]).to_s, (error[:message] || error["message"]).to_s]
41
+ else
42
+ ["internal_error", error.to_s]
43
+ end
44
+ end
45
+ module_function :error_code_and_message
46
+
47
+ def exception_class_for(code)
48
+ return Jumbotron::NotFoundError if NOT_FOUND_CODES.include?(code)
49
+ return Jumbotron::InvalidRequestError if code == "invalid_request"
50
+ return Jumbotron::UnsupportedCapabilityError if code == "unsupported"
51
+
52
+ Jumbotron::Error
53
+ end
54
+ module_function :exception_class_for
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Public
5
+ ALLOWED_INCLUDES = %i[consensus current_lines consensus_constituents].freeze
6
+
7
+ Sport = Data.define(:id, :name)
8
+ League = Data.define(:id, :name, :sport)
9
+ Season = Data.define(:id, :name, :starts_on, :ends_on)
10
+ SeasonPhase = Data.define(:id, :name)
11
+ ScheduleGroup = Data.define(:id, :kind, :number, :name)
12
+ ScheduleGroupEnumeration = Data.define(:groups, :completeness)
13
+ ScheduleGroupsRequest = Data.define(
14
+ :league_id,
15
+ :sport,
16
+ :league,
17
+ :season_id,
18
+ :season,
19
+ :season_phase,
20
+ :kind
21
+ )
22
+ Team = Data.define(:id, :name)
23
+ Venue = Data.define(:id, :name)
24
+ Participant = Data.define(:role, :score, :result, :team)
25
+ GameSegment = Data.define(:kind, :number)
26
+ # Clock label text. Name is canonical/public contract, not Kernel#display.
27
+ GameClock = Data.define(:mode, :seconds, :display) # rubocop:disable Lint/DataDefineOverride
28
+ GameProgress = Data.define(:state, :segment, :clock)
29
+ ConsensusConstituent = Data.define(:bookmaker_id)
30
+ CurrentLine = Data.define(
31
+ :game_id,
32
+ :bookmaker_id,
33
+ :bookmaker_name,
34
+ :market,
35
+ :outcome,
36
+ :line_value,
37
+ :price_american,
38
+ :observed_at,
39
+ :changed_at
40
+ )
41
+ ConsensusLine = Data.define(
42
+ :game_id,
43
+ :market,
44
+ :outcome,
45
+ :line_value,
46
+ :constituent_count,
47
+ :observed_at,
48
+ :constituents
49
+ )
50
+ Game = Data.define(
51
+ :id,
52
+ :scheduled_at,
53
+ :lifecycle,
54
+ :progress,
55
+ :neutral_site,
56
+ :observed_at,
57
+ :changed_at,
58
+ :venue,
59
+ :participants,
60
+ :consensus_lines,
61
+ :current_lines
62
+ )
63
+ Schedule = Data.define(
64
+ :league,
65
+ :season,
66
+ :season_phase,
67
+ :group,
68
+ :starts_at,
69
+ :ends_at,
70
+ :games
71
+ )
72
+ GameRequest = Data.define(:id, :includes)
73
+ GameIdsRequest = Data.define(:game_ids)
74
+ ScheduleRequest = Data.define(
75
+ :league_id,
76
+ :sport,
77
+ :league,
78
+ :season_id,
79
+ :season,
80
+ :season_phase,
81
+ :schedule_group_id,
82
+ :group,
83
+ :starts_at,
84
+ :ends_at,
85
+ :includes
86
+ )
87
+ end
88
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ class ScheduleGroup < ApplicationRecord
5
+ belongs_to :season, inverse_of: :schedule_groups
6
+ belongs_to :season_phase, optional: true, inverse_of: :schedule_groups
7
+ has_many :games, inverse_of: :schedule_group, dependent: :restrict_with_exception
8
+
9
+ validates :kind, presence: true
10
+ validates :name, presence: true
11
+ validate :season_phase_belongs_to_season
12
+
13
+ private
14
+
15
+ def season_phase_belongs_to_season
16
+ return if season_phase.blank? || season.blank?
17
+ return if season_phase.season_id == season_id
18
+
19
+ errors.add(:season_phase, "must belong to the same season")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ class Season < ApplicationRecord
5
+ belongs_to :league, inverse_of: :seasons
6
+ has_many :season_phases, inverse_of: :season, dependent: :restrict_with_exception
7
+ has_many :schedule_groups, inverse_of: :season, dependent: :restrict_with_exception
8
+ has_many :games, inverse_of: :season, dependent: :restrict_with_exception
9
+ has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
10
+
11
+ validates :name, presence: true
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ class SeasonPhase < ApplicationRecord
5
+ belongs_to :season, inverse_of: :season_phases
6
+ has_many :schedule_groups, inverse_of: :season_phase, dependent: :restrict_with_exception
7
+ has_many :games, inverse_of: :season_phase, dependent: :restrict_with_exception
8
+ has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
9
+
10
+ validates :name, presence: true
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ class Sport < ApplicationRecord
5
+ has_many :leagues, inverse_of: :sport, dependent: :restrict_with_exception
6
+ has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
7
+
8
+ validates :name, presence: true
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ class Team < ApplicationRecord
5
+ has_many :game_participants, inverse_of: :team, dependent: :restrict_with_exception
6
+ has_many :games, through: :game_participants
7
+ has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
8
+
9
+ validates :name, presence: true
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ class Venue < ApplicationRecord
5
+ has_many :games, inverse_of: :venue, dependent: :restrict_with_exception
6
+ has_many :provider_identities, as: :target, inverse_of: :target, dependent: :restrict_with_exception
7
+
8
+ validates :name, presence: true
9
+ end
10
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Espn
6
+ # Provider-wide ESPN availability cooldown. Backed by Rails-configured cache/store.
7
+ class Cooldown
8
+ STATE_KEY = "jumbotron:providers:espn:cooldown"
9
+ BASE_DELAY_SECONDS = 5
10
+ MAX_DELAY_SECONDS = 180
11
+
12
+ def initialize(store: Rails.cache, clock: -> { Time.current })
13
+ @store = store
14
+ @clock = clock
15
+ end
16
+
17
+ # rubocop:disable Naming/PredicateMethod -- bang mutators report whether state changed
18
+ def record_failure!(error, now: clock.call)
19
+ return false unless ProviderFailure.availability_failure?(error)
20
+
21
+ state = read_state
22
+ count = state.fetch(:failure_count, 0) + 1
23
+ delay = delay_for(count)
24
+ write_state(failure_count: count, cooldown_until: now + delay)
25
+ true
26
+ end
27
+
28
+ def record_success!
29
+ store.delete(STATE_KEY)
30
+ true
31
+ end
32
+ # rubocop:enable Naming/PredicateMethod
33
+
34
+ def cooling_down?(now: clock.call)
35
+ retry_after(now: now).positive?
36
+ end
37
+
38
+ def retry_after(now: clock.call)
39
+ until_time = read_state[:cooldown_until]
40
+ return 0 if until_time.nil?
41
+
42
+ remaining = (until_time - now).to_f
43
+ remaining.positive? ? remaining.ceil : 0
44
+ end
45
+
46
+ def failure_count
47
+ read_state.fetch(:failure_count, 0)
48
+ end
49
+
50
+ def self.delay_for(failure_count)
51
+ [BASE_DELAY_SECONDS * (2**(failure_count - 1)), MAX_DELAY_SECONDS].min
52
+ end
53
+
54
+ delegate :delay_for, to: :class
55
+
56
+ private
57
+
58
+ attr_reader :store, :clock
59
+
60
+ def read_state
61
+ raw = store.read(STATE_KEY)
62
+ return { failure_count: 0, cooldown_until: nil } if raw.blank?
63
+
64
+ {
65
+ failure_count: Integer(raw[:failure_count] || raw["failure_count"] || 0),
66
+ cooldown_until: parse_time(raw[:cooldown_until] || raw["cooldown_until"])
67
+ }
68
+ end
69
+
70
+ def write_state(failure_count:, cooldown_until:)
71
+ store.write(
72
+ STATE_KEY,
73
+ { failure_count: failure_count, cooldown_until: cooldown_until.utc.iso8601(6) },
74
+ expires_in: MAX_DELAY_SECONDS * 2
75
+ )
76
+ end
77
+
78
+ def parse_time(value)
79
+ return if value.nil?
80
+ return value if value.is_a?(Time)
81
+ return value.to_time if value.respond_to?(:to_time)
82
+
83
+ Time.iso8601(value.to_s)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Espn
6
+ class CooldownActiveError < StandardError
7
+ def initialize(msg = "ESPN provider cooldown is active")
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Espn
6
+ module ProviderFailure
7
+ module_function
8
+
9
+ def availability_failure?(error)
10
+ return false unless error.is_a?(CommandTower::Clients::Errors::UpstreamError)
11
+
12
+ return true if server_error_status?(error)
13
+ return true if transport_cause?(error.cause)
14
+
15
+ false
16
+ end
17
+
18
+ def server_error_status?(error)
19
+ status = error.details.is_a?(Hash) ? error.details[:status] : nil
20
+ status.is_a?(Integer) && status >= 500
21
+ end
22
+
23
+ def transport_cause?(cause)
24
+ return false if cause.nil?
25
+ return true if cause.is_a?(CommandTower::Clients::Transport::Error)
26
+ return true if defined?(Faraday) && cause.is_a?(Faraday::Error)
27
+ return true if cause.is_a?(Timeout::Error)
28
+ return true if cause.is_a?(SocketError)
29
+ return true if cause.is_a?(Errno::ECONNREFUSED)
30
+ return true if cause.is_a?(Errno::ECONNRESET)
31
+ return true if cause.is_a?(Errno::ETIMEDOUT)
32
+ return true if cause.is_a?(Net::OpenTimeout)
33
+ return true if cause.is_a?(Net::ReadTimeout)
34
+
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Espn
6
+ module Resources
7
+ class CompetitionOdds
8
+ def self.acquire(sport:, league:, event_id:, competition_id:)
9
+ cooldown = Cooldown.new
10
+ return CommandTower::Clients::ClientResult.failure(error: CooldownActiveError.new) if cooldown.cooling_down?
11
+
12
+ result = Jumbotron::Clients.espn.competition_odds.get(
13
+ sport: sport,
14
+ league: league,
15
+ event_id: event_id,
16
+ competition_id: competition_id
17
+ )
18
+ if result.success?
19
+ cooldown.record_success!
20
+ else
21
+ cooldown.record_failure!(result.error)
22
+ end
23
+ result
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Espn
6
+ module Resources
7
+ class Scoreboard
8
+ DISCOVERY_SEASON_TYPES = [1, 2, 3].freeze
9
+
10
+ def self.acquire(adapter:, **query)
11
+ cooldown = Cooldown.new
12
+ return CommandTower::Clients::ClientResult.failure(error: CooldownActiveError.new) if cooldown.cooling_down?
13
+
14
+ result = Jumbotron::Clients.espn.scoreboard.get(
15
+ sport: adapter.sport,
16
+ league: adapter.league,
17
+ **query
18
+ )
19
+ if result.success?
20
+ cooldown.record_success!
21
+ else
22
+ cooldown.record_failure!(result.error)
23
+ end
24
+ result
25
+ end
26
+
27
+ # ESPN year-only and phase-only scoreboards are capped (~100 events) and do not
28
+ # isolate a season. Full-season discovery expands leagues[].calendar into
29
+ # dates + seasontype + week requests, then concatenates unique events.
30
+ def self.acquire_full_season(adapter:, season_year:)
31
+ year = Integer(season_year)
32
+ seed = acquire(adapter: adapter, dates: year)
33
+ return seed unless seed.success?
34
+
35
+ requests = discovery_requests(year, seed.output.season_calendar)
36
+ return unavailable_calendar(year) if requests.empty?
37
+
38
+ events_by_id = {}
39
+ requests.each do |query|
40
+ result = acquire(adapter: adapter, **query)
41
+ return result unless result.success?
42
+
43
+ Array(result.output.events).each do |event|
44
+ events_by_id[event.id] ||= event
45
+ end
46
+ end
47
+
48
+ CommandTower::Clients::ClientResult.success(
49
+ output: Jumbotron::Deserializers::Clients::Espn::Scoreboard::Get::Result.build!(
50
+ events: events_by_id.values,
51
+ season_calendar: seed.output.season_calendar
52
+ )
53
+ )
54
+ end
55
+
56
+ def self.expand_full_season(adapter:, season_year:)
57
+ year = Integer(season_year)
58
+ seed = acquire(adapter: adapter, dates: year)
59
+ return seed unless seed.success?
60
+
61
+ requests = discovery_requests(year, seed.output.season_calendar)
62
+ return unavailable_calendar(year) if requests.empty?
63
+
64
+ CommandTower::Clients::ClientResult.success(output: requests)
65
+ end
66
+
67
+ def self.discovery_requests(year, season_calendar)
68
+ Array(season_calendar).filter_map do |phase|
69
+ next unless DISCOVERY_SEASON_TYPES.include?(phase.season_type)
70
+
71
+ phase.weeks.map do |week|
72
+ { dates: year, season_type: phase.season_type, week: week }
73
+ end
74
+ end.flatten
75
+ end
76
+ private_class_method :discovery_requests
77
+
78
+ def self.unavailable_calendar(year)
79
+ CommandTower::Clients::ClientResult.failure(
80
+ error: CommandTower::Clients::Errors::UpstreamError.new(
81
+ message: "ESPN scoreboard calendar missing for season #{year}; cannot expand full-season discovery"
82
+ )
83
+ )
84
+ end
85
+ private_class_method :unavailable_calendar
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Providers
5
+ module Espn
6
+ module Resources
7
+ class Teams
8
+ def self.acquire(adapter:, **query)
9
+ Jumbotron::Clients.espn.teams.get(
10
+ sport: adapter.sport,
11
+ league: adapter.league,
12
+ **query
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Scheduling
5
+ module Backends
6
+ class SidekiqCron
7
+ def initialize(job_class: nil)
8
+ @job_class = job_class
9
+ end
10
+
11
+ def materialize(schedules, **)
12
+ klass = job_class
13
+ remove_stale(klass, schedules)
14
+ upsert_desired(klass, schedules)
15
+ end
16
+
17
+ def cron_string(cadence)
18
+ Clock.cron(cadence)
19
+ end
20
+
21
+ private
22
+
23
+ def remove_stale(klass, schedules)
24
+ desired_ids = schedules.map(&:id)
25
+ Array(klass.all).each do |job|
26
+ name = job_name(job)
27
+ next unless Owned.key?(name)
28
+ next if desired_ids.include?(name)
29
+
30
+ destroy_job(klass, name, job)
31
+ end
32
+ end
33
+
34
+ def upsert_desired(klass, schedules)
35
+ schedules.sort_by(&:id).each do |definition|
36
+ klass.create(
37
+ "name" => definition.id,
38
+ "class" => definition.job_class_name,
39
+ "cron" => cron_string(definition.cadence),
40
+ "args" => [definition.arguments]
41
+ )
42
+ end
43
+ end
44
+
45
+ def job_class
46
+ @job_class || (defined?(::Sidekiq::Cron::Job) && ::Sidekiq::Cron::Job) ||
47
+ raise(UnsupportedIntegrationError, "Sidekiq::Cron::Job is not available")
48
+ end
49
+
50
+ def job_name(job)
51
+ if job.respond_to?(:name)
52
+ job.name
53
+ elsif job.is_a?(Hash)
54
+ job[:name] || job["name"]
55
+ else
56
+ job.to_s
57
+ end
58
+ end
59
+
60
+ def destroy_job(klass, name, job)
61
+ if job.respond_to?(:destroy)
62
+ job.destroy
63
+ elsif klass.respond_to?(:destroy)
64
+ klass.destroy(name)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "yaml"
6
+
7
+ module Jumbotron
8
+ module Scheduling
9
+ module Backends
10
+ class SolidQueue
11
+ ENV_SECTIONS = %w[development test production staging].freeze
12
+ DEFAULT_RELATIVE = "config/recurring.yml"
13
+
14
+ def initialize(path: nil)
15
+ @explicit_path = path
16
+ end
17
+
18
+ def materialize(schedules, path: nil)
19
+ destination = Pathname.new(path || @explicit_path || default_path)
20
+ document = load_document(destination)
21
+ section = current_section(document)
22
+ section.delete_if { |key, _| Owned.key?(key) }
23
+ schedules.sort_by(&:id).each do |definition|
24
+ section[definition.id] = task_for(definition)
25
+ end
26
+ write_document(destination, document)
27
+ destination
28
+ end
29
+
30
+ def schedule_string(cadence)
31
+ Clock.fugit(cadence)
32
+ end
33
+
34
+ private
35
+
36
+ def default_path
37
+ ENV.fetch("SOLID_QUEUE_RECURRING_SCHEDULE") do
38
+ ENV.fetch("JUMBOTRON_SOLID_QUEUE_RECURRING") do
39
+ Rails.root.join(DEFAULT_RELATIVE).to_s
40
+ end
41
+ end
42
+ end
43
+
44
+ def load_document(destination)
45
+ return {} unless destination.exist?
46
+
47
+ loaded = YAML.safe_load_file(destination, permitted_classes: [Symbol], aliases: true)
48
+ stringify_keys(loaded || {})
49
+ end
50
+
51
+ def current_section(document)
52
+ if document.empty? || env_keyed?(document)
53
+ document[Rails.env.to_s] ||= {}
54
+ else
55
+ document
56
+ end
57
+ end
58
+
59
+ def env_keyed?(document)
60
+ document.is_a?(Hash) && !document.empty? && document.keys.all? { |key| ENV_SECTIONS.include?(key.to_s) }
61
+ end
62
+
63
+ def task_for(definition)
64
+ {
65
+ "class" => definition.job_class_name,
66
+ "args" => [definition.arguments],
67
+ "schedule" => schedule_string(definition.cadence)
68
+ }
69
+ end
70
+
71
+ def write_document(destination, document)
72
+ FileUtils.mkdir_p(destination.dirname)
73
+ header = "# jumbotron:* keys are managed by `bin/rails jumbotron:schedules:materialize`.\n"
74
+ destination.write(header + YAML.dump(stringify_keys(document)))
75
+ end
76
+
77
+ def stringify_keys(value)
78
+ case value
79
+ when Hash
80
+ value.each_with_object({}) do |(key, nested), memo|
81
+ memo[key.to_s] = stringify_keys(nested)
82
+ end
83
+ when Array
84
+ value.map { |item| stringify_keys(item) }
85
+ else
86
+ value
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end