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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +5 -0
- data/app/adapters/jumbotron/adapters/base.rb +136 -0
- data/app/adapters/jumbotron/adapters/cadence.rb +42 -0
- data/app/adapters/jumbotron/adapters/discovery_definition.rb +7 -0
- data/app/adapters/jumbotron/adapters/espn/nfl.rb +169 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/acquisition_scope.rb +29 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/competition_odds.rb +185 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/lifecycle.rb +30 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/line_acquisition_scope.rb +26 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/progress.rb +54 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/scoreboard.rb +146 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/season_phase.rb +33 -0
- data/app/adapters/jumbotron/adapters/espn/nfl_helpers/teams.rb +35 -0
- data/app/adapters/jumbotron/adapters/line_acquisition.rb +19 -0
- data/app/adapters/jumbotron/adapters/operation.rb +23 -0
- data/app/adapters/jumbotron/adapters/policy_definition.rb +14 -0
- data/app/adapters/jumbotron/adapters/registry.rb +78 -0
- data/app/adapters/jumbotron/adapters.rb +11 -0
- data/app/boot/jumbotron/boot/enqueue_discoveries.rb +41 -0
- data/app/clients/jumbotron/clients/espn/competition_odds/get.rb +26 -0
- data/app/clients/jumbotron/clients/espn/competition_odds/get_input.rb +16 -0
- data/app/clients/jumbotron/clients/espn/scoreboard/get.rb +18 -0
- data/app/clients/jumbotron/clients/espn/scoreboard/get_input.rb +17 -0
- data/app/clients/jumbotron/clients/espn/teams/get.rb +18 -0
- data/app/clients/jumbotron/clients/espn/teams/get_input.rb +15 -0
- data/app/clients/jumbotron/clients/espn.rb +44 -0
- data/app/clients/jumbotron/clients.rb +8 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition.rb +79 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/draw_odds.rb +31 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/get.rb +66 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/item.rb +60 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/listed_provider.rb +31 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/market_window.rb +31 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/point_spread.rb +27 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/price.rb +35 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/side_quote.rb +35 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competition_odds/team_odds.rb +40 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/competitor.rb +49 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/event.rb +81 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/scoreboard/get.rb +88 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/season.rb +27 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/status.rb +69 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/support.rb +61 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/team.rb +42 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/teams/get.rb +76 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/venue.rb +34 -0
- data/app/deserializers/jumbotron/deserializers/clients/espn/week.rb +23 -0
- data/app/deserializers/jumbotron/deserializers/public/game.rb +26 -0
- data/app/deserializers/jumbotron/deserializers/public/game_ids.rb +28 -0
- data/app/deserializers/jumbotron/deserializers/public/includes.rb +32 -0
- data/app/deserializers/jumbotron/deserializers/public/schedule.rb +151 -0
- data/app/deserializers/jumbotron/deserializers/public/schedule_groups.rb +91 -0
- data/app/errors/jumbotron/error.rb +6 -0
- data/app/errors/jumbotron/errors/adapters.rb +143 -0
- data/app/errors/jumbotron/errors/canonical.rb +67 -0
- data/app/errors/jumbotron/errors/public.rb +27 -0
- data/app/errors/jumbotron/invalid_request_error.rb +6 -0
- data/app/errors/jumbotron/not_found_error.rb +6 -0
- data/app/errors/jumbotron/unsupported_capability_error.rb +6 -0
- data/app/jobs/jumbotron/application_job.rb +6 -0
- data/app/jobs/jumbotron/discovery_job.rb +14 -0
- data/app/jobs/jumbotron/game_update_job.rb +12 -0
- data/app/jobs/jumbotron/line_update_job.rb +12 -0
- data/app/models/jumbotron/application_record.rb +8 -0
- data/app/models/jumbotron/bookmaker.rb +11 -0
- data/app/models/jumbotron/canonical.rb +128 -0
- data/app/models/jumbotron/game.rb +92 -0
- data/app/models/jumbotron/game_participant.rb +10 -0
- data/app/models/jumbotron/historical_change.rb +9 -0
- data/app/models/jumbotron/league.rb +12 -0
- data/app/models/jumbotron/line_observation.rb +18 -0
- data/app/models/jumbotron/observation_batch.rb +14 -0
- data/app/models/jumbotron/provider_identity.rb +32 -0
- data/app/models/jumbotron/public/translate_outcome.rb +57 -0
- data/app/models/jumbotron/public.rb +88 -0
- data/app/models/jumbotron/schedule_group.rb +22 -0
- data/app/models/jumbotron/season.rb +13 -0
- data/app/models/jumbotron/season_phase.rb +12 -0
- data/app/models/jumbotron/sport.rb +10 -0
- data/app/models/jumbotron/team.rb +11 -0
- data/app/models/jumbotron/venue.rb +10 -0
- data/app/providers/jumbotron/providers/espn/cooldown.rb +88 -0
- data/app/providers/jumbotron/providers/espn/cooldown_active_error.rb +13 -0
- data/app/providers/jumbotron/providers/espn/provider_failure.rb +40 -0
- data/app/providers/jumbotron/providers/espn/resources/competition_odds.rb +29 -0
- data/app/providers/jumbotron/providers/espn/resources/scoreboard.rb +90 -0
- data/app/providers/jumbotron/providers/espn/resources/teams.rb +19 -0
- data/app/scheduling/jumbotron/scheduling/backends/sidekiq_cron.rb +70 -0
- data/app/scheduling/jumbotron/scheduling/backends/solid_queue.rb +92 -0
- data/app/scheduling/jumbotron/scheduling/clock.rb +61 -0
- data/app/scheduling/jumbotron/scheduling/desired_schedules.rb +51 -0
- data/app/scheduling/jumbotron/scheduling/detect_integration.rb +45 -0
- data/app/scheduling/jumbotron/scheduling/materializer.rb +13 -0
- data/app/scheduling/jumbotron/scheduling/owned.rb +19 -0
- data/app/scheduling/jumbotron/scheduling/schedule_definition.rb +7 -0
- data/app/serializers/jumbotron/serializers/clients/espn/competition_odds/get.rb +17 -0
- data/app/serializers/jumbotron/serializers/clients/espn/scoreboard/get.rb +22 -0
- data/app/serializers/jumbotron/serializers/clients/espn/teams/get.rb +20 -0
- data/app/services/jumbotron/services/adapters/build_acquisition_scopes.rb +37 -0
- data/app/services/jumbotron/services/adapters/build_line_acquisition_scopes.rb +39 -0
- data/app/services/jumbotron/services/adapters/ensure_league.rb +29 -0
- data/app/services/jumbotron/services/adapters/execute_endpoint.rb +49 -0
- data/app/services/jumbotron/services/adapters/execute_line_acquisition_scope.rb +75 -0
- data/app/services/jumbotron/services/adapters/execute_synchronization_scope.rb +64 -0
- data/app/services/jumbotron/services/adapters/resolve_discovery.rb +53 -0
- data/app/services/jumbotron/services/adapters/resolve_game_update_policy.rb +74 -0
- data/app/services/jumbotron/services/adapters/resolve_line_update_policy.rb +74 -0
- data/app/services/jumbotron/services/adapters/select_eligible_games.rb +41 -0
- data/app/services/jumbotron/services/adapters/validate_sync_request.rb +51 -0
- data/app/services/jumbotron/services/canonical/attach_provider_identities.rb +59 -0
- data/app/services/jumbotron/services/canonical/derive_consensus_for_games.rb +18 -0
- data/app/services/jumbotron/services/canonical/derive_consensus_line.rb +70 -0
- data/app/services/jumbotron/services/canonical/ensure_bookmaker.rb +70 -0
- data/app/services/jumbotron/services/canonical/ensure_schedule_group.rb +40 -0
- data/app/services/jumbotron/services/canonical/ensure_season.rb +22 -0
- data/app/services/jumbotron/services/canonical/ensure_season_phase.rb +22 -0
- data/app/services/jumbotron/services/canonical/persist_line_observations.rb +145 -0
- data/app/services/jumbotron/services/canonical/record_observation_evidence.rb +51 -0
- data/app/services/jumbotron/services/canonical/resolve_current_lines.rb +21 -0
- data/app/services/jumbotron/services/canonical/resolve_current_lines_for_games.rb +69 -0
- data/app/services/jumbotron/services/canonical/upsert_game.rb +186 -0
- data/app/services/jumbotron/services/canonical/upsert_game_participant.rb +88 -0
- data/app/services/jumbotron/services/canonical/upsert_team.rb +90 -0
- data/app/services/jumbotron/services/canonical/upsert_venue.rb +92 -0
- data/app/services/jumbotron/services/public/assemble_public_game.rb +131 -0
- data/app/services/jumbotron/services/public/assemble_public_schedule.rb +63 -0
- data/app/services/jumbotron/services/public/derive_consensus_for_games.rb +21 -0
- data/app/services/jumbotron/services/public/load_games_by_ids.rb +18 -0
- data/app/services/jumbotron/services/public/load_included_lines.rb +47 -0
- data/app/services/jumbotron/services/public/map_public_consensus_lines.rb +44 -0
- data/app/services/jumbotron/services/public/map_public_current_lines.rb +27 -0
- data/app/services/jumbotron/services/public/read_game.rb +32 -0
- data/app/services/jumbotron/services/public/read_schedule.rb +116 -0
- data/app/services/jumbotron/services/public/read_schedule_groups.rb +90 -0
- data/app/services/jumbotron/services/public/resolve_current_lines_for_games.rb +21 -0
- data/app/synchronization/jumbotron/synchronization/lease.rb +127 -0
- data/app/workflows/jumbotron/workflows/execute_discovery_workflow.rb +131 -0
- data/app/workflows/jumbotron/workflows/execute_game_update_policy_workflow.rb +159 -0
- data/app/workflows/jumbotron/workflows/execute_line_update_policy_workflow.rb +156 -0
- data/app/workflows/jumbotron/workflows/public_read.rb +29 -0
- data/app/workflows/jumbotron/workflows/read_consensus_workflow.rb +37 -0
- data/app/workflows/jumbotron/workflows/read_current_lines_workflow.rb +27 -0
- data/app/workflows/jumbotron/workflows/read_game_workflow.rb +34 -0
- data/app/workflows/jumbotron/workflows/read_schedule_groups_workflow.rb +21 -0
- data/app/workflows/jumbotron/workflows/read_schedule_workflow.rb +48 -0
- data/app/workflows/jumbotron/workflows/synchronize_adapter_workflow.rb +183 -0
- data/db/migrate/20260812000001_create_jumbotron_foundational_catalog.rb +68 -0
- data/db/migrate/20260812000002_create_jumbotron_game_graph.rb +70 -0
- data/db/migrate/20260812000003_add_freshness_to_jumbotron_provider_mutated_entities.rb +31 -0
- data/db/migrate/20260812000004_create_jumbotron_observation_evidence.rb +34 -0
- data/db/migrate/20260813000001_create_jumbotron_schedule_groups.rb +24 -0
- data/db/migrate/20260814000001_create_jumbotron_line_observations.rb +38 -0
- data/db/migrate/20260820000001_add_progress_to_jumbotron_games.rb +14 -0
- data/lib/jumbotron/client.rb +40 -0
- data/lib/jumbotron/engine.rb +32 -0
- data/lib/jumbotron/version.rb +5 -0
- data/lib/jumbotron.rb +13 -0
- data/lib/tasks/jumbotron_schedules.rake +12 -0
- 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,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
|