|
| 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