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

  1. Install the module on a site with significant content activity (or seed the user_activities table with ~50k rows).
  2. Go to the manual purge form at /admin/config/activities/purge/manual.
  3. Choose "Delete by entity type", select node, submit.
  4. 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_activities table).
  • 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 ActivitiesPurgeService to 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 in ActivitiesManualPurgeForm to 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>') after batch_set() in submitForm() so the AJAX renderer has a destination on completion (was triggering a TypeError in AjaxRenderer::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.

CommentFileSizeAuthor
activities-memory-exhaustion-purge.patch19.58 KBbluehead

Issue fork activities-3601300

Command icon 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

bluehead created an issue. See original summary.

bluehead’s picture

Pushed 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.

bluehead’s picture

Issue summary: View changes
cleevewaters’s picture

Tested and confirmed to work

  • Checked existing state (without patch) and confirmed there was an issue
  • Applied the patch
  • Manually purged the activities
  • Confirmed the activities were purged

Thanks @bluehead

cleevewaters’s picture

Status: Needs review » Reviewed & tested by the community