hastci 0.1.0

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.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module HastCI
6
+ class Config
7
+ DEFAULT_API_BASE_URL = "https://hastci.com/api"
8
+ DEFAULT_CLAIM_BATCH_SIZE = 10
9
+ DEFAULT_HEARTBEAT_INTERVAL = 15
10
+ DEFAULT_SEEDING_TIMEOUT = 300
11
+ DEFAULT_LOG_LEVEL = "INFO"
12
+ VALID_LOG_LEVELS = %w[DEBUG INFO WARN ERROR FATAL].freeze
13
+
14
+ private_constant :DEFAULT_API_BASE_URL, :DEFAULT_CLAIM_BATCH_SIZE, :DEFAULT_HEARTBEAT_INTERVAL,
15
+ :DEFAULT_SEEDING_TIMEOUT, :DEFAULT_LOG_LEVEL, :VALID_LOG_LEVELS
16
+
17
+ attr_reader :api_key, :api_base_url, :api_max_retries,
18
+ :run_key, :worker_id, :commit_sha,
19
+ :claim_batch_size, :heartbeat_interval, :seeding_timeout, :log_level, :debug
20
+
21
+ def self.load_from_env(env)
22
+ if env["GITHUB_ACTIONS"] == "true"
23
+ from_github_actions(env)
24
+ else
25
+ from_generic_ci(env)
26
+ end
27
+ rescue KeyError => e
28
+ raise ConfigurationError, "Missing required env var: #{e.key}"
29
+ end
30
+
31
+ def self.from_github_actions(env)
32
+ run_id = env.fetch("GITHUB_RUN_ID")
33
+ run_attempt = env.fetch("GITHUB_RUN_ATTEMPT")
34
+ commit_sha = env.fetch("GITHUB_SHA")
35
+ node_index = env.fetch("CI_NODE_INDEX")
36
+
37
+ new(
38
+ api_key: env.fetch("HASTCI_API_KEY"),
39
+ api_base_url: env.fetch("HASTCI_API_URL", DEFAULT_API_BASE_URL),
40
+ api_max_retries: env["HASTCI_API_MAX_RETRIES"]&.to_i,
41
+ run_key: "gha-#{run_id}-#{run_attempt}-#{commit_sha}",
42
+ worker_id: "gha-worker-#{node_index}",
43
+ commit_sha: commit_sha,
44
+ claim_batch_size: env.fetch("HASTCI_CLAIM_BATCH_SIZE", DEFAULT_CLAIM_BATCH_SIZE).to_i,
45
+ heartbeat_interval: env.fetch("HASTCI_HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL).to_i,
46
+ seeding_timeout: env.fetch("HASTCI_SEEDING_TIMEOUT", DEFAULT_SEEDING_TIMEOUT).to_i,
47
+ log_level: env.fetch("HASTCI_LOG_LEVEL", DEFAULT_LOG_LEVEL),
48
+ debug: env["HASTCI_DEBUG"] == "true"
49
+ )
50
+ end
51
+
52
+ def self.from_generic_ci(env)
53
+ worker_id = env["HASTCI_WORKER_ID"] ||
54
+ (env["CI_NODE_INDEX"] && "worker-#{env["CI_NODE_INDEX"]}") ||
55
+ "worker-#{SecureRandom.hex(4)}"
56
+
57
+ new(
58
+ api_key: env.fetch("HASTCI_API_KEY"),
59
+ api_base_url: env.fetch("HASTCI_API_URL", DEFAULT_API_BASE_URL),
60
+ api_max_retries: env["HASTCI_API_MAX_RETRIES"]&.to_i,
61
+ run_key: env.fetch("HASTCI_RUN_KEY"),
62
+ worker_id: worker_id,
63
+ commit_sha: env["HASTCI_COMMIT_SHA"],
64
+ claim_batch_size: env.fetch("HASTCI_CLAIM_BATCH_SIZE", DEFAULT_CLAIM_BATCH_SIZE).to_i,
65
+ heartbeat_interval: env.fetch("HASTCI_HEARTBEAT_INTERVAL", DEFAULT_HEARTBEAT_INTERVAL).to_i,
66
+ seeding_timeout: env.fetch("HASTCI_SEEDING_TIMEOUT", DEFAULT_SEEDING_TIMEOUT).to_i,
67
+ log_level: env.fetch("HASTCI_LOG_LEVEL", DEFAULT_LOG_LEVEL),
68
+ debug: env["HASTCI_DEBUG"] == "true"
69
+ )
70
+ end
71
+
72
+ private_class_method :from_github_actions, :from_generic_ci
73
+
74
+ def initialize(
75
+ api_key:,
76
+ api_base_url:,
77
+ api_max_retries:,
78
+ run_key:,
79
+ worker_id:,
80
+ commit_sha:,
81
+ claim_batch_size:,
82
+ heartbeat_interval:,
83
+ seeding_timeout:,
84
+ log_level:,
85
+ debug: false
86
+ )
87
+ raise ConfigurationError, "worker_id cannot be blank" if worker_id.nil? || worker_id.empty?
88
+ raise ConfigurationError, "claim_batch_size must be positive" if claim_batch_size <= 0
89
+ raise ConfigurationError, "heartbeat_interval must be positive" if heartbeat_interval <= 0
90
+ raise ConfigurationError, "seeding_timeout must be positive" if seeding_timeout <= 0
91
+
92
+ @api_key = api_key
93
+ @api_base_url = api_base_url
94
+ @api_max_retries = api_max_retries
95
+ @run_key = run_key
96
+ @worker_id = worker_id
97
+ @commit_sha = commit_sha
98
+ @claim_batch_size = claim_batch_size
99
+ @heartbeat_interval = heartbeat_interval
100
+ @seeding_timeout = seeding_timeout
101
+ @log_level = normalize_log_level(log_level)
102
+ @debug = debug
103
+ end
104
+
105
+ private
106
+
107
+ def normalize_log_level(level)
108
+ normalized = level.to_s.upcase
109
+ VALID_LOG_LEVELS.include?(normalized) ? normalized : DEFAULT_LOG_LEVEL
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class ConfigurationError < Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class ErrorCollector
5
+ def initialize
6
+ @mutex = Mutex.new
7
+ @errors = []
8
+ end
9
+
10
+ def report(error)
11
+ @mutex.synchronize { @errors << error unless @errors.any? }
12
+ end
13
+
14
+ def first_error
15
+ @mutex.synchronize { @errors.first }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ module ExitCodes
5
+ SUCCESS = 0
6
+ TEST_FAILURES = 1
7
+ CONFIGURATION_ERROR = 2
8
+ API_ERROR = 3
9
+ NETWORK_ERROR = 4
10
+ CANCELLED = 5
11
+ INTERNAL_ERROR = 6
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class FatalApiError < ApiError; end
5
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class Heartbeat
5
+ SHUTDOWN_TIMEOUT = 5
6
+ MAX_CONSECUTIVE_FAILURES = 5
7
+
8
+ private_constant :SHUTDOWN_TIMEOUT, :MAX_CONSECUTIVE_FAILURES
9
+
10
+ def initialize(api_client:, error_collector: nil, interval: 15)
11
+ @api_client = api_client
12
+ @error_collector = error_collector
13
+ @interval = interval
14
+ @running = false
15
+ @thread = nil
16
+ @mutex = Mutex.new
17
+ @stop_condition = ConditionVariable.new
18
+ @consecutive_failures = 0
19
+ end
20
+
21
+ def start
22
+ @mutex.synchronize do
23
+ return if @running
24
+
25
+ @running = true
26
+ @thread = Thread.new { run_loop }
27
+ end
28
+ end
29
+
30
+ def stop
31
+ @mutex.synchronize do
32
+ return unless @running
33
+
34
+ @running = false
35
+ @stop_condition.signal
36
+ end
37
+
38
+ @thread.join(SHUTDOWN_TIMEOUT)
39
+ end
40
+
41
+ def running?
42
+ @mutex.synchronize { @running }
43
+ end
44
+
45
+ private
46
+
47
+ def run_loop
48
+ while running? && !fatal_error
49
+ send_heartbeat
50
+ wait_for_next_heartbeat
51
+ end
52
+ end
53
+
54
+ def wait_for_next_heartbeat
55
+ @mutex.synchronize do
56
+ return unless @running
57
+
58
+ @stop_condition.wait(@mutex, @interval)
59
+ end
60
+ end
61
+
62
+ def fatal_error
63
+ @error_collector&.first_error
64
+ end
65
+
66
+ def send_heartbeat
67
+ @api_client.heartbeat
68
+ @consecutive_failures = 0
69
+ rescue RetryExhaustedError, FatalApiError => e
70
+ @error_collector&.report(e)
71
+ HastCI.logger.error("Heartbeat failed: #{e.message}")
72
+ rescue RetryableError, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
73
+ Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
74
+ @consecutive_failures += 1
75
+ HastCI.logger.warn("Heartbeat failed (#{@consecutive_failures}/#{MAX_CONSECUTIVE_FAILURES}): #{e.class} - #{e.message}")
76
+
77
+ if @consecutive_failures >= MAX_CONSECUTIVE_FAILURES
78
+ error = FatalApiError.new("Heartbeat failed #{MAX_CONSECUTIVE_FAILURES} consecutive times")
79
+ @error_collector&.report(error)
80
+ HastCI.logger.error("Heartbeat: max consecutive failures reached")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ module Pact
5
+ PACT_FILENAME = "hastci_rspec-hastci_api.json"
6
+
7
+ def self.contract_path
8
+ File.expand_path("../../spec/pacts/#{PACT_FILENAME}", __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class QueueDrained < ApiError; end
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class RetryExhaustedError < ApiError
5
+ attr_reader :original_error
6
+
7
+ def initialize(message, status_code: nil, response_body: nil, original_error: nil)
8
+ @original_error = original_error
9
+ super(message, status_code: status_code, response_body: response_body)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class RetryableError < ApiError; end
5
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class Session
5
+ DEFAULT_POLL_INTERVAL = 0.5
6
+ DEFAULT_BUFFER_MIN_SIZE = 3
7
+ DEFAULT_BUFFER_MAX_SIZE = 10
8
+ DEFAULT_SEEDING_POLL_INTERVAL = 1.0
9
+
10
+ STOP_REASONS = %i[user_interrupt server_cancelled].freeze
11
+
12
+ private_constant :DEFAULT_POLL_INTERVAL, :DEFAULT_BUFFER_MIN_SIZE, :DEFAULT_BUFFER_MAX_SIZE,
13
+ :DEFAULT_SEEDING_POLL_INTERVAL
14
+
15
+ def self.run(config:, **options)
16
+ session = new(config: config, **options)
17
+
18
+ begin
19
+ yield session
20
+ ensure
21
+ begin
22
+ cleanup_result = session.cleanup
23
+ ensure
24
+ session.shutdown!
25
+ end
26
+ end
27
+
28
+ cleanup_result
29
+ end
30
+
31
+ def initialize(
32
+ config:,
33
+ api_client: nil,
34
+ ack_worker: nil,
35
+ task_buffer: nil,
36
+ poll_interval: DEFAULT_POLL_INTERVAL,
37
+ buffer_min_size: DEFAULT_BUFFER_MIN_SIZE,
38
+ buffer_max_size: DEFAULT_BUFFER_MAX_SIZE,
39
+ sleeper: Kernel.method(:sleep),
40
+ seeding_poll_interval: DEFAULT_SEEDING_POLL_INTERVAL,
41
+ error_collector: ErrorCollector.new
42
+ )
43
+ @config = config
44
+ @api_client = api_client || ApiClient.new(config: config)
45
+ @ack_worker = ack_worker
46
+ @task_buffer = task_buffer
47
+ @poll_interval = poll_interval
48
+ @buffer_min_size = buffer_min_size
49
+ @buffer_max_size = buffer_max_size
50
+ @sleeper = sleeper
51
+ @seeding_poll_interval = seeding_poll_interval
52
+ @error_collector = error_collector
53
+
54
+ @run_id = nil
55
+ @status = nil
56
+ @role = nil
57
+ @heartbeat = nil
58
+
59
+ @stop_reason = nil
60
+ @stop_mutex = Mutex.new
61
+ end
62
+
63
+ attr_reader :run_id, :status, :role, :error_collector, :config
64
+
65
+ def first_error
66
+ @error_collector.first_error
67
+ end
68
+
69
+ def request_stop!(reason)
70
+ raise ArgumentError, "Invalid stop reason: #{reason}" unless STOP_REASONS.include?(reason)
71
+
72
+ should_stop_buffer = false
73
+ @stop_mutex.synchronize do
74
+ return if @stop_reason
75
+
76
+ @stop_reason = reason
77
+ should_stop_buffer = user_initiated_stop?(reason)
78
+ end
79
+
80
+ @task_buffer&.stop if should_stop_buffer
81
+ end
82
+
83
+ def stopping?
84
+ @stop_mutex.synchronize { !@stop_reason.nil? }
85
+ end
86
+
87
+ def stop_reason
88
+ @stop_mutex.synchronize { @stop_reason }
89
+ end
90
+
91
+ def cancelled?
92
+ stop_reason == :server_cancelled
93
+ end
94
+
95
+ def start!
96
+ connect! unless @run_id
97
+ start_heartbeat!
98
+
99
+ unless seeder?
100
+ wait_for_ready!
101
+ return if stopping?
102
+ end
103
+
104
+ start_task_buffer!
105
+ end
106
+
107
+ def connect!
108
+ run_state = @api_client.init_run
109
+
110
+ @run_id = run_state.fetch(:run_id)
111
+ @status = run_state.fetch(:status)
112
+ @role = run_state.fetch(:role)
113
+
114
+ HastCI.logger.info("[HastCI] Connected: run_id=#{@run_id} role=#{@role}")
115
+
116
+ @ack_worker ||= AckWorker.new(api_client: @api_client, error_collector: @error_collector)
117
+ @ack_worker.start
118
+
119
+ self
120
+ end
121
+
122
+ def start_task_buffer!
123
+ return if stopping?
124
+
125
+ @task_buffer ||= build_task_buffer
126
+ @task_buffer.start
127
+ end
128
+
129
+ def wait_for_ready!
130
+ return unless @status == :seeding
131
+
132
+ HastCI.logger.info("[HastCI] Waiting for seeding (timeout: #{@config.seeding_timeout}s)...")
133
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @config.seeding_timeout
134
+
135
+ while @status == :seeding
136
+ return if stopping?
137
+
138
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
139
+ raise FatalApiError, "Timed out waiting for seeding to complete (#{@config.seeding_timeout}s). " \
140
+ "The seeder may have failed."
141
+ end
142
+
143
+ run_state = @api_client.run_status(run_id: @run_id)
144
+ @status = run_state.fetch(:status)
145
+
146
+ break unless @status == :seeding
147
+
148
+ @sleeper.call(@seeding_poll_interval)
149
+ end
150
+
151
+ HastCI.logger.info("[HastCI] Seeding complete, status=#{@status}")
152
+ end
153
+
154
+ def seeder?
155
+ role == :seeder
156
+ end
157
+
158
+ def seed(tasks:)
159
+ ensure_connected!
160
+
161
+ HastCI.logger.info("[HastCI] Seeding #{tasks.size} tasks...")
162
+ @api_client.seed(run_id: run_id, tasks: tasks)
163
+ HastCI.logger.info("[HastCI] Seeding complete")
164
+ end
165
+
166
+ def each_task
167
+ return enum_for(:each_task) unless block_given?
168
+
169
+ ensure_connected!
170
+
171
+ loop do
172
+ return if stopping?
173
+
174
+ check_for_fatal_error!
175
+
176
+ task = @task_buffer.next_task
177
+ return if task.nil?
178
+
179
+ yield task
180
+ end
181
+ end
182
+
183
+ def ack(result)
184
+ ensure_connected!
185
+
186
+ @ack_worker.enqueue(result)
187
+ end
188
+
189
+ def flush_acks!(timeout: 10)
190
+ return true unless @ack_worker
191
+
192
+ @ack_worker.flush(timeout: timeout)
193
+ end
194
+
195
+ def disconnect!
196
+ @task_buffer&.stop
197
+ @ack_worker&.stop
198
+ @api_client.disconnect!
199
+ end
200
+
201
+ def start_heartbeat!
202
+ return if @heartbeat
203
+
204
+ @heartbeat = Heartbeat.new(
205
+ api_client: @api_client,
206
+ interval: @config.heartbeat_interval,
207
+ error_collector: @error_collector
208
+ )
209
+ @heartbeat.start
210
+ end
211
+
212
+ def cleanup
213
+ flush_ok = flush_acks!(timeout: 10)
214
+ HastCI.logger.info("[HastCI] Cleanup: flush_ok=#{flush_ok}")
215
+
216
+ {
217
+ flush_ok: flush_ok,
218
+ first_error: first_error,
219
+ stop_reason: stop_reason
220
+ }
221
+ end
222
+
223
+ def shutdown!
224
+ @heartbeat&.stop
225
+ disconnect!
226
+ end
227
+
228
+ private
229
+
230
+ def build_task_buffer
231
+ TaskBuffer.new(
232
+ min_size: @buffer_min_size,
233
+ max_size: @buffer_max_size,
234
+ fetcher: lambda { |limit|
235
+ batch_size = [limit, @config.claim_batch_size].min
236
+ @api_client.claim(batch: batch_size)
237
+ },
238
+ error_collector: @error_collector,
239
+ poll_interval: @poll_interval,
240
+ on_cancelled: -> { request_stop!(:server_cancelled) }
241
+ )
242
+ end
243
+
244
+ def check_for_fatal_error!
245
+ error = @error_collector.first_error
246
+ raise error if error
247
+ end
248
+
249
+ def ensure_connected!
250
+ return if run_id
251
+
252
+ raise HastCI::Error, "Session is not connected. Call #connect! first."
253
+ end
254
+
255
+ def user_initiated_stop?(reason)
256
+ reason == :user_interrupt
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HastCI
4
+ class Task
5
+ attr_reader :id, :name
6
+
7
+ def initialize(id:, name:)
8
+ @id = id
9
+ @name = name
10
+ freeze
11
+ end
12
+
13
+ def ==(other)
14
+ other.is_a?(Task) && other.id == id && other.name == name
15
+ end
16
+
17
+ alias_method :eql?, :==
18
+
19
+ def hash
20
+ [id, name].hash
21
+ end
22
+ end
23
+ end