Skip to content

Rate limit heartbeat exports#1374

Open
skyfallwastaken wants to merge 1 commit into
mainfrom
codex/rate-limit-heartbeat-exports
Open

Rate limit heartbeat exports#1374
skyfallwastaken wants to merge 1 commit into
mainfrom
codex/rate-limit-heartbeat-exports

Conversation

@skyfallwastaken
Copy link
Copy Markdown
Member

Summary of the problem

Users could create repeated heartbeat export jobs without any cooldown, which could enqueue duplicate expensive exports.

Describe your changes

  • Added a 10-minute per-user export creation cooldown before enqueuing HeartbeatExportJob.
  • Added GoodJob concurrency protection so one export job per user can run at a time.
  • Updated the imports/exports settings copy to tell users about the cooldown.
  • Added controller and system coverage for repeated export requests.

Validation:

  • docker compose exec web rails test test/controllers/my/heartbeats_controller_test.rb test/jobs/heartbeat_export_job_test.rb
  • docker compose exec web rails test test/system/heartbeat_export_test.rb
  • Subagent also ran focused RuboCop, Zeitwerk, Svelte check, and Prettier check for the changed files.

Screenshots / Media

Not included.

Copilot AI review requested due to automatic review settings May 28, 2026 18:20
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Greptile Summary

This PR introduces a two-layer protection against duplicate heartbeat export jobs: a 10-minute per-user cooldown enforced in the controller (by querying recent GoodJob::Job records) and a GoodJob concurrency guard on the job itself (total_limit: 1, drop: true) as a defense-in-depth backstop.

  • The controller's recent_export_requested? queries GoodJob::Job directly using a JSONB path expression to match the user ID in serialized_params, then redirects with an alert if a recent job is found. This correctly scopes the check per user.
  • The job gains a per-user good_job_control_concurrency_with key so at most one export can run simultaneously for a given user, regardless of how jobs were enqueued.
  • Tests cover the rate-limit path at both the controller integration level and the system/browser level, and the system tests are cleaned up with a wait_for_export_controls helper.

Confidence Score: 4/5

Safe to merge; the rate limiting and concurrency guard work correctly for the normal flow.

The core protection works as intended, but the controller's recent_export_requested? query counts failed and GoodJob-dropped jobs the same as successful ones. A user whose export silently fails is still locked out for the full 10 minutes with no signal that anything went wrong. Additionally, the "10 minutes" string in the Svelte component is not derived from EXPORT_COOLDOWN, so a future change to the cooldown duration will leave the UI copy out of date.

app/controllers/my/heartbeats_controller.rb — the recent_export_requested? query and its interaction with failed/dropped job records.

Important Files Changed

Filename Overview
app/controllers/my/heartbeats_controller.rb Adds controller-level rate limiting via a GoodJob::Job table query; logic is sound but the check includes failed/discarded job records, which could silently consume a user's cooldown slot after an unsuccessful export.
app/jobs/heartbeat_export_job.rb Adds GoodJob per-user concurrency guard (total_limit: 1, drop: true) as a defense-in-depth layer against duplicate exports; key lambda correctly uses the first argument (user_id).
app/javascript/pages/Users/Settings/ImportsExports.svelte Adds "10 minutes" to the export description copy; the value is hardcoded and will drift if EXPORT_COOLDOWN changes.
test/controllers/my/heartbeats_controller_test.rb Adds GoodJob::Job.delete_all setup and a rate-limit integration test; covers the happy path and blocked second request correctly.
test/system/heartbeat_export_test.rb Extracts wait_for_export_controls helper and adds a system-level rate limit test; clean refactor with good browser coverage.

Sequence Diagram

sequenceDiagram
    actor User
    participant Controller as HeartbeatsController#export
    participant DB as GoodJob::Job (DB)
    participant Queue as GoodJob Queue
    participant Job as HeartbeatExportJob

    User->>Controller: POST /my/heartbeats/export
    Controller->>DB: recent_export_requested?
    alt No recent job found
        DB-->>Controller: false
        Controller->>Queue: perform_later(user_id, ...)
        Queue->>DB: INSERT GoodJob::Job record
        Note over Queue,Job: GoodJob concurrency check (total_limit: 1 per user key)
        alt No concurrent job running
            Queue->>Job: execute
            Job-->>User: Email with export zip
        else Concurrent job exists
            Queue-->>Queue: drop job silently
        end
        Controller-->>User: Redirect with notice
    else Recent job exists within 10 min
        DB-->>Controller: true
        Controller-->>User: Redirect with alert
    end
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
app/controllers/my/heartbeats_controller.rb:75-84
**Rate limit applies to failed/discarded jobs**

`recent_export_requested?` queries all `GoodJob::Job` records (pending, running, finished, and failed) within the cooldown window. If a user's export job fails or is dropped by the GoodJob concurrency guard, they'll still be blocked for the full 10 minutes with no indication that anything went wrong. Consider filtering to non-failed states (e.g. excluding `error_event` IS NOT NULL or `discarded_at` IS NOT NULL) so that a failed export doesn't silently consume the user's rate limit slot.

