Skip to content

Commit abc2485

Browse files
committed
Clean up infrastructure boundaries
1 parent 2d54bf2 commit abc2485

21 files changed

Lines changed: 750 additions & 705 deletions

bin/discord_bot

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ require_relative '../lib/polish_open_source_rank'
55

66
configuration = PolishOpenSourceRank::Configuration.load
77

8-
PolishOpenSourceRank::Infrastructure::DiscordInviteBot.build(configuration: configuration).run
8+
PolishOpenSourceRank::Contexts::Community::Infrastructure::Discord::DiscordInviteBot
9+
.build(configuration: configuration)
10+
.run

lib/polish_open_source_rank/configuration/configuration.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
require 'dry/configurable'
44
require_relative 'definitions'
5+
require_relative 'env_file'
56
require_relative 'groups'
67
require_relative 'secrets_policy'
7-
require_relative '../env_file'
88

99
module PolishOpenSourceRank
1010
# Loads application configuration from local env files and process ENV while preserving typed public getters.
File renamed without changes.
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
# frozen_string_literal: true
2+
3+
require 'sequel'
4+
5+
module PolishOpenSourceRank
6+
module Contexts
7+
module Operations
8+
module Infrastructure
9+
module SQLite
10+
class SQLiteJobProgress
11+
STALE_SECONDS = 10 * 60
12+
WORK_EVENT_STATS_SAMPLE_SIZE = 5000
13+
MONTHLY_PLATFORM_ORDER = %w[github gitlab codeberg].freeze
14+
PACKAGE_STAGE_ORDER = %w[
15+
repository_scan
16+
manifest_parse
17+
registry_resolve
18+
registry_snapshot
19+
].freeze
20+
RECENT_ERRORS_SQL = <<~SQL
21+
SELECT job_kind, stage, unit_kind, platform, ecosystem, subject_label, error, finished_at
22+
FROM job_work_events
23+
WHERE error IS NOT NULL
24+
ORDER BY datetime(finished_at) DESC, id DESC
25+
LIMIT 30
26+
SQL
27+
WORK_EVENT_STATS_SQL = <<~SQL.freeze
28+
WITH recent_events AS (
29+
SELECT duration_ms, finished_at, id
30+
FROM job_work_events
31+
WHERE %<filters>s
32+
ORDER BY finished_at DESC, id DESC
33+
LIMIT #{WORK_EVENT_STATS_SAMPLE_SIZE}
34+
),
35+
ranked AS (
36+
SELECT duration_ms,
37+
finished_at,
38+
ROW_NUMBER() OVER (ORDER BY duration_ms) AS duration_rank,
39+
COUNT(*) OVER () AS events_count
40+
FROM recent_events
41+
)
42+
SELECT events_count,
43+
MIN(finished_at) AS first_finished_at,
44+
MAX(finished_at) AS last_finished_at,
45+
ROUND(AVG(duration_ms)) AS average_ms,
46+
MAX(CASE
47+
WHEN duration_rank = ((events_count + 1) / 2) THEN duration_ms
48+
END) AS median_ms,
49+
MAX(CASE
50+
WHEN duration_rank = ((events_count * 95 + 99) / 100) THEN duration_ms
51+
END) AS p95_ms
52+
FROM ranked
53+
GROUP BY events_count
54+
SQL
55+
56+
def initialize(database, stale_seconds: STALE_SECONDS)
57+
@database = database
58+
@stale_seconds = stale_seconds
59+
end
60+
61+
def call(now: Time.now.utc)
62+
run = current_run
63+
package_run = current_package_run
64+
period_start = run&.fetch(:period_start) || package_run&.fetch(:period_start)
65+
66+
{
67+
generated_at: now.iso8601,
68+
run: run&.slice(:period_start, :period_end, :status, :started_at, :finished_at, :error),
69+
package_run: package_run&.slice(:period_start, :ecosystem, :status, :started_at, :finished_at, :error),
70+
platforms: [],
71+
progress_points: [],
72+
request_points: request_points(run || package_run, now),
73+
sections: period_start ? sections(period_start, now) : [],
74+
recent_events: recent_events,
75+
recent_errors: fetch_all(RECENT_ERRORS_SQL)
76+
}
77+
end
78+
79+
private
80+
81+
attr_reader :database, :stale_seconds
82+
83+
def current_run
84+
sync_runs_dataset
85+
.select(:period_start, :period_end, :status, :started_at, :finished_at, :error)
86+
.order(Sequel.desc(Sequel.function(:datetime, :started_at)), Sequel.desc(:period_start))
87+
.first
88+
end
89+
90+
def current_package_run
91+
return unless table?(:package_crawl_runs)
92+
93+
package_crawl_runs_dataset
94+
.select(:period_start, :ecosystem, :status, :started_at, :finished_at, :error)
95+
.order(Sequel.desc(Sequel.function(:datetime, :started_at)), Sequel.desc(:id))
96+
.first
97+
end
98+
99+
def sections(period_start, now)
100+
monthly_sections(period_start, now) + package_sections(period_start, now)
101+
end
102+
103+
def monthly_sections(period_start, now)
104+
candidate_sections = MONTHLY_PLATFORM_ORDER.flat_map do |platform|
105+
[
106+
user_candidate_section(period_start, platform, now),
107+
organization_candidate_section(period_start, platform, now)
108+
]
109+
end.compact
110+
candidate_sections + repository_sections(period_start, now)
111+
end
112+
113+
def user_candidate_section(period_start, platform, now)
114+
counts = candidate_counts(:candidate_users, period_start, platform)
115+
return if counts.fetch(:total).zero?
116+
117+
section(
118+
label: "monthly users / #{platform}",
119+
period_start: period_start,
120+
job_kind: 'monthly',
121+
stage: 'users',
122+
unit_kind: 'user_candidate',
123+
platform: platform,
124+
total: counts.fetch(:total),
125+
done: counts.fetch(:done),
126+
pending: counts.fetch(:pending),
127+
failed: counts.fetch(:failed),
128+
skipped: counts.fetch(:skipped),
129+
status_detail: nil,
130+
now: now
131+
)
132+
end
133+
134+
def organization_candidate_section(period_start, platform, now)
135+
counts = candidate_counts(:candidate_organizations, period_start, platform)
136+
return if counts.fetch(:total).zero?
137+
138+
section(
139+
label: "monthly organizations / #{platform}",
140+
period_start: period_start,
141+
job_kind: 'monthly',
142+
stage: 'organizations',
143+
unit_kind: 'organization_candidate',
144+
platform: platform,
145+
total: counts.fetch(:total),
146+
done: counts.fetch(:done),
147+
pending: counts.fetch(:pending),
148+
failed: counts.fetch(:failed),
149+
skipped: counts.fetch(:skipped),
150+
status_detail: nil,
151+
now: now
152+
)
153+
end
154+
155+
def repository_sections(period_start, now)
156+
SQLiteRepositoryJobProgressSections
157+
.new(
158+
database: database,
159+
finished_sync_run: method(:finished_sync_run?),
160+
section_builder: method(:section)
161+
)
162+
.call(period_start, now)
163+
end
164+
165+
def package_sections(period_start, now)
166+
SQLitePackageJobProgressSections
167+
.new(database: database, section_builder: method(:section))
168+
.call(period_start, now)
169+
end
170+
171+
def section(attributes)
172+
event_stats = work_event_stats(attributes)
173+
pending = attributes.fetch(:pending)
174+
average_ms = event_stats.fetch(:average_ms)
175+
p95_ms = event_stats.fetch(:p95_ms)
176+
{
177+
**attributes.except(:now, :period_start),
178+
throughput_per_minute: event_stats.fetch(:throughput_per_minute),
179+
average_ms: average_ms,
180+
median_ms: event_stats.fetch(:median_ms),
181+
p95_ms: p95_ms,
182+
eta_average_seconds: eta_seconds(pending, average_ms),
183+
eta_p95_seconds: eta_seconds(pending, p95_ms),
184+
last_finished_at: event_stats.fetch(:last_finished_at),
185+
state: section_state(attributes, event_stats),
186+
stale: section_stale?(attributes, event_stats)
187+
}
188+
end
189+
190+
def work_event_stats(attributes)
191+
stats = work_event_stats_row(attributes)
192+
return empty_work_event_stats unless stats
193+
194+
count = stats.fetch(:events_count).to_i
195+
{
196+
throughput_per_minute: throughput_per_minute(
197+
count,
198+
stats.fetch(:first_finished_at),
199+
stats.fetch(:last_finished_at)
200+
),
201+
average_ms: stats.fetch(:average_ms)&.to_i,
202+
median_ms: stats.fetch(:median_ms)&.to_i,
203+
p95_ms: stats.fetch(:p95_ms)&.to_i,
204+
last_finished_at: stats.fetch(:last_finished_at)
205+
}
206+
end
207+
208+
def empty_work_event_stats
209+
{
210+
throughput_per_minute: 0.0,
211+
average_ms: nil,
212+
median_ms: nil,
213+
p95_ms: nil,
214+
last_finished_at: nil
215+
}
216+
end
217+
218+
def work_event_stats_row(attributes)
219+
rows = fetch_all(work_event_stats_sql(attributes), work_event_stats_params(attributes))
220+
rows.first
221+
end
222+
223+
def work_event_stats_sql(attributes)
224+
format(WORK_EVENT_STATS_SQL, filters: work_event_filters(attributes).join(' AND '))
225+
end
226+
227+
def work_event_filters(attributes)
228+
filters = ['period_start = ?', 'job_kind = ?', 'stage = ?', 'unit_kind = ?']
229+
filters << 'platform = ?' if attributes[:platform]
230+
filters << 'ecosystem = ?' if attributes[:ecosystem]
231+
filters
232+
end
233+
234+
def work_event_stats_params(attributes)
235+
params = [
236+
attributes.fetch(:period_start),
237+
attributes.fetch(:job_kind),
238+
attributes.fetch(:stage),
239+
attributes.fetch(:unit_kind)
240+
]
241+
params << attributes[:platform] if attributes[:platform]
242+
params << attributes[:ecosystem] if attributes[:ecosystem]
243+
params
244+
end
245+
246+
def throughput_per_minute(count, first_finished_at, last_finished_at)
247+
return 0.0 if count < 2
248+
249+
started = Time.parse(first_finished_at)
250+
finished = Time.parse(last_finished_at)
251+
minutes = [(finished - started) / 60.0, 1.0 / 60.0].max
252+
(count / minutes).round(2)
253+
end
254+
255+
def eta_seconds(pending, duration_ms)
256+
return nil unless duration_ms&.positive?
257+
258+
((pending.to_i * duration_ms) / 1000.0).round
259+
end
260+
261+
def stale?(last_finished_at, now)
262+
return false unless last_finished_at
263+
264+
(now - Time.parse(last_finished_at)) > stale_seconds
265+
end
266+
267+
def section_stale?(attributes, event_stats)
268+
return false unless attributes.fetch(:pending).positive?
269+
270+
stale?(event_stats.fetch(:last_finished_at), attributes.fetch(:now))
271+
end
272+
273+
def section_state(attributes, event_stats)
274+
return 'failed' if failed_monthly_run_with_pending_work?(attributes)
275+
return package_section_state(attributes) if package_section?(attributes)
276+
return 'pending' if attributes.fetch(:pending).positive? && !event_stats.fetch(:last_finished_at)
277+
return 'running' if attributes.fetch(:pending).positive?
278+
return 'failed' if attributes.fetch(:failed).positive?
279+
280+
'complete'
281+
end
282+
283+
def failed_monthly_run_with_pending_work?(attributes)
284+
attributes[:job_kind] == 'monthly' &&
285+
failed_sync_run?(attributes.fetch(:period_start)) &&
286+
attributes.fetch(:pending).positive?
287+
end
288+
289+
def package_section?(attributes)
290+
attributes[:job_kind] == 'packages'
291+
end
292+
293+
def package_section_state(attributes)
294+
return 'failed' if attributes.fetch(:failed).positive?
295+
return 'complete' unless attributes.fetch(:pending).positive?
296+
297+
case latest_package_run_status(attributes.fetch(:period_start))
298+
when 'running' then 'running'
299+
when 'failed' then 'failed'
300+
else 'pending'
301+
end
302+
end
303+
304+
def latest_package_run_status(period_start)
305+
package_crawl_runs_dataset
306+
.where(period_start: period_start)
307+
.order(Sequel.desc(Sequel.function(:datetime, :started_at)), Sequel.desc(:id))
308+
.get(:status)
309+
end
310+
311+
def failed_sync_run?(period_start)
312+
sync_runs_dataset.where(period_start: period_start, status: 'failed').any?
313+
end
314+
315+
def finished_sync_run?(period_start)
316+
sync_runs_dataset.where(period_start: period_start, status: 'finished').any?
317+
end
318+
319+
def candidate_counts(table, period_start, platform)
320+
rows = database.dataset(table).where(period_start: period_start, platform: platform)
321+
total = rows.count
322+
pending = rows.where(status: 'pending').count
323+
failed = rows.where(status: 'failed').count
324+
skipped = rows.where(status: %w[rejected missing]).count
325+
{ total: total, done: total - pending, pending: pending, failed: failed, skipped: skipped }
326+
end
327+
328+
def request_points(run, now)
329+
return [] unless run
330+
331+
finished_at = run[:finished_at] || now.iso8601
332+
fetch_all(<<~SQL, [run.fetch(:started_at), finished_at])
333+
SELECT platform, substr(recorded_at, 1, 16) || ':00Z' AS minute,
334+
COUNT(*) AS requests_count,
335+
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS error_count
336+
FROM api_request_events
337+
WHERE recorded_at >= ? AND recorded_at <= ?
338+
GROUP BY platform, minute
339+
ORDER BY platform, minute
340+
SQL
341+
end
342+
343+
def recent_events
344+
fetch_all(<<~SQL)
345+
SELECT job_kind AS platform, stage AS source, subject_label AS subject,
346+
status AS detail, finished_at AS recorded_at
347+
FROM job_work_events
348+
ORDER BY datetime(finished_at) DESC, id DESC
349+
LIMIT 30
350+
SQL
351+
end
352+
353+
def fetch_all(sql, params = [])
354+
database.fetch_all(sql, params)
355+
end
356+
357+
def table?(name)
358+
database.fetch_value(
359+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
360+
[name.to_s]
361+
) == 1
362+
end
363+
364+
def sync_runs_dataset
365+
database.dataset(:sync_runs)
366+
end
367+
368+
def package_crawl_runs_dataset
369+
database.dataset(:package_crawl_runs)
370+
end
371+
end
372+
end
373+
end
374+
end
375+
end
376+
end

0 commit comments

Comments
 (0)