Make large exports go to ActiveStorage (#990)

* Make em go to ActiveStorage

* Oops!
This commit is contained in:
Mahad Kalam 2026-02-21 11:53:18 +00:00 committed by GitHub
parent 44777ad644
commit 1b7e0462dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 246 additions and 46 deletions

View file

@ -165,3 +165,5 @@ gem "tailwindcss-rails", "~> 4.2"
gem "inertia_rails", "~> 3.17"
gem "vite_rails", "~> 3.0"
gem "rubyzip", "~> 3.2"

View file

@ -656,6 +656,7 @@ DEPENDENCIES
rswag-ui
rubocop-rails-omakase
ruby_identicon
rubyzip (~> 3.2)
selenium-webdriver
sentry-rails
sentry-ruby

View file

@ -0,0 +1,11 @@
class HeartbeatExportCleanupJob < ApplicationJob
queue_as :default
def perform(blob_id)
blob = ActiveStorage::Blob.find_by(id: blob_id)
return if blob.nil?
return unless blob.metadata["heartbeat_export"]
blob.purge
end
end

View file

@ -1,3 +1,5 @@
require "zip"
class HeartbeatExportJob < ApplicationJob
queue_as :default
@ -35,17 +37,39 @@ class HeartbeatExportJob < ApplicationJob
export_data = build_export_data(heartbeats, start_date, end_date)
user_identifier = user.slack_uid.presence || "user_#{user.id}"
filename = "heartbeats_#{user_identifier}_#{start_date.strftime("%Y%m%d")}_#{end_date.strftime("%Y%m%d")}.json"
json_filename = "heartbeats_#{user_identifier}_#{start_date.strftime("%Y%m%d")}_#{end_date.strftime("%Y%m%d")}.json"
zip_filename = "#{File.basename(json_filename, ".json")}.zip"
Tempfile.create([ "heartbeat_export", ".json" ]) do |file|
file.write(export_data.to_json)
file.rewind
HeartbeatExportMailer.export_ready(
user,
recipient_email: recipient_email,
file_path: file.path,
filename: filename
).deliver_now
Tempfile.create([ "heartbeat_export", ".zip" ]) do |zip_file|
Zip::File.open(zip_file.path, create: true) do |archive|
archive.add(json_filename, file.path)
end
blob = File.open(zip_file.path, "rb") do |zip_io|
ActiveStorage::Blob.create_and_upload!(
io: zip_io,
filename: zip_filename,
content_type: "application/zip",
metadata: {
heartbeat_export: true,
user_id: user.id
}
)
end
HeartbeatExportCleanupJob.set(wait: 7.days).perform_later(blob.id)
HeartbeatExportMailer.export_ready(
user,
recipient_email: recipient_email,
blob: blob,
filename: zip_filename
).deliver_now
end
end
rescue ArgumentError => e
Rails.logger.error("Heartbeat export failed for user #{user_id}: #{e.message}")

View file

@ -1,10 +1,8 @@
class HeartbeatExportMailer < ApplicationMailer
def export_ready(user, recipient_email:, file_path:, filename:)
def export_ready(user, recipient_email:, blob:, filename:)
@user = user
attachments[filename] = {
mime_type: "application/json",
content: File.binread(file_path)
}
@filename = filename
@download_url = rails_blob_url(blob, disposition: "attachment")
mail(
to: recipient_email,

View file

@ -1,4 +1,7 @@
<h1>Your heartbeat export is ready</h1>
<p>Hi <%= h(@user.display_name) %>,</p>
<p>Your Hackatime heartbeat export has been generated and is attached to this email.</p>
<p>If you didn't request this export, you can safely ignore this email.</p>
<p>Your Hackatime heartbeat export has been generated.</p>
<p>
<a href="<%= @download_url %>">Download <%= @filename %></a>
</p>
<p>This link will stop working after 7 days.</p>

View file

@ -2,6 +2,9 @@ Your heartbeat export is ready
Hi <%= h(@user.display_name) %>,
Your Hackatime heartbeat export has been generated and is attached to this email.
Your Hackatime heartbeat export has been generated.
If you didn't request this export, you can safely ignore this email.
Download file: <%= @download_url %>
Filename: <%= @filename %>
This link will stop working after 7 days.

View file

@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end

32
db/schema.rb generated
View file

@ -10,11 +10,39 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_02_19_153152) do
ActiveRecord::Schema[8.1].define(version: 2026_02_21_113553) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.text "metadata"
t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "admin_api_keys", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "name", null: false
@ -629,6 +657,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_19_153152) do
t.index ["user_id"], name: "index_wakatime_mirrors_on_user_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "admin_api_keys", "users"
add_foreign_key "api_keys", "users"
add_foreign_key "commits", "repositories"

