THREESCALE-11354: Sync application plan name to backend#4325
Conversation
|
is it not simpler to add the method to pisoni? PR description sounds like it is harder but adding a background worker with the possibility that it may fail or be delayed seems to me as much more work 🤔 |
I don't think it'll be specially hard. It's just it requires extra steps like creating a PR to pisoni repo, then launch a new release, upgrade porta side... that's what I wanted to avoid. Also, there's no other use case for applications batch update in backend. And it's not much important if the job is delayed or fails because It's not really clear why the clients want this in the first place, as this guy commented https://redhat.atlassian.net/browse/THREESCALE-11354?focusedCommentId=11989077 |
|
I somehow don't like introducing background jobs for no important reason. They are somehow harder to catch errors with and number of background jobs sometimes seem to create troubles. It's more about that, than the actual risk of not having name updated. But if you think this a rare operation I can agree. |
|
3scale/pisoni#36 we need to update pisoni anyways |
|
@akostadinov I made some changes to use the new pisoni release. Now the update is made in batches of 1000 apps. I know you don't like it, but I decided to keep the background job. I don't think we can avoid it because, tough most of plans have less than 1000 apps and would be updated in one request/batch, there are a few dozens plans with more than 10K apps assigned. There's even one plan with 1.3 millions apps. In my local machine, updating a plan with 15K apps, takes the background job about 8 seconds to complete. I don't think it's acceptable to block a client's request for so much time. Also, we should consider that unicorn has a timeout of 30 or 40 seconds I think, so there's a top limit on the number of apps that could be updated synchronously. A request to update a plan with about 60-70K apps would keep the client blocked for 30 seconds, only to end up failing with a timeout. I also refactored a bit to satisfy Qltysh demands. |
|
Did updating 15k apps with pisoni in a single request fail? |
Haven't tried, the batch size is 1000. How big do you think the batch should be? |
If there is no issue updating 15k apps, I think it is fine to just do it instead of introducing batches. |
What do you mean? Edited the release after release? Wouldn't it better to just release a new patch version? |
I meant I made changes here, in this PR, to call the new pisoni method |
But what's the limit? at some point we will need batches. We have a plan with 1.3 million apps. |
If things work in a single call, then why implement a batch? Will 1.5M in a single call work? |
Not realistic. The payload size would be huge and would take a lot of time to process. I'm sure we would hit a timeout or some DoS protection on the way. I'd say a more reasonable batch size would be about 10k apps which would take about 1MB per request. |
When an application plan's name is updated via the Account Management API, the new name was not being propagated to Apisonator (the backend). This caused plan_name to become stale in the backend, even though it's sent during application authorization checks. Add an after_commit callback on ApplicationPlan that detects name changes and enqueues a Sidekiq worker to iterate all applications (cinstances) of that plan, calling update_backend_application on each. The worker uses includes(:service, :plan) to prevent N+1 queries. This ensures Apisonator receives the updated plan name without requiring application-level changes. Fixes: THREESCALE-11354 Assisted-by: Claude Code
Add unit tests for the backend extension callback and worker that sync plan names to Apisonator when an ApplicationPlan is updated. The tests verify: - Worker is enqueued only when plan name changes (not other attributes) - Worker syncs plan_name to backend for each cinstance - Worker handles missing plans gracefully - No N+1 queries when iterating cinstances Assisted-by: Claude Code
Add an integration test that verifies the end-to-end flow when updating an application plan name through the Account Management API. The test confirms that a PUT request to update the plan name triggers the worker, which then syncs the new plan_name to Apisonator for all applications subscribed to that plan. Assisted-by: Claude Code
Switch BackendUpdateApplicationPlanWorker from iterating cinstances individually (N separate HTTP calls to Apisonator) to batching them via Application.save_batch. This reduces the number of backend requests from O(n) to O(n/1000), which matters for plans with hundreds of thousands of applications. The batch logic is extracted to ApplicationPlan#update_backend_plan to keep the worker thin and allow unit-testing the batch construction independently. The service_id passed to save_batch uses backend_id (string) to match the convention used everywhere else in the backend sync layer. Assisted-by: Claude Code
Extract the per-application hash construction into a private backend_application_attributes method to resolve the NestedIterators reek smell. Hoist service.backend_id outside the batch loop so it is computed once per plan rather than once per application, resolving the RepeatedCall smell. Add :reek:UtilityFunction suppression to the worker's perform method, consistent with other workers in the codebase. Assisted-by: Claude Code
Extract the expected backend application hash construction into a shared helper expected_backend_applications to reduce the statement count and ABC size of test_update_syncs_plan_name_to_backend below qltysh thresholds. Rename the lambda parameter n to count to satisfy the UncommunicativeVariableName reek rule. Assisted-by: Claude Code
Move backend_application_attributes from ApplicationPlan to Cinstance where it belongs — the method references app state/attributes 4 times vs plan attributes 2 times, triggering reek:FeatureEnvy. With the method on Cinstance, both callers (update_backend_application and update_backend_plan) can delegate to it, eliminating duplication flagged in code review. The update_backend_plan caller now uses batch.map(&:backend_application_attributes) instead of a block literal, resolving reek:NestedIterators. Also add .includes(:service, :plan) to prevent N+1 queries when batching. Assisted-by: Claude Code
bf64970 to
461da32
Compare
|
Rebased to solve conflicts |
Collapse the two-line nil check into a single idiomatic guard clause using assignment in condition, as suggested in code review. Parentheses satisfy the linter's Style/AssignmentInCondition requirement.
Review: Sync Application Plan Name to BackendThe problem is real and the approach is sound — renaming an Issues1. In Suggested alternative: def update_backend_plan
bid = service.backend_id
pid = id
pname = name
cinstances.select(:id, :application_id, :state, :redirect_url).find_in_batches do |batch|
apps = batch.map do |ci|
state = ci.state
state = :active if ci.live?
{ service_id: bid, id: ci.application_id, state: state,
plan_id: pid, plan_name: pname, redirect_url: ci.redirect_url }
end
ThreeScale::Core::Application.save_batch(bid, apps)
end
endThis eliminates the eager loads and avoids loading unnecessary columns. 2. No error handling — one failed batch aborts the entire sync If cinstances.find_in_batches do |batch|
begin
ThreeScale::Core::Application.save_batch(...)
rescue => e
Rails.logger.error("Failed to sync batch for plan #{id}: #{e.message}")
end
end3. No concurrency/dedup protection
4. Default For 1.5M cinstances: 1500 sequential HTTP calls to apisonator. I benchmarked the apisonator batch endpoint directly — 10k apps/batch runs fine at ~810KB request size and ~2.6s. Bumping 5. PR description contradicts the code The description says "I considered adding an What's correct
Benchmark data (apisonator batch endpoint)I wrote a benchmark (
1.5M in a single call works but uses ~3 GB RSS. The batched approach from porta (1000 per call) avoids this memory spike entirely — each call is tiny. The main cost is HTTP round-trip overhead from many sequential calls, hence the suggestion to increase batch_size. |
Follow-up: Consider Iterable JobsThis worker is a textbook case for iterable/interruptible jobs. The current
Sidekiq 7.3+ has built-in iterable jobs ( With iterable jobs the worker would look roughly like: class BackendUpdateApplicationPlanWorker
include Sidekiq::Job
include SidekiqIteration::Iteration
def build_enumerator(plan_id, cursor:)
plan = ApplicationPlan.find_by(id: plan_id)
return unless plan
active_record_batches_enumerator(
plan.cinstances.select(:id, :application_id, :state, :redirect_url),
cursor: cursor,
batch_size: 5000
)
end
def each_iteration(batch, plan_id)
plan = ApplicationPlan.find_by(id: plan_id)
return unless plan
bid = plan.service.backend_id
apps = batch.map do |ci|
state = ci.state
state = :active if ci.live?
{ service_id: bid, id: ci.application_id, state: state,
plan_id: plan.id, plan_name: plan.name, redirect_url: ci.redirect_url }
end
ThreeScale::Core::Application.save_batch(bid, apps)
end
endWhat this gives you for free:
Even if adding |
Correction: Sidekiq 7.3 is already on masterMy previous comment incorrectly stated porta is on Sidekiq 6.4 — I was looking at a stale local branch. Master has Sidekiq 7.3.2, which means This makes the iterable jobs approach even more straightforward: class BackendUpdateApplicationPlanWorker
include Sidekiq::Job
include Sidekiq::Job::Iterable
sidekiq_options queue: :backend_sync
def build_enumerator(plan_id, cursor:)
plan = ApplicationPlan.find_by(id: plan_id)
return unless plan
active_record_batches_enumerator(
plan.cinstances.select(:id, :application_id, :state, :redirect_url),
cursor: cursor,
batch_size: 5000
)
end
def each_iteration(batch, plan_id)
plan = ApplicationPlan.find_by(id: plan_id)
return unless plan
bid = plan.service.backend_id
apps = batch.map do |ci|
state = ci.state
state = :active if ci.live?
{ service_id: bid, id: ci.application_id, state: state,
plan_id: plan.id, plan_name: plan.name, redirect_url: ci.redirect_url }
end
ThreeScale::Core::Application.save_batch(bid, apps)
end
endZero new dependencies. Cursor-based resumption, graceful shutdown on pod kill, per-batch error isolation — all built in. |

What this PR does / why we need it:
This PR is about syncing the application name to backend when the name is updated from porta.
An application plan can be attached to many cinstances, and despite backend providing an endpoint for application batch update, our
pisonigem doesn't provide a method for it, so we have to send a request per cinstance. Due to that, I added a worker to do the sync, so the user request is not blocked for plans with many cinstances.I considered adding an
Application.save_batchmethod topisoni, or sending the request any other way, but I don't think any of those is worth it, considering how much demand this change will really have in real world.Which issue(s) this PR fixes
https://redhat.atlassian.net/browse/THREESCALE-11354
Verification steps
PUT /admin/api/services/<id>/application_plans/<plan_id>GET /transactions/authorize.xml