This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository 
https://gitbox.apache.org/repos/asf/pekko-persistence-cassandra.git


The following commit(s) were added to refs/heads/main by this push:
     new 7342e07f Add Cleanup.close to release the reconciliation it starts 
(#493)
7342e07f is described below

commit 7342e07f67e23f7533c09b13bb797c385e9cfbbc
Author: PJ Fanning <[email protected]>
AuthorDate: Wed Sep 23 09:23:48 2026 +0100

    Add Cleanup.close to release the reconciliation it starts (#493)
    
    Motivation:
    Cleanup's tagged event operations lazily create a Reconciliation, which 
starts a
    system actor that stays alive for the lifetime of the ActorSystem. Cleanup 
is
    documented as a per-batch tool that can be run several times in parallel, so
    every instance that deletes tagged events leaks one of those actors.
    
    Modification:
    Add `Cleanup.close()`, which closes the internal Reconciliation. Track 
whether
    the lazily created Reconciliation exists so that closing a Cleanup that 
never
    touched tagged events does not create one just to stop it again. Document 
the
    new method in the cleanup docs.
    
    Result:
    Callers can release the actor when they are done cleaning up, and a Cleanup 
used
    only for snapshot or event deletes never starts one at all.
    
    Tests:
    - sbt "core/Test/testOnly 
org.apache.pekko.persistence.cassandra.cleanup.CleanupCloseSpec" - passed (new 
spec, no Cassandra needed)
    - sbt "core/mimaReportBinaryIssues" - passed
    - scalafmt --mode diff-ref=upstream/main --list - no changes needed
    - Cassandra integration tests not run - no local Cassandra (docker not 
running)
    
    References:
    Refs the Reconciliation.close change this builds on - resource leak found 
while
    auditing actor and session lifecycles
---
 .../persistence/cassandra/cleanup/Cleanup.scala    | 30 +++++++-
 .../cassandra/cleanup/CleanupCloseSpec.scala       | 83 ++++++++++++++++++++++
 docs/src/main/paradox/cleanup.md                   |  3 +
 3 files changed, 113 insertions(+), 3 deletions(-)

diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/cleanup/Cleanup.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/cleanup/Cleanup.scala
index d9a0c0e1..edb5a348 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/cleanup/Cleanup.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/cleanup/Cleanup.scala
@@ -48,6 +48,9 @@ import com.datastax.oss.driver.api.core.cql.Row
  * When a list of `persistenceIds` are given they are deleted sequentially in 
the order
  * of the list. It's possible to parallelize the deletes by running several 
cleanup operations
  * at the same time operating on different sets of `persistenceIds`.
+ *
+ * The tagged event operations start an actor that lives until 
[[Cleanup#close]] is called, so an
+ * instance should be closed once the cleanup operations it was created for 
have completed.
  */
 @ApiMayChange
 final class Cleanup(systemProvider: ClassicActorSystemProvider, settings: 
CleanupSettings) {
@@ -67,9 +70,16 @@ final class Cleanup(systemProvider: 
ClassicActorSystemProvider, settings: Cleanu
   // operations on journal, snapshotStore and tagViews should be only be done 
when dry-run = false
   private val journal: ActorRef = 
Persistence(system).journalFor(pluginLocation + ".journal")
   private lazy val snapshotStore: ActorRef = 
Persistence(system).snapshotStoreFor(pluginLocation + ".snapshot")
-  private lazy val tagViewsReconciliation = new Reconciliation(
-    system,
-    new ReconciliationSettings(system.settings.config.getConfig(pluginLocation 
+ ".reconciler")))
+  // only the tagged event operations need this and it starts an actor, so 
track whether it was
+  // created to keep `close` from creating one just to stop it again
+  @volatile private var tagViewsReconciliationCreated = false
+  private lazy val tagViewsReconciliation = {
+    val reconciliation = new Reconciliation(
+      system,
+      new 
ReconciliationSettings(system.settings.config.getConfig(pluginLocation + 
".reconciler")))
+    tagViewsReconciliationCreated = true
+    reconciliation
+  }
 
   private implicit val askTimeout: Timeout = operationTimeout
 
@@ -339,6 +349,20 @@ final class Cleanup(systemProvider: 
ClassicActorSystemProvider, settings: Cleanu
     
sendToSnapshotStore(CassandraSnapshotStore.DeleteAllSnapshots(persistenceId))
   }
 
+  /**
+   * Releases the resources that the tagged event operations acquired. Without 
this the actor that
+   * they start stays alive for the lifetime of the `ActorSystem`, so call 
this once the cleanup
+   * operations have completed.
+   *
+   * Any operation still in progress is aborted, so only close after the 
futures returned by the
+   * other methods have completed. This instance must not be used again after 
it has been closed.
+   *
+   * Calling this more than once has no further effect, and it does nothing if 
no tagged event
+   * operation was used.
+   */
+  def close(): Unit =
+    if (tagViewsReconciliationCreated) tagViewsReconciliation.close()
+
   private def foreach(
       persistenceIds: Seq[String],
       operationName: String,
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/cleanup/CleanupCloseSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/cleanup/CleanupCloseSpec.scala
new file mode 100644
index 00000000..69f18b5d
--- /dev/null
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/cleanup/CleanupCloseSpec.scala
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.persistence.cassandra.cleanup
+
+import scala.concurrent.duration._
+
+import com.typesafe.config.ConfigFactory
+import org.apache.pekko
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpecLike
+import pekko.actor.{ ActorIdentity, ActorRef, ActorSystem, Identify }
+import pekko.testkit.{ ImplicitSender, TestKit }
+
+object CleanupCloseSpec {
+
+  // no Cassandra is needed, only the lifecycle of the reconciliation actor is 
asserted on
+  val config = ConfigFactory.parseString("""
+      pekko.persistence.cassandra.cleanup.dry-run = true
+      datastax-java-driver.advanced.reconnect-on-init = false
+    """)
+}
+
+class CleanupCloseSpec
+    extends TestKit(ActorSystem("CleanupCloseSpec", CleanupCloseSpec.config))
+    with AnyWordSpecLike
+    with Matchers
+    with ImplicitSender
+    with BeforeAndAfterAll {
+
+  override protected def afterAll(): Unit = {
+    shutdown()
+    super.afterAll()
+  }
+
+  private def reconciliationTagWriters(): Seq[ActorRef] = {
+    system.actorSelection("/system/reconciliation-tag-writers-*") ! 
Identify(())
+    receiveWhile(500.millis) {
+      case ActorIdentity(_, ref) => ref
+    }.flatten
+  }
+
+  "Cleanup" should {
+
+    "not start a reconciliation when only snapshot and event operations are 
used" in {
+      val cleanup = new Cleanup(system)
+
+      cleanup.close()
+
+      reconciliationTagWriters() shouldBe empty
+    }
+
+    "stop the reconciliation it started when closed" in {
+      val cleanup = new Cleanup(system)
+      // the returned future needs Cassandra and is deliberately not awaited, 
it is only used to
+      // trigger the creation of the internal Reconciliation
+      cleanup.deleteAllTaggedEvents("pid-1")
+
+      val tagWriters = reconciliationTagWriters()
+      tagWriters should have size 1
+      watch(tagWriters.head)
+
+      cleanup.close()
+
+      expectTerminated(tagWriters.head)
+    }
+  }
+}
diff --git a/docs/src/main/paradox/cleanup.md b/docs/src/main/paradox/cleanup.md
index 2ea58865..f90fa01d 100644
--- a/docs/src/main/paradox/cleanup.md
+++ b/docs/src/main/paradox/cleanup.md
@@ -28,4 +28,7 @@ Scala
 Java
 : @@snip 
[snapshot-keyspace](/docs/src/test/java/jdoc/cleanup/CleanupDocExample.java) { 
#cleanup } 
 
+The operations that delete tagged events start an actor that lives for as long 
as the `ActorSystem` does, so call
+`close()` on the cleanup tool once the operations have completed.
+
 By default, all operations only print what they were going to do. Once you're 
happy with what the cleanup tool is going to do set 
`pekko.persistence.cassandra.cleanup.dry-run = false`


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to