View file

@ -0,0 +1,30 @@
require "test_helper"
class HeartbeatExportCleanupJobTest < ActiveJob::TestCase
test "purges heartbeat export blob" do
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new("{\"sample\":true}"),
filename: "heartbeats_test.json",
content_type: "application/json",
metadata: { "heartbeat_export" => true }
)
assert_difference -> { ActiveStorage::Blob.count }, -1 do
HeartbeatExportCleanupJob.perform_now(blob.id)
end
end
test "does not purge blob without heartbeat export metadata" do
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new("test"),
filename: "notes.txt",
content_type: "text/plain"
)
assert_no_difference -> { ActiveStorage::Blob.count } do
HeartbeatExportCleanupJob.perform_now(blob.id)
end
blob.purge
end
end

View file

@ -1,8 +1,10 @@
require "test_helper"
require "zip"
class HeartbeatExportJobTest < ActiveJob::TestCase
setup do
ActionMailer::Base.deliveries.clear
GoodJob::Job.delete_all
@user = User.create!(
timezone: "UTC",
slack_uid: "U#{SecureRandom.hex(5)}",
@ -14,26 +16,32 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
)
end
test "all-data export sends email with attachment and export metadata" do
test "all-data export uploads blob, schedules cleanup, and emails download link" do
first_time = Time.utc(2026, 2, 10, 12, 0, 0)
second_time = Time.utc(2026, 2, 12, 12, 0, 0)
hb1 = create_heartbeat(at_time: first_time, entity: "src/first.rb")
hb2 = create_heartbeat(at_time: second_time, entity: "src/second.rb")
HeartbeatExportJob.perform_now(@user.id, all_data: true)
assert_difference -> { ActiveStorage::Blob.count }, 1 do
assert_difference -> { GoodJob::Job.where(job_class: "HeartbeatExportCleanupJob").count }, 1 do
HeartbeatExportJob.perform_now(@user.id, all_data: true)
end
end
assert_equal 1, ActionMailer::Base.deliveries.size
mail = ActionMailer::Base.deliveries.last
assert_equal [ @user.email_addresses.first.email ], mail.to
assert_equal "Your Hackatime heartbeat export is ready", mail.subject
assert_equal 1, mail.attachments.size
attachment = mail.attachments.first
assert_equal "application/json", attachment.mime_type
assert_match(/\Aheartbeats_#{@user.slack_uid}_20260210_20260212\.json\z/, attachment.filename.to_s)
blob = ActiveStorage::Blob.order(created_at: :asc).last
assert_equal "application/zip", blob.content_type
assert_match(/\Aheartbeats_#{@user.slack_uid}_20260210_20260212\.zip\z/, blob.filename.to_s)
payload = JSON.parse(attachment.body.decoded)
payload = parse_zipped_export_payload(
blob.download,
"heartbeats_#{@user.slack_uid}_20260210_20260212.json"
)
assert_equal "2026-02-10", payload.dig("export_info", "date_range", "start_date")
assert_equal "2026-02-12", payload.dig("export_info", "date_range", "end_date")
assert_equal 2, payload.dig("export_info", "total_heartbeats")
@ -41,6 +49,8 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
assert_equal [ hb1.id, hb2.id ], payload.fetch("heartbeats").map { |row| row.fetch("id") }
assert_equal "src/first.rb", payload.fetch("heartbeats").first.fetch("entity")
assert_equal "src/second.rb", payload.fetch("heartbeats").last.fetch("entity")
assert_includes mail.text_part.body.decoded, "/rails/active_storage/blobs/redirect/"
end
test "date-range export includes only heartbeats in range" do
@ -55,7 +65,10 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
end_date: "2026-02-11"
)
payload = JSON.parse(ActionMailer::Base.deliveries.last.attachments.first.body.decoded)
payload = parse_zipped_export_payload(
ActiveStorage::Blob.order(created_at: :asc).last.download,
"heartbeats_#{@user.slack_uid}_20260210_20260211.json"
)
exported_ids = payload.fetch("heartbeats").map { |row| row.fetch("id") }
assert_equal [ in_range_one.id, in_range_two.id ], exported_ids
@ -117,4 +130,31 @@ class HeartbeatExportJobTest < ActiveJob::TestCase
source_type: :test_entry
)
end
def parse_zipped_export_payload(zip_bytes, json_filename)
zip_data = zip_bytes
zip_data = zip_data.download if zip_data.respond_to?(:download)
if zip_data.respond_to?(:read)
zip_data.rewind if zip_data.respond_to?(:rewind)
zip_data = zip_data.read
end
payload = nil
found_expected_entry = false
Zip::InputStream.open(StringIO.new(zip_data.to_s)) do |stream|
while (entry = stream.get_next_entry)
next unless entry.name.end_with?(".json")
payload = JSON.parse(stream.read)
found_expected_entry = entry.name == json_filename
break if found_expected_entry
end
end
assert_not_nil payload, "Expected zip to include a JSON file"
assert found_expected_entry, "Expected zip to include #{json_filename}"
payload
end
end

