Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions engine/lib/tasks/coplan_summaries.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace :coplan do
namespace :summaries do
# One-time backfill of AI summaries for plans created before the
# summary infra (COPLAN-24, #118), which only fires on new
# PlanVersion creates. Enqueues SummarizePlanJob for every plan
# missing a summary; the job's sha-claim debounce makes this safe
# to re-run.
#
# Throttled with a sleep between batches to avoid spiking OpenAI
# cost/rate limits. Tune via env:
#
# BATCH_SIZE=25 INTERVAL=5 bin/rails coplan:summaries:backfill
#
# Plans that already have a summary are excluded by the query;
# plans with no current_plan_version (no content) are skipped.
desc "Backfill AI summaries for plans missing one (COPLAN-31)"
task backfill: :environment do
batch_size = Integer(ENV["BATCH_SIZE"].presence || 25)
interval = Float(ENV["INTERVAL"].presence || 5)

# A prior SummarizePlanJob may have claimed the current content
# sha (summary_content_sha256) but then hit CoPlan::Ai::Error or
# got a blank response — leaving the plan summary-less with the
# sha already claimed. Re-enqueuing alone would no-op forever at
# the job's claim_sha guard, so those plans could never be
# backfilled. Clear the stale claims (only for summary-less
# plans; already-summarized plans keep theirs) so the enqueued
# jobs can re-claim and retry.
reset = CoPlan::Plan.where(summary: nil)
.where.not(summary_content_sha256: nil)
.update_all(summary_content_sha256: nil)

enqueued = 0
skipped = 0

CoPlan::Plan.where(summary: nil).find_each(batch_size: batch_size) do |plan|
if plan.current_plan_version_id.nil?
skipped += 1
next
end

CoPlan::SummarizePlanJob.perform_later(plan_id: plan.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset claimed shas before re-enqueueing failed summaries

When this backfill is rerun after any first attempt where SummarizePlanJob claimed the current SHA but then hit CoPlan::Ai::Error or got a blank response, the plan still has summary: nil but summary_content_sha256 already equals the current version's SHA. Enqueuing the same job again will no-op at claim_sha, so those existing plans can remain permanently summary-less even though the task reports them as enqueued; either clear the claim for nil summaries or add a force/backfill path before enqueueing.

Useful? React with 👍 / 👎.

enqueued += 1

sleep(interval) if interval.positive? && (enqueued % batch_size).zero?
end

puts "coplan:summaries:backfill — enqueued=#{enqueued} skipped=#{skipped} reset_claims=#{reset} (skipped = no current_plan_version; already-summarized excluded by query)"
end
end
end
82 changes: 82 additions & 0 deletions spec/lib/tasks/coplan_summaries_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
require "rails_helper"
require "rake"

RSpec.describe "coplan:summaries:backfill", type: :task do
include ActiveJob::TestHelper

subject(:run_task) { Rake::Task["coplan:summaries:backfill"].tap(&:reenable).invoke }

around do |example|
previous_application = Rake.application
Rake.application = Rake::Application.new
Rake::Task.define_task(:environment)
load CoPlan::Engine.root.join("lib/tasks/coplan_summaries.rake").to_s
example.run
ensure
Rake.application = previous_application
end

before { clear_enqueued_jobs }

it "enqueues SummarizePlanJob for summary-less plans with a current version" do
plan = create(:plan)

expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id)
end

it "skips plans that already have a summary" do
create(:plan).update_columns(summary: "Already summarized.")

expect { run_task }.not_to have_enqueued_job(CoPlan::SummarizePlanJob)
end

it "skips plans with no current_plan_version" do
create(:plan).update_columns(current_plan_version_id: nil)

expect { run_task }.not_to have_enqueued_job(CoPlan::SummarizePlanJob)
end

it "enqueues only the eligible plans when mixed" do
eligible = create(:plan)
create(:plan).update_columns(summary: "Has one.")
create(:plan).update_columns(current_plan_version_id: nil)

# Plan creation enqueues its own SummarizePlanJob (PlanVersion
# after_create_commit); clear those so we observe only the task's.
clear_enqueued_jobs
run_task

expect(enqueued_jobs.map { |job| job[:args].first["plan_id"] }).to eq([eligible.id])
end

it "clears a stale sha claim on a summary-less plan so the retried job isn't debounced away" do
plan = create(:plan)
# Simulate a prior job that claimed the sha but failed/blanked:
# summary stays nil while the sha is already claimed.
plan.update_columns(summary_content_sha256: plan.current_plan_version.content_sha256)

expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id)
expect(plan.reload.summary_content_sha256).to be_nil
end

it "leaves an already-summarized plan's sha claim intact" do
plan = create(:plan)
sha = plan.current_plan_version.content_sha256
plan.update_columns(summary: "Done.", summary_content_sha256: sha)

run_task

expect(plan.reload.summary_content_sha256).to eq(sha)
end

it "falls back to defaults when BATCH_SIZE/INTERVAL are blank" do
plan = create(:plan)
ENV["BATCH_SIZE"] = ""
ENV["INTERVAL"] = ""

expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id)
ensure
ENV.delete("BATCH_SIZE")
ENV.delete("INTERVAL")
end
end
Loading