mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
403 lines
12 KiB
Ruby
403 lines
12 KiB
Ruby
require "fileutils"
|
|
require "zlib"
|
|
|
|
class HeartbeatImportRunner < ApplicationService
|
|
class << self
|
|
include ErrorReporting
|
|
end
|
|
|
|
PROGRESS_INTERVAL = 250
|
|
REMOTE_REFRESH_THROTTLE = 5.seconds
|
|
TMP_DIR = Rails.root.join("tmp", "heartbeat_imports")
|
|
|
|
class ActiveImportError < StandardError; end
|
|
class CooldownError < StandardError
|
|
attr_reader :retry_at
|
|
|
|
def initialize(retry_at)
|
|
@retry_at = retry_at
|
|
super("Remote imports are limited to once every 8 minutes.")
|
|
end
|
|
end
|
|
|
|
class FeatureDisabledError < StandardError; end
|
|
class InvalidProviderError < StandardError; end
|
|
class InvalidDownloadUrlError < StandardError; end
|
|
|
|
def self.start_dev_upload(user:, uploaded_file:)
|
|
ensure_no_active_import!(user)
|
|
|
|
run = user.heartbeat_import_runs.create!(
|
|
source_kind: :dev_upload,
|
|
source_filename: uploaded_file.original_filename.to_s,
|
|
state: :queued,
|
|
message: "Queued import."
|
|
)
|
|
|
|
file_path = persist_uploaded_file(uploaded_file, run.id)
|
|
HeartbeatImportJob.perform_later(run.id, file_path)
|
|
run
|
|
rescue ActiveRecord::RecordNotUnique
|
|
raise ActiveImportError, "Another import is already in progress."
|
|
end
|
|
|
|
def self.start_remote_import(user:, provider:, api_key:)
|
|
ensure_imports_enabled!(user)
|
|
ensure_no_active_import!(user)
|
|
ensure_remote_cooldown!(user)
|
|
|
|
run = user.heartbeat_import_runs.create!(
|
|
source_kind: normalize_provider(provider),
|
|
state: :queued,
|
|
encrypted_api_key: api_key.to_s,
|
|
message: "Queued import."
|
|
)
|
|
|
|
HeartbeatImportDumpJob.perform_later(run.id)
|
|
run
|
|
rescue ActiveRecord::RecordNotUnique
|
|
raise ActiveImportError, "Another import is already in progress."
|
|
end
|
|
|
|
def self.start_wakatime_download_link_import(user:, download_url:)
|
|
ensure_imports_enabled!(user)
|
|
ensure_no_active_import!(user)
|
|
|
|
unless HeartbeatImportDumpClient.valid_wakatime_download_url?(download_url)
|
|
raise InvalidDownloadUrlError, "Download link must start with https://wakatime.s3.amazonaws.com."
|
|
end
|
|
|
|
run = user.heartbeat_import_runs.create!(
|
|
source_kind: :wakatime_download_link,
|
|
state: :queued,
|
|
message: "Queued import."
|
|
)
|
|
|
|
queue_remote_download(run:, download_url:, remote_dump_status: "Manual download link received")
|
|
run
|
|
rescue ActiveRecord::RecordNotUnique
|
|
raise ActiveImportError, "Another import is already in progress."
|
|
end
|
|
|
|
def self.find_run(user:, import_id:)
|
|
user.heartbeat_import_runs.find_by(id: import_id)
|
|
end
|
|
|
|
def self.latest_run(user:)
|
|
HeartbeatImportRun.latest_for(user)
|
|
end
|
|
|
|
def self.refresh_remote_run!(run)
|
|
return run unless refreshable_remote_run?(run)
|
|
|
|
HeartbeatImportDumpJob.perform_later(run.id)
|
|
|
|
run.reload
|
|
rescue => e
|
|
report_error(e, message: "Error refreshing heartbeat import run #{run&.id}")
|
|
run
|
|
end
|
|
|
|
def self.remote_import_cooldown_until(user:)
|
|
return nil if user.admin_level_superadmin?
|
|
|
|
HeartbeatImportRun.remote_cooldown_until_for(user)
|
|
end
|
|
|
|
def self.refreshable_remote_run?(run)
|
|
run&.remote? &&
|
|
run.remote_dump_pollable? &&
|
|
Flipper.enabled?(:imports, run.user) &&
|
|
run.updated_at <= REMOTE_REFRESH_THROTTLE.ago &&
|
|
run.encrypted_api_key.present? &&
|
|
!dump_job_pending_for?(run) # Prevent duplicate job enqueuing
|
|
end
|
|
|
|
def self.dump_job_pending_for?(run)
|
|
GoodJob::Job.where(
|
|
job_class: "HeartbeatImportDumpJob",
|
|
finished_at: nil,
|
|
error: nil
|
|
).where("serialized_params -> 'arguments' -> 0 = to_jsonb(?::text)", run.id.to_s).exists?
|
|
end
|
|
|
|
def self.serialize(run)
|
|
return nil unless run
|
|
|
|
{
|
|
import_id: run.id.to_s,
|
|
state: run.state,
|
|
source_kind: run.source_kind,
|
|
progress_percent: run.progress_percent,
|
|
processed_count: run.processed_count,
|
|
total_count: run.total_count,
|
|
imported_count: run.imported_count,
|
|
skipped_count: run.skipped_count,
|
|
errors_count: run.errors_count,
|
|
message: run.message.to_s,
|
|
error_message: run.error_message,
|
|
remote_dump_status: run.remote_dump_status,
|
|
remote_percent_complete: run.remote_percent_complete,
|
|
cooldown_until: remote_import_cooldown_until(user: run.user)&.iso8601,
|
|
source_filename: run.source_filename,
|
|
updated_at: run.updated_at.iso8601,
|
|
started_at: run.started_at&.iso8601,
|
|
finished_at: run.finished_at&.iso8601
|
|
}
|
|
end
|
|
|
|
def self.run_import(import_run_id:, file_path:)
|
|
ActiveRecord::Base.connection_pool.with_connection do
|
|
run = HeartbeatImportRun.includes(:user).find_by(id: import_run_id)
|
|
return unless run
|
|
return if run.terminal?
|
|
|
|
user = run.user
|
|
|
|
run.update!(
|
|
state: :importing,
|
|
total_count: nil,
|
|
processed_count: 0,
|
|
started_at: run.started_at || Time.current,
|
|
message: "Importing heartbeats..."
|
|
)
|
|
|
|
result = import_file_streaming(file_path, user, run)
|
|
|
|
if result[:success]
|
|
complete_run!(run, result:)
|
|
else
|
|
fail_run!(
|
|
run,
|
|
message: result[:error],
|
|
imported_count: result[:imported_count],
|
|
skipped_count: result[:skipped_count],
|
|
errors_count: result[:errors].size
|
|
)
|
|
end
|
|
end
|
|
rescue => e
|
|
run = HeartbeatImportRun.includes(:user).find_by(id: import_run_id)
|
|
fail_run!(run, message: e.message) if run && !run.terminal?
|
|
report_error(e, message: "HeartbeatImportDumpJob failed") # Track unexpected errors
|
|
ensure
|
|
FileUtils.rm_f(file_path) if file_path.present?
|
|
ActiveRecord::Base.connection_pool.release_connection
|
|
end
|
|
|
|
def self.import_file_streaming(file_path, user, run)
|
|
File.open(file_path, "rb") do |file|
|
|
magic_bytes = file.read(2)
|
|
file.rewind
|
|
|
|
io = if magic_bytes == [ 0x1f, 0x8b ].pack("C*")
|
|
Zlib::GzipReader.new(file)
|
|
else
|
|
file
|
|
end
|
|
|
|
begin
|
|
result = HeartbeatImportService.import_from_file(
|
|
io,
|
|
user,
|
|
progress_interval: PROGRESS_INTERVAL,
|
|
on_progress: lambda { |processed_count|
|
|
run.update_columns(
|
|
processed_count: processed_count,
|
|
message: "Importing heartbeats...",
|
|
updated_at: Time.current
|
|
)
|
|
}
|
|
)
|
|
ensure
|
|
io.close if io.respond_to?(:close) && io != file
|
|
end
|
|
|
|
result
|
|
end
|
|
end
|
|
|
|
def self.persist_uploaded_file(uploaded_file, import_run_id)
|
|
FileUtils.mkdir_p(TMP_DIR)
|
|
ext = File.extname(uploaded_file.original_filename.to_s)
|
|
ext = ".json" if ext.blank?
|
|
file_path = TMP_DIR.join("#{import_run_id}#{ext}")
|
|
FileUtils.cp(uploaded_file.tempfile.path, file_path)
|
|
|
|
file_path.to_s
|
|
end
|
|
|
|
def self.persist_remote_download(run:, file_content:)
|
|
FileUtils.mkdir_p(TMP_DIR)
|
|
|
|
file_path = TMP_DIR.join("#{run.id}-remote.json")
|
|
File.binwrite(file_path, file_content) # Write raw bytes, let run_import handle decompression
|
|
file_path.to_s
|
|
end
|
|
|
|
def self.queue_remote_download(run:, download_url:, remote_dump_status: nil, remote_percent_complete: nil)
|
|
run.update!(
|
|
state: :downloading_dump,
|
|
remote_dump_status: remote_dump_status || run.remote_dump_status,
|
|
remote_percent_complete: remote_percent_complete.nil? ? run.remote_percent_complete : remote_percent_complete,
|
|
message: "Downloading data dump...",
|
|
error_message: nil
|
|
)
|
|
|
|
HeartbeatImportRemoteDownloadJob.perform_later(run.id, download_url)
|
|
end
|
|
|
|
def self.build_success_message(result)
|
|
message = "Imported #{result[:imported_count]} out of #{result[:total_count]} heartbeats in #{result[:time_taken]}s."
|
|
return message if result[:skipped_count].zero?
|
|
|
|
"#{message} Skipped #{result[:skipped_count]} duplicate heartbeats."
|
|
end
|
|
|
|
def self.complete_run!(run, result:)
|
|
run.update!(
|
|
state: :completed,
|
|
processed_count: result[:total_count],
|
|
total_count: result[:total_count],
|
|
imported_count: result[:imported_count],
|
|
skipped_count: result[:skipped_count],
|
|
errors_count: result[:errors].size,
|
|
message: build_success_message(result),
|
|
error_message: nil,
|
|
finished_at: Time.current
|
|
)
|
|
|
|
run.clear_sensitive_fields!
|
|
send_completion_email(run)
|
|
end
|
|
|
|
def self.fail_run!(
|
|
run,
|
|
message:,
|
|
imported_count: run.imported_count,
|
|
skipped_count: run.skipped_count,
|
|
errors_count: run.errors_count,
|
|
remote_dump_status: run.remote_dump_status.presence || "failed",
|
|
notify: true
|
|
)
|
|
run.update!(
|
|
state: :failed,
|
|
imported_count:,
|
|
skipped_count:,
|
|
errors_count:,
|
|
remote_dump_status: run.remote? ? remote_dump_status : run.remote_dump_status,
|
|
message: "Import failed: #{message}",
|
|
error_message: message,
|
|
finished_at: Time.current
|
|
)
|
|
|
|
send_failure_email(run) if notify
|
|
run.clear_sensitive_fields!
|
|
end
|
|
|
|
def self.fail_run_for_error!(import_run_id:, error:, notify: true)
|
|
run = HeartbeatImportRun.includes(:user).find_by(id: import_run_id)
|
|
return unless run
|
|
return if run.terminal?
|
|
|
|
fail_run!(run, message: failure_message_for(run:, error:), notify:)
|
|
end
|
|
|
|
def self.failure_message_for(run:, error:)
|
|
case error
|
|
when HeartbeatImportDumpClient::AuthenticationError
|
|
"#{import_source_name(run)} rejected the import because the API key is invalid."
|
|
when HeartbeatImportDumpClient::TransientError
|
|
transient_failure_message_for(run:, error:)
|
|
else
|
|
error.respond_to?(:message) ? error.message : error.to_s
|
|
end
|
|
end
|
|
|
|
def self.import_source_name(run)
|
|
run.hackatime_v1_dump? ? "Hackatime v1" : "WakaTime"
|
|
end
|
|
|
|
def self.ensure_imports_enabled!(user)
|
|
return if Flipper.enabled?(:imports, user)
|
|
|
|
raise FeatureDisabledError, "Imports are not enabled for this user."
|
|
end
|
|
|
|
def self.ensure_no_active_import!(user)
|
|
return unless HeartbeatImportRun.active_for(user)
|
|
|
|
raise ActiveImportError, "Another import is already in progress."
|
|
end
|
|
|
|
def self.ensure_remote_cooldown!(user)
|
|
retry_at = remote_import_cooldown_until(user:)
|
|
return if retry_at.blank?
|
|
|
|
raise CooldownError, retry_at
|
|
end
|
|
|
|
def self.normalize_provider(provider)
|
|
normalized_provider = provider.to_s
|
|
|
|
aliases = {
|
|
"wakatime" => "wakatime_dump",
|
|
"hackatime_v1" => "hackatime_v1_dump",
|
|
"wakatime_dump" => "wakatime_dump",
|
|
"hackatime_v1_dump" => "hackatime_v1_dump"
|
|
}
|
|
|
|
aliases.fetch(normalized_provider) { raise InvalidProviderError, "Unsupported import provider." }
|
|
end
|
|
|
|
def self.transient_failure_message_for(run:, error:)
|
|
return provider_error_message_for(run) if error.status.to_i >= 500
|
|
|
|
"#{import_source_name(run)} could not be reached while processing the import. Please try again."
|
|
end
|
|
|
|
def self.provider_error_message_for(run)
|
|
message = "#{import_source_name(run)} ran into an error while processing the import."
|
|
return message unless run.hackatime_v1_dump?
|
|
|
|
"#{message} Please reach out to #hackatime-help on Slack."
|
|
end
|
|
|
|
def self.send_completion_email(run)
|
|
return unless send_import_email?(run)
|
|
|
|
recipient_email = recipient_email_for(run.user)
|
|
return if recipient_email.blank?
|
|
|
|
HeartbeatImportMailer.import_completed(
|
|
run.user,
|
|
run:,
|
|
recipient_email:
|
|
).deliver_later
|
|
rescue => e
|
|
report_error(e, message: "Failed to send heartbeat import completion email for run #{run.id}")
|
|
end
|
|
|
|
def self.send_failure_email(run)
|
|
return unless send_import_email?(run)
|
|
|
|
recipient_email = recipient_email_for(run.user)
|
|
return if recipient_email.blank?
|
|
|
|
HeartbeatImportMailer.import_failed(
|
|
run.user,
|
|
run:,
|
|
recipient_email:
|
|
).deliver_later
|
|
rescue => e
|
|
report_error(e, message: "Failed to send heartbeat import failure email for run #{run.id}")
|
|
end
|
|
|
|
def self.send_import_email?(run)
|
|
!run.dev_upload?
|
|
end
|
|
|
|
def self.recipient_email_for(user)
|
|
user.email_addresses.order(:id).pick(:email)
|
|
end
|
|
end
|