View file

@ -10,30 +10,31 @@ class HeartbeatExportMailerTest < ActionMailer::TestCase
@recipient_email = "mailer-export-#{SecureRandom.hex(6)}@example.com"
end
test "export_ready builds recipient, body, and json attachment" do
Tempfile.create([ "heartbeat_export_mailer", ".json" ]) do |file|
file.write({ sample: true }.to_json)
file.rewind
test "export_ready builds recipient and includes download link" do
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new({ sample: true }.to_json),
filename: "heartbeats_test.zip",
content_type: "application/zip",
metadata: { "heartbeat_export" => true }
)
mail = HeartbeatExportMailer.export_ready(
@user,
recipient_email: @recipient_email,
file_path: file.path,
filename: "heartbeats_test.json"
)
mail = HeartbeatExportMailer.export_ready(
@user,
recipient_email: @recipient_email,
blob: blob,
filename: "heartbeats_test.zip"
)
assert_equal [ @recipient_email ], mail.to
assert_equal "Your Hackatime heartbeat export is ready", mail.subject
assert_equal 1, mail.attachments.size
assert_equal [ @recipient_email ], mail.to
assert_equal "Your Hackatime heartbeat export is ready", mail.subject
assert_equal 0, mail.attachments.size
attachment = mail.attachments.first
assert_equal "heartbeats_test.json", attachment.filename
assert_equal "application/json", attachment.mime_type
assert_equal({ "sample" => true }, JSON.parse(attachment.body.decoded))
assert_includes mail.html_part.body.decoded, "Your heartbeat export is ready"
assert_includes mail.text_part.body.decoded, "Your Hackatime heartbeat export has been generated"
assert_includes mail.text_part.body.decoded, @user.display_name
assert_includes mail.text_part.body.decoded, "/rails/active_storage/blobs/redirect/"
assert_includes mail.text_part.body.decoded, "heartbeats_test.zip"
assert_includes mail.html_part.body.decoded, "Your heartbeat export is ready"
assert_includes mail.text_part.body.decoded, "Your Hackatime heartbeat export has been generated"
assert_includes mail.text_part.body.decoded, @user.display_name
end
blob.purge
end
end