The final diff of gitlab-org/gitlab!270142 is tiny: a handful of lines in the service and a few more in its spec. But getting it merged took a database review with real production query plans, two spec improvements from a reviewer who tested them by breaking the code on purpose, and a Danger failure I caused myself.
This post covers what the issue was, why each_batch was the wrong tool here, how the specs got stronger in review, and what database review looks like when you're a community contributor without access to GitLab's production database tools.
The issue: a batch loop over something already batched
The issue was gitlab-org/gitlab#639257, opened by Tim Rizzi. It came from a suggestion by Daniyal Arshad in the review of an earlier MR (!266409), which added a Maven snapshot pass to the package cleanup policy.
Some background. Packages::Cleanup::ExecutePolicyService runs a project's package cleanup policy. For Maven snapshot packages, it works out which old snapshot builds to remove and marks their files as pending_destruction. A background job deletes them later.
The marking happened in slices of up to 200 ids. Each slice was handed to another service:
old_ids_by_name.values.flatten.each_slice(MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE) do |ids|
result = ::Packages::MarkPackageFilesForDestructionService.new(::Packages::PackageFile.id_in(ids))
.execute(batch_deadline: batch_deadline, batch_size: MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE)
@counts[:marked_package_files_total_count] += result.payload[:marked_package_files_count]
break :timeout unless result.success?
end
And here's the core of that service:
@package_files.each_batch(of: min_batch_size) do |batched_package_files|
if batch_deadline&.past?
timeout = true
break
end
updates_count += batched_package_files.update_all(status: :pending_destruction)
end
each_batch is GitLab's standard way to walk a large relation safely. It finds a start id, then a boundary id, then yields a batch. That's exactly right when you don't know how big the relation is.
Here, though, the relation is PackageFile.id_in(ids) with at most 200 ids, and the batch size is 200. There is only ever one batch. So each slice paid for two lookups whose only job was to find out what we already knew.
The plan
I commented on the issue with this plan:
My plan is to replace
each_batchover the one-batch snapshot relation witheach_sliceplusPackages::PackageFile.id_in(ids).update_all(status: :pending_destruction), matchingmark_packages_for_destruction, and to stubbatch_deadlinein the snapshot timeout spec.
mark_packages_for_destruction is a method in the same service that already slices ids and updates them directly. Matching an existing pattern in the same file is the easiest kind of change to review. Tim replied that the plan "sounds right", assigned me, and asked me to tag Tim and Fiona McCawley on the MR.
The change
The production change is the loop body, now one statement per slice:
old_ids_by_name.values.flatten.each_slice(MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE) do |ids|
break :timeout if batch_deadline.past?
@counts[:marked_package_files_total_count] +=
::Packages::PackageFile.id_in(ids).update_all(status: :pending_destruction)
end
Two behaviors had to stay the same:
-
The deadline. The old service checked
batch_deadlinebefore each batch. The new loop checks it before each slice, so a long cleanup still stops on time and reports:timeout. -
The count.
update_allreturns the number of updated rows, somarked_package_files_total_countstill adds up the same way.
The spec needed a change too. The old timeout example stubbed MarkPackageFilesForDestructionService to return an error, and that service isn't called anymore.
CI lesson: Danger fails on long commit subjects
My first push failed the danger-review job, and it was entirely my fault. GitLab's Danger rules require commit subjects of 72 characters or fewer, and mine was longer. I reworded it to Use update_all for Maven snapshot cleanup slices and pushed again. The next pipeline passed.
The same Danger run also left a note on the update_all line:
When using any of these: … Bulk commands:
bulk_insert,update_all, etc. … You must:
- Include the full database query in the merge request description (including scopes, pagination, and limits)
- Include the query execution plan
- Request a ~database review
Adding the SQL was easy: a primary-key UPDATE on packages_package_files with WHERE id IN (...) for up to 200 ids. The query plan was harder.
Database review without database access
GitLab's query plans come from Database Lab, which runs queries against a clone of production. It needs GitLab team access, and as a community contributor I don't have it. I put the full SQL in the description, explained that it's the same statement mark_packages_for_destruction already runs, and asked for a database review.
Fiona stepped in:
Database Lab needs GitLab team access, so I've captured the plans: the old and new SQL, each followed by its query plan, are now in the Database query section of the description.
Fiona went further than I expected and picked a real slice from production data: the first 200 ids this pass would mark for one snapshot package in gitlab-org/editor-extensions/gitlab-eclipse-plugin. That package has 601 installable files, and with keep_n_duplicated_package_files: 1 the pass marks 442 of them in slices of 200, 200 and 42.
Fiona's table, summarized:
| Statement | Plan | Time (cold clone) | |
|---|---|---|---|
| After | UPDATE ... WHERE id IN (...) |
Index Scan on the primary key, 200 rows | 968 ms |
| Before | start SELECT ... LIMIT 1
|
Index Only Scan on the primary key | 6 ms |
boundary SELECT ... LIMIT 1 OFFSET 200
|
Index Only Scan on the primary key | 92 ms | |
UPDATE ... AND id >= <start> |
Index Scan on the primary key, 200 rows | 596 ms |
The headline: the UPDATE plan is unchanged, and the two lookups disappear. The UPDATE times differ between runs mostly because of cold I/O on the clone. Fiona noted that a second run of the new UPDATE on a warmed clone took 760 ms with the same plan, and every UPDATE run touched about the same number of buffers.
This is the part of contributing that doesn't show up in the diff. The reviewer filled a gap I couldn't fill, and did it with real data instead of a guess.
The review that made the specs stronger
Fiona also left two spec suggestions. Both came with a "Why" section explaining how Fiona had tested them. I think this is the best kind of review feedback.
1. Guard the listing deadline check
The service checks the deadline in two places: while listing snapshot files, and before each update slice. My spec for "deadline already passed" asserted that no file changed and that timeout was true. Fiona found a hole:
I deleted
break :timeout if batch_deadline.past?from the listing loop (execute_policy_service.rb:184) and ran this example locally: it still passed.
Without the listing check, the listing runs to the end, then the first slice's deadline check stops the marking, and no file changes. My assertions couldn't tell the two code paths apart. The suggested fix was one line on a counter that only grows after listing:
expect(execute.payload[:counts][:unique_package_id_and_file_name_total_count]).to eq(0)
With this line, the example fails if the listing check is removed.
2. Don't rely on counting calls
My "timeout between slices" spec stubbed the deadline with a sequence: and_return(false, false, true). Fiona pointed out that the sequence is shared by every batch_deadline.past? call in execute, including other passes. So a new check anywhere earlier would shift it and break the test for the wrong reason.
The suggestion flips the deadline right after the first update, by wrapping PackageFile.id_in. That's the same approach an existing example uses for the generic rule:
allow(::Packages::PackageFile).to receive(:id_in).and_wrap_original do |method, ids|
allow(service).to receive(:batch_deadline).and_return(1.second.ago)
method.call(ids)
end
With the batch size stubbed to 2, the example now asserts that exactly two files are marked before the timeout:
it 'stops after the current batch and reports a timeout' do
expect { execute }.to change { installable_files.count }.by(-2)
expect(execute.payload[:timeout]).to be(true)
expect(execute.payload[:counts][:marked_package_files_total_count]).to eq(2)
end
I applied both in one commit (028c6d2f), specs only, and credited Fiona in the commit message. Fiona then approved the backend side and asked Jean van der Walt for the database review.
Database approval, and a follow-up idea
Jean reviewed the database side:
Database looks good: the
UPDATEplan is unchanged, and each slice now skips the twoeach_batchlookups.
Jean also left a non-blocking note: the 200-row UPDATE takes about 600–960 ms on a cold clone, and a follow-up could try a smaller MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE. I agreed it's worth trying and kept it out of this MR. Changing the batch size changes the risk profile, and it deserves its own measurement.
Abdul Wadood gave the database maintainer approval ("~database LGTM! 🚀"), and Abdul merged the MR. Five pipelines in total: one failure (my commit subject) and four green.
What I took away
1. each_batch is for unknown sizes. If you've already sliced the ids, a direct update_all per slice is cheaper and easier to read. Look for an existing pattern in the same file before inventing one.
2. Keep deadline semantics when you refactor a loop. Check the deadline at the same granularity as before, and keep the counters adding up the same way.
3. Test that your test can fail. Fiona's method was simple: delete the line the spec claims to protect and see if the spec still passes. If it does, the spec isn't guarding anything.
4. Prefer event-based stubs over call-count sequences. and_return(false, false, true) breaks when someone adds an unrelated check. Flipping the stub when the interesting thing happens doesn't.
5. Community contributors can still do database work. Put the full SQL in the description, explain why it's safe, and ask. Reviewers with access can capture the plans. Just make their job easy.
6. Keep commit subjects within 72 characters. Danger will fail the pipeline if you don't.
Links
- Issue: gitlab-org/gitlab#639257
- Merge request: gitlab-org/gitlab!270142
- Where the idea came from: gitlab-org/gitlab!266409
- GitLab's guide to preparing bulk updates for database review: docs.gitlab.com/development/database_review
Thanks to Tim Rizzi for the issue, Daniyal Arshad for the original idea, Fiona McCawley for the plans and the spec reviews, Jean van der Walt for the database review, and Abdul Wadood for the final approval.


Top comments (0)