Problem/Motivation
On a site with ~47,000 rows in user_activities, all purge operations fail with PHP memory exhaustion, even at a 1GB memory limit:
Fatal error: Allowed memory size of 1073741824 bytes exhausted (tried to allocate 20480 bytes) in /core/lib/Drupal/Core/TypedData/Plugin/DataType/Map.php on line 221
Two distinct bugs cause this:
1. ActivitiesPurgeService loads all matching entities into memory before deleting them. Four methods follow the same pattern:
$ids = $query->execute()->fetchCol(); // unbounded — all matching IDs $entities = $storage->loadMultiple($ids); // hydrate every entity $storage->delete($entities); // delete in one shot
At 47k rows this is fatal. The pattern is in purgeByTime(), purgeByCount(), purgeByEntityType(), and is duplicated inline in ActivitiesManualPurgeForm::batchPurgeAll().
2. The form's batch operations do not actually iterate. batchPurgeByEntityType() and batchPurgeByTime() both call the service's purge method once inside the sandbox-init guard, then set $context['finished'] = 1. The "batch" is a single operation that processes everything synchronously, defeating the point.
The consequence is that the cron-driven auto-purge (configured via purge.purge_method = time_based) silently fails every run — the exception is caught and logged, but the table keeps growing.
Steps to reproduce
- Install the module on a site with significant content activity (or seed the
user_activitiestable with ~50k rows). - Go to the manual purge form at
/admin/config/activities/purge/manual. - Choose "Delete by entity type", select node, submit.
- Observe the AJAX batch fail with HTTP 500 / memory exhaustion in
Map.php.
Cron-based auto-purge fails the same way, but silently — the exception is caught in executePurge() and only logged.
Proposed resolution
Replace the load-and-delete pattern with chunked direct-table deletes. This is safe for the user_activities entity because:
- It has no field data tables (all base fields live in the
user_activitiestable). - It has no file references or other entity references pointing to it.
- Its only delete hook (
activities_user_activities_insert— note: insert, not delete) doesn't fire on deletion, so no hook side-effects are lost.
The patch:
- Refactors
ActivitiesPurgeServiceto delete via the database connection in chunks of 500 rows. - Introduces a public
deleteChunk(array $conditions, int $limit)method on the service so batch operations can iterate without re-implementing query building. - Rewrites all three
batchPurge*methods inActivitiesManualPurgeFormto actually iterate per batch invocation, calculate$context['sandbox']['max']up front for accurate progress reporting, and set$context['finished']as a fraction until completion. - Adds a
$form_state->setRedirect('<current>')afterbatch_set()insubmitForm()so the AJAX renderer has a destination on completion (was triggering aTypeErrorinAjaxRenderer::renderResponse()for null main content).
Verified on a real production-derived dataset of 47,278 rows in DDEV:
- Direct CLI call (
purgeByEntityType('node')): peak memory ~16 MB, deletes successfully. - Drush batch run: clean iteration in chunks of 500, completes all 46,964 rows with no memory growth.
- Browser manual purge form: completes successfully, status message confirms deletion count.
Compare to original behaviour: fatal OOM at 1024MB before deleting a single row.
Remaining tasks
- Patch review.
- Test coverage for the chunked purge behaviour would be valuable but isn't included in this patch.
- Possible follow-up: the post-batch redirect lands on
<current>, which is the form route but may resolve to "Access denied" for some user/permission combinations. A dedicated success page or routing back to the purge form by route name might be cleaner; left as-is for minimal-change scope.
User interface changes
None visible to end users. The manual purge form now displays an accurate progress bar during batch operations (previously the batch completed in a single non-iterative step, so progress was instantaneous on the rare occasions it succeeded at all).
API changes
One new public method on ActivitiesPurgeService:
public function deleteChunk(array $conditions, int $limit = self::CHUNK_SIZE): int
Existing public method signatures are preserved; only return types were tightened (added : int declarations) and parameter types where they were previously untyped. Callers passing correctly-typed values are unaffected.
Data model changes
None.
| Comment | File | Size | Author |
|---|---|---|---|
| activities-memory-exhaustion-purge.patch | 19.58 KB | bluehead |
Issue fork activities-3601300
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
bluehead commentedPushed as MR !13
Tested on a production-derived dataset of 47,278 rows in DDEV. Direct CLI call peaks at ~16MB (previously OOM at 1GB). Drush batch and browser manual purge form both complete successfully.
Comment #4
bluehead commentedComment #5
cleevewaters commentedTested and confirmed to work
Thanks @bluehead
Comment #6
cleevewaters commented