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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Scheduling
5
+ class Clock
6
+ MINUTE = 17
7
+ HOUR = 6
8
+ WEEKDAY = 3
9
+ WEEKDAY_NAME = "wednesday"
10
+
11
+ def self.clock_time
12
+ "#{HOUR}:#{format('%02d', MINUTE)} am"
13
+ end
14
+
15
+ def self.fugit(cadence)
16
+ every = cadence.every
17
+ case canonical_unit(cadence.unit)
18
+ when :second then every == 1 ? "every second" : "every #{every} seconds"
19
+ when :minute then every == 1 ? "every minute" : "every #{every} minutes"
20
+ when :hour then hour_fugit(every)
21
+ when :day then day_fugit(every)
22
+ when :week then "every #{WEEKDAY_NAME} at #{clock_time}"
23
+ else
24
+ raise ArgumentError, "unsupported cadence unit: #{cadence.unit}"
25
+ end
26
+ end
27
+
28
+ def self.cron(cadence)
29
+ every = cadence.every
30
+ case canonical_unit(cadence.unit)
31
+ when :minute then "*/#{every} * * * *"
32
+ when :hour then hour_cron(every)
33
+ when :day then day_cron(every)
34
+ when :week then "#{MINUTE} #{HOUR} * * #{WEEKDAY}"
35
+ else
36
+ raise ArgumentError, "unsupported cadence unit: #{cadence.unit}"
37
+ end
38
+ end
39
+
40
+ def self.canonical_unit(unit)
41
+ unit.to_s.sub(/s\z/, "").to_sym
42
+ end
43
+
44
+ def self.hour_fugit(every)
45
+ every == 1 ? "every hour at minute #{MINUTE}" : "every #{every} hours at minute #{MINUTE}"
46
+ end
47
+
48
+ def self.day_fugit(every)
49
+ every == 1 ? "every day at #{clock_time}" : "every #{every} days at #{clock_time}"
50
+ end
51
+
52
+ def self.hour_cron(every)
53
+ every == 1 ? "#{MINUTE} * * * *" : "#{MINUTE} */#{every} * * *"
54
+ end
55
+
56
+ def self.day_cron(every)
57
+ every == 1 ? "#{MINUTE} #{HOUR} * * *" : "#{MINUTE} #{HOUR} */#{every} * *"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Scheduling
5
+ module DesiredSchedules
6
+ GAME_JOB_CLASS_NAME = "Jumbotron::GameUpdateJob"
7
+ LINE_JOB_CLASS_NAME = "Jumbotron::LineUpdateJob"
8
+
9
+ module_function
10
+
11
+ def call
12
+ ensure_adapters_loaded!
13
+ Adapters::Registry.registered.flat_map { |adapter| schedules_for(adapter) }
14
+ end
15
+
16
+ def schedules_for(adapter)
17
+ adapter.policies.values.filter_map { |policy| definition_for(adapter, policy) }
18
+ end
19
+
20
+ def definition_for(adapter, policy)
21
+ return if adapter.discovery_ids.include?(policy.id)
22
+ return if policy.cadence.nil?
23
+
24
+ job_class_name = job_class_name_for(policy)
25
+ return if job_class_name.nil?
26
+
27
+ ScheduleDefinition.new(
28
+ id: Owned.id(adapter.adapter_id, policy.id),
29
+ job_class_name: job_class_name,
30
+ arguments: {
31
+ "adapter_id" => adapter.adapter_id.to_s,
32
+ "policy_id" => policy.id.to_s
33
+ },
34
+ cadence: policy.cadence
35
+ )
36
+ end
37
+
38
+ def job_class_name_for(policy)
39
+ if Services::Adapters::ResolveGameUpdatePolicy::UPDATE_POLICY_TYPES.include?(policy.type)
40
+ GAME_JOB_CLASS_NAME
41
+ elsif Services::Adapters::ResolveLineUpdatePolicy::LINE_POLICY_TYPES.include?(policy.type)
42
+ LINE_JOB_CLASS_NAME
43
+ end
44
+ end
45
+
46
+ def ensure_adapters_loaded!
47
+ Adapters::Registry.ensure_loaded!
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Scheduling
5
+ class UnsupportedIntegrationError < StandardError; end
6
+
7
+ module DetectIntegration
8
+ SUPPORTED = "Solid Queue, Sidekiq + Sidekiq-Cron"
9
+
10
+ module_function
11
+
12
+ def call
13
+ name = adapter_name
14
+ return Backends::SolidQueue.new if name == :solid_queue
15
+ return sidekiq_backend! if name == :sidekiq
16
+
17
+ raise_unsupported(name)
18
+ end
19
+
20
+ def adapter_name
21
+ return ActiveJob::Base.queue_adapter_name.to_s.to_sym if ActiveJob::Base.respond_to?(:queue_adapter_name)
22
+
23
+ configured = Rails.application.config.active_job.queue_adapter
24
+ return configured.to_sym if configured.is_a?(Symbol)
25
+
26
+ ActiveJob::Base.queue_adapter.class.name.to_s.demodulize.underscore.sub(/_adapter\z/, "").to_sym
27
+ end
28
+
29
+ def sidekiq_backend!
30
+ return Backends::SidekiqCron.new if defined?(::Sidekiq::Cron::Job)
31
+
32
+ raise UnsupportedIntegrationError,
33
+ "Jumbotron recurring schedules cannot be materialized for ActiveJob adapter :sidekiq " \
34
+ "because no supported recurring scheduler is present. " \
35
+ "Supported scheduling integrations: #{SUPPORTED}."
36
+ end
37
+
38
+ def raise_unsupported(name)
39
+ raise UnsupportedIntegrationError,
40
+ "Jumbotron recurring schedules cannot be materialized for ActiveJob adapter :#{name}. " \
41
+ "Supported scheduling integrations: #{SUPPORTED}."
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Scheduling
5
+ class Materializer
6
+ def self.call(schedules: nil, backend: nil, solid_queue_path: nil)
7
+ schedules ||= DesiredSchedules.call
8
+ backend ||= DetectIntegration.call
9
+ backend.materialize(schedules, path: solid_queue_path)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Scheduling
5
+ module Owned
6
+ PREFIX = "jumbotron:"
7
+
8
+ module_function
9
+
10
+ def key?(name)
11
+ name.to_s.start_with?(PREFIX)
12
+ end
13
+
14
+ def id(adapter_id, policy_id)
15
+ "#{PREFIX}#{adapter_id}:#{policy_id}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Scheduling
5
+ ScheduleDefinition = Data.define(:id, :job_class_name, :arguments, :cadence)
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Serializers
5
+ module Clients
6
+ module Espn
7
+ module CompetitionOdds
8
+ class Get
9
+ def self.call(_input)
10
+ CommandTower::Clients::SerializedRequest.build(query: {})
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Serializers
5
+ module Clients
6
+ module Espn
7
+ module Scoreboard
8
+ class Get
9
+ def self.call(input)
10
+ query = {}
11
+ query["seasontype"] = input.season_type unless input.season_type.nil?
12
+ query["week"] = input.week unless input.week.nil?
13
+ query["dates"] = input.dates unless input.dates.nil?
14
+
15
+ CommandTower::Clients::SerializedRequest.build(query: query)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Serializers
5
+ module Clients
6
+ module Espn
7
+ module Teams
8
+ class Get
9
+ def self.call(input)
10
+ query = {}
11
+ query["limit"] = input.limit unless input.limit.nil?
12
+
13
+ CommandTower::Clients::SerializedRequest.build(query: query)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class BuildAcquisitionScopes < CommandTower::Services::ApplicationService
7
+ validate :adapter, required: true
8
+ validate :games, required: true
9
+
10
+ def call
11
+ seen = {}
12
+ scopes = []
13
+ incomplete = 0
14
+ Array(games).each do |game|
15
+ scope = adapter.acquisition_scope_for(game)
16
+ if scope.nil?
17
+ incomplete += 1
18
+ next
19
+ end
20
+
21
+ key = [
22
+ scope.fetch(:endpoint),
23
+ Synchronization::Lease.normalized_acquisition_parts(scope.fetch(:acquisition))
24
+ ]
25
+ next if seen[key]
26
+
27
+ seen[key] = true
28
+ scopes << { endpoint: scope.fetch(:endpoint), acquisition: scope.fetch(:acquisition) }
29
+ end
30
+
31
+ context.scopes = scopes
32
+ context.skipped_incomplete_scope = incomplete
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class BuildLineAcquisitionScopes < CommandTower::Services::ApplicationService
7
+ validate :adapter, required: true
8
+ validate :games, required: true
9
+
10
+ def call
11
+ seen = {}
12
+ scopes = []
13
+ incomplete = 0
14
+ games_by_id = Game.where(id: Array(games).map(&:id)).includes(:provider_identities).index_by(&:id)
15
+ Array(games).each do |game|
16
+ scoped_game = games_by_id[game.id] || game
17
+ scope = adapter.line_acquisition_scope_for(scoped_game)
18
+ if scope.nil?
19
+ incomplete += 1
20
+ next
21
+ end
22
+
23
+ key = [
24
+ scope.fetch(:endpoint),
25
+ Synchronization::Lease.normalized_acquisition_parts(scope.fetch(:acquisition))
26
+ ]
27
+ next if seen[key]
28
+
29
+ seen[key] = true
30
+ scopes << { endpoint: scope.fetch(:endpoint), acquisition: scope.fetch(:acquisition) }
31
+ end
32
+
33
+ context.scopes = scopes
34
+ context.skipped_incomplete_scope = incomplete
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class EnsureLeague < CommandTower::Services::ApplicationService
7
+ validate :adapter, required: true
8
+
9
+ def call
10
+ sport = find_or_create_sport
11
+ league = find_or_create_league(sport)
12
+ context.league = league
13
+ end
14
+
15
+ private
16
+
17
+ def find_or_create_sport
18
+ existing = Jumbotron::Sport.where("LOWER(name) = ?", adapter.sport.downcase).first
19
+ existing || Jumbotron::Sport.create!(name: adapter.sport)
20
+ end
21
+
22
+ def find_or_create_league(sport)
23
+ existing = sport.leagues.where("LOWER(name) = ?", adapter.league.downcase).first
24
+ existing || sport.leagues.create!(name: adapter.league)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class ExecuteEndpoint < CommandTower::Services::ApplicationService
7
+ validate :adapter, required: true
8
+ validate :endpoint, required: true
9
+ validate :observed_at, required: true
10
+ validate :acquisition, is_a: Hash, required: false
11
+
12
+ def call
13
+ operation = adapter.operation(endpoint)
14
+ provider_result = operation.acquire(**(acquisition || {}))
15
+ unless provider_result.success?
16
+ fail_acquisition!(provider_result)
17
+ return
18
+ end
19
+
20
+ begin
21
+ context.sync_input = operation.transform(provider_result.output, observed_at: observed_at)
22
+ rescue Jumbotron::Adapters::TransformError => e
23
+ context.fail!(
24
+ application_error: Jumbotron::Errors::Adapters::NormalizationFailedError.new(
25
+ details: { message: e.message }
26
+ )
27
+ )
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def fail_acquisition!(provider_result)
34
+ error = provider_result.error
35
+ if error.is_a?(Jumbotron::Providers::Espn::CooldownActiveError)
36
+ context.fail!(application_error: Jumbotron::Errors::Adapters::ProviderCooldownActiveError.new)
37
+ return
38
+ end
39
+
40
+ context.fail!(
41
+ application_error: Jumbotron::Errors::Adapters::AcquisitionFailedError.new(
42
+ details: { message: error&.message || "provider acquisition failed" }
43
+ )
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class ExecuteLineAcquisitionScope < CommandTower::Services::ApplicationService
7
+ validate :adapter, required: true
8
+ validate :acquisition, is_a: Hash, required: true
9
+
10
+ def call
11
+ if adapter.provider_cooling_down?
12
+ context.outcome = :cooldown_active
13
+ return
14
+ end
15
+
16
+ lease = Synchronization::Lease.new
17
+ lease_scope = Synchronization::Lease.scope(
18
+ adapter_id: adapter.adapter_id,
19
+ endpoint: :competition_odds,
20
+ acquisition: acquisition
21
+ )
22
+ held = lease.acquire(lease_scope)
23
+ unless held.acquired?
24
+ context.outcome = :contention
25
+ return
26
+ end
27
+
28
+ begin
29
+ execute_held
30
+ ensure
31
+ lease.release(lease_scope, token: held.token)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def execute_held
38
+ provider_result = adapter.acquire_line_odds(acquisition)
39
+ unless provider_result.success?
40
+ record_acquisition_failure(provider_result)
41
+ return
42
+ end
43
+
44
+ ingest = adapter.translate_line_odds(
45
+ provider_result.output,
46
+ observed_at: Time.current,
47
+ acquisition: acquisition
48
+ )
49
+ persisted = Canonical::PersistLineObservations.call(ingest: ingest)
50
+ unless persisted.success?
51
+ context.outcome = :failed
52
+ context.failure_class = "mutation"
53
+ return
54
+ end
55
+
56
+ context.outcome = :succeeded
57
+ rescue Jumbotron::Adapters::TransformError
58
+ context.outcome = :failed
59
+ context.failure_class = "mutation"
60
+ end
61
+
62
+ def record_acquisition_failure(provider_result)
63
+ error = provider_result.error
64
+ if error.is_a?(Jumbotron::Providers::Espn::CooldownActiveError)
65
+ context.outcome = :cooldown_active
66
+ return
67
+ end
68
+
69
+ context.outcome = :failed
70
+ context.failure_class = "acquisition"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ # Isolates lease + existing SynchronizeAdapterWorkflow.call. The workflow-calls-workflow
7
+ # rule remains deferred; do not invent a SharedSequence here.
8
+ class ExecuteSynchronizationScope < CommandTower::Services::ApplicationService
9
+ validate :adapter, required: true
10
+ validate :league, required: true
11
+ validate :endpoint, required: true
12
+ validate :acquisition, is_a: Hash, required: true
13
+
14
+ def call
15
+ lease = Synchronization::Lease.new
16
+ lease_scope = Synchronization::Lease.scope(
17
+ adapter_id: adapter.adapter_id,
18
+ endpoint: endpoint,
19
+ acquisition: acquisition
20
+ )
21
+ held = lease.acquire(lease_scope)
22
+ unless held.acquired?
23
+ context.outcome = :contention
24
+ return
25
+ end
26
+
27
+ begin
28
+ record_sync_outcome(
29
+ Workflows::SynchronizeAdapterWorkflow.call(
30
+ adapter: adapter,
31
+ endpoint: endpoint,
32
+ league_id: league.id,
33
+ acquisition: acquisition
34
+ )
35
+ )
36
+ ensure
37
+ lease.release(lease_scope, token: held.token)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def record_sync_outcome(result)
44
+ if result.success?
45
+ context.outcome = :succeeded
46
+ return
47
+ end
48
+
49
+ code = Array(result.errors).first
50
+ code = code[:code] if code.is_a?(Hash)
51
+ code = code.code if code.respond_to?(:code)
52
+
53
+ if code.to_s == "provider_cooldown_active"
54
+ context.outcome = :cooldown_active
55
+ return
56
+ end
57
+
58
+ context.outcome = :failed
59
+ context.failure_class = result.meta[:failure_class]
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class ResolveDiscovery < CommandTower::Services::ApplicationService
7
+ validate :adapter_id, required: true
8
+ validate :discovery_id, required: true
9
+
10
+ def call
11
+ Jumbotron::Adapters::Registry.ensure_loaded!
12
+ adapter = Jumbotron::Adapters::Registry.find(adapter_id)
13
+ if adapter.nil?
14
+ context.fail!(
15
+ application_error: Jumbotron::Errors::Adapters::UnknownAdapterError.new(
16
+ details: { adapter_id: adapter_id }
17
+ )
18
+ )
19
+ return
20
+ end
21
+
22
+ unless adapter.registered?
23
+ context.fail!(
24
+ application_error: Jumbotron::Errors::Adapters::NotRegisteredError.new(
25
+ details: { adapter: adapter.to_s }
26
+ )
27
+ )
28
+ return
29
+ end
30
+
31
+ definition = lookup_discovery(adapter)
32
+ return if definition.nil?
33
+
34
+ context.adapter = adapter
35
+ context.definition = definition
36
+ end
37
+
38
+ private
39
+
40
+ def lookup_discovery(adapter)
41
+ adapter.discovery(discovery_id.to_sym)
42
+ rescue Jumbotron::Adapters::UnknownDiscoveryError
43
+ context.fail!(
44
+ application_error: Jumbotron::Errors::Adapters::UnknownDiscoveryError.new(
45
+ details: { discovery_id: discovery_id }
46
+ )
47
+ )
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jumbotron
4
+ module Services
5
+ module Adapters
6
+ class ResolveGameUpdatePolicy < CommandTower::Services::ApplicationService
7
+ UPDATE_POLICY_TYPES = %i[
8
+ future_game_update
9
+ live_game_update
10
+ interrupted_game_update
11
+ ].freeze
12
+
13
+ validate :adapter_id, required: true
14
+ validate :policy_id, required: true
15
+
16
+ def call
17
+ Jumbotron::Adapters::Registry.ensure_loaded!
18
+ adapter = Jumbotron::Adapters::Registry.find(adapter_id)
19
+ if adapter.nil?
20
+ context.fail!(
21
+ application_error: Jumbotron::Errors::Adapters::UnknownAdapterError.new(
22
+ details: { adapter_id: adapter_id }
23
+ )
24
+ )
25
+ return
26
+ end
27
+
28
+ unless adapter.registered?
29
+ context.fail!(
30
+ application_error: Jumbotron::Errors::Adapters::NotRegisteredError.new(
31
+ details: { adapter: adapter.to_s }
32
+ )
33
+ )
34
+ return
35
+ end
36
+
37
+ key = policy_id.to_sym
38
+ if adapter.discovery_ids.include?(key)
39
+ context.fail!(
40
+ application_error: Jumbotron::Errors::Adapters::DiscoveryNotExecutableError.new(
41
+ details: { policy_id: policy_id }
42
+ )
43
+ )
44
+ return
45
+ end
46
+
47
+ policy = lookup_policy(adapter, key)
48
+ return if policy.nil?
49
+
50
+ unless UPDATE_POLICY_TYPES.include?(policy.type)
51
+ context.fail!(application_error: Jumbotron::Errors::Adapters::InvalidPolicyTypeError.new)
52
+ return
53
+ end
54
+
55
+ context.adapter = adapter
56
+ context.policy = policy
57
+ end
58
+
59
+ private
60
+
61
+ def lookup_policy(adapter, key)
62
+ adapter.policy(key)
63
+ rescue Jumbotron::Adapters::UnknownPolicyError
64
+ context.fail!(
65
+ application_error: Jumbotron::Errors::Adapters::UnknownPolicyError.new(
66
+ details: { policy_id: policy_id }
67
+ )
68
+ )
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end