### Issue 2 of 2
app/javascript/pages/Users/Settings/ImportsExports.svelte:339-340
**Hardcoded cooldown duration in UI copy**

The "10 minutes" string is hardcoded here while the authoritative value lives in `My::HeartbeatsController::EXPORT_COOLDOWN`. If the cooldown is ever adjusted, this copy will silently mismatch. Passing the cooldown value as an Inertia prop (e.g. `export_cooldown_minutes`) and interpolating it here would keep the two in sync automatically.

Reviews (1): Last reviewed commit: "Rate limit heartbeat exports" | Re-trigger Greptile

Comment on lines +75 to +84
def recent_export_requested?
GoodJob::Job
.where(job_class: "HeartbeatExportJob")
.where("created_at >= ?", EXPORT_COOLDOWN.ago)
.where(
"serialized_params -> 'arguments' -> 0 = to_jsonb(?::bigint)",
current_user.id
)
.exists?
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Rate limit applies to failed/discarded jobs

recent_export_requested? queries all GoodJob::Job records (pending, running, finished, and failed) within the cooldown window. If a user's export job fails or is dropped by the GoodJob concurrency guard, they'll still be blocked for the full 10 minutes with no indication that anything went wrong. Consider filtering to non-failed states (e.g. excluding error_event IS NOT NULL or discarded_at IS NOT NULL) so that a failed export doesn't silently consume the user's rate limit slot.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/controllers/my/heartbeats_controller.rb
Line: 75-84

Comment:
**Rate limit applies to failed/discarded jobs**

`recent_export_requested?` queries all `GoodJob::Job` records (pending, running, finished, and failed) within the cooldown window. If a user's export job fails or is dropped by the GoodJob concurrency guard, they'll still be blocked for the full 10 minutes with no indication that anything went wrong. Consider filtering to non-failed states (e.g. excluding `error_event` IS NOT NULL or `discarded_at` IS NOT NULL) so that a failed export doesn't silently consume the user's rate limit slot.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 339 to 340

<p class="mt-3 text-sm text-muted">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded cooldown duration in UI copy

The "10 minutes" string is hardcoded here while the authoritative value lives in My::HeartbeatsController::EXPORT_COOLDOWN. If the cooldown is ever adjusted, this copy will silently mismatch. Passing the cooldown value as an Inertia prop (e.g. export_cooldown_minutes) and interpolating it here would keep the two in sync automatically.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/javascript/pages/Users/Settings/ImportsExports.svelte
Line: 339-340

Comment:
**Hardcoded cooldown duration in UI copy**

The "10 minutes" string is hardcoded here while the authoritative value lives in `My::HeartbeatsController::EXPORT_COOLDOWN`. If the cooldown is ever adjusted, this copy will silently mismatch. Passing the cooldown value as an Inertia prop (e.g. `export_cooldown_minutes`) and interpolating it here would keep the two in sync automatically.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds protections to prevent users from repeatedly enqueuing expensive heartbeat export jobs by introducing a per-user cooldown at request time and enforcing per-user job concurrency at execution time.

Changes:

  • Added a 10-minute per-user cooldown in My::HeartbeatsController#export to block repeated export requests.
  • Added GoodJob concurrency controls to HeartbeatExportJob to ensure only one export job per user runs at a time.
  • Updated UI copy and added controller + system test coverage for the rate limit behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
app/controllers/my/heartbeats_controller.rb Adds cooldown logic that checks recent GoodJob enqueues and blocks repeated export requests.
app/jobs/heartbeat_export_job.rb Adds GoodJob concurrency control keyed by user id to prevent parallel exports per user.
app/javascript/pages/Users/Settings/ImportsExports.svelte Updates settings page copy to communicate the 10-minute export cooldown.
test/controllers/my/heartbeats_controller_test.rb Adds integration test coverage ensuring repeated export POSTs are blocked and don’t enqueue a second job.
test/system/heartbeat_export_test.rb Adds end-to-end coverage for the cooldown behavior in the UI flow and a helper to wait for controls.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +68 to +83
def export_rate_limited?
return false unless recent_export_requested?

redirect_to my_settings_imports_exports_path, alert: "Export requests are limited to once every 10 minutes."
true
end

def recent_export_requested?
GoodJob::Job
.where(job_class: "HeartbeatExportJob")
.where("created_at >= ?", EXPORT_COOLDOWN.ago)
.where(
"serialized_params -> 'arguments' -> 0 = to_jsonb(?::bigint)",
current_user.id
)
.exists?
Comment on lines +69 to +84
return false unless recent_export_requested?

redirect_to my_settings_imports_exports_path, alert: "Export requests are limited to once every 10 minutes."
true
end

def recent_export_requested?
GoodJob::Job
.where(job_class: "HeartbeatExportJob")
.where("created_at >= ?", EXPORT_COOLDOWN.ago)
.where(
"serialized_params -> 'arguments' -> 0 = to_jsonb(?::bigint)",
current_user.id
)
.exists?
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants