Repository navigation
perf: shard journal resequencers to reduce HOL blocking - #32994
leviramsey wants to merge 8 commits into
Conversation
|
Benchmarked with a pathological in-memory store (high variation in persist latency) with 1000 actors persisting ~100 events each as fast as they can: Benchmark: class DelayingInMemPlugin extends AsyncWriteJournal with AsyncRecovery {
// Instantaneous recovery, a miracle
override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = zeroFuture
override def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)(
recoveryCallback: PersistentRepr => Unit): Future[Unit] =
Future.unit
override def asyncWriteMessages(messages: Seq[AtomicWrite]): Future[Seq[Try[Unit]]] = {
if (schedulerGranularityMillis < 1) {
schedulerGranularityMillis =
context.system.settings.config.getDuration("akka.scheduler.tick-duration").toMillis.min(Int.MaxValue / 2).toInt
}
val rand = ThreadLocalRandom.current()
val delayMillis = {
val first = rand.nextInt(101)
first * rand.nextInt(first + 1)
}
val fut = Future.successful(messages.map(_ => successUnit))
if (delayMillis < schedulerGranularityMillis && rand.nextInt(schedulerGranularityMillis) > delayMillis) fut
else after(delayMillis.millis)(fut)(context.system)
}
override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit] = Future.unit
val zeroFuture = Future.successful(0L)
val successUnit = Success(())
var schedulerGranularityMillis: Int = -1
}
object Benchmark {
val config = ConfigFactory.parseString("""
akka.persistence.journal.plugin = delaying-in-mem
delaying-in-mem {
class = "akka.persistence.journal.DelayingInMemPlugin"
num-resequencers = 2048
circuit-breaker.call-timeout = 11s
}
""").withFallback(ConfigFactory.load())
def main(args: Array[String]): Unit = {
implicit val system = ActorSystem("Resequencer-Benchmark", config)
import system.dispatcher
val totalEvents = if (args.length > 0) args(0).toInt else 100000
val numActors = if (args.length > 1) args(1).toInt.min(totalEvents) else 1000
val eventsPerActor = 1 + (totalEvents / numActors)
val actors = (1 to numActors).iterator
.map(i => system.actorOf(testPersistentActor(s"test-persistent-$i"), s"test-persistent-$i"))
.toArray
implicit val timeout: Timeout = 20.seconds
val streams = actors.iterator.map { actor =>
Source
.repeat(())
.take(eventsPerActor)
.mapAsync(1) { _ =>
actor ? "persist"
}
.toMat(Sink.ignore)(Keep.right)
}.toSeq
system.log.info("Using {} resequencers", system.settings.config.getInt("delaying-in-mem.num-resequencers"))
val startTime = System.nanoTime()
Await.result(
Future.sequence(streams.iterator.map(_.run()).toSeq).flatMap { _ =>
val endTime = System.nanoTime()
val elapsed = endTime - startTime
system.log.info(
"Persisted {} events (via {} actors) in {} seconds",
eventsPerActor * numActors,
numActors,
elapsed / 1000000000L)
system.terminate()
},
(11 * eventsPerActor).seconds)
system.terminate() // just in case
}
def testPersistentActor(persistenceId: String): Props = Props(new TestPersistentActor(persistenceId))
class TestPersistentActor(override val persistenceId: String) extends PersistentActor with ActorLogging {
override def receiveRecover = PartialFunction.empty // no events to replay
override def receiveCommand = {
log.info("Recovered test persistent actor {}", persistenceId);
{
case s: String =>
val replyTo = sender()
persist(s) { _ =>
replyTo ! Done
}
}
}
}
} |
|
I think the idea has merit. Did you try running bench/test against actual database, with R2DBC for example? |
Reproducing high-variance latency against a non-pathological backend would be difficult (short of introducing a pathological serializer which does some random |
|
Sleeping inside a serializer is not the right simulation. I think the interesting part would be delays from the outside, Toxiproxy with latency toxic and jitter, in front of a real DB to introduce latency for example. An alternative could be something like a wrapper journal that delegates to the in-mem plugin and delays the completed future with the scheduler for ~1% of writes (say 100-500ms extra). I think it would also be better with p50 and p99 percentiles persist round-trip latency with 1 vs 8 or 16 resequencers, since the claim here is that we improve latency. Total time isn't the right proof. We'd also want the same runs with zero delay introduced, to confirm a non default value does not cost throughput when the store is healthy. One aspect of this that I didn't think of initially is that for a struggling/overloaded database this multiple resequencers may actually be worse since the current head-of-line blocking kind of acts as accidental backpressure. |
|
"accidental backpressure" is called out in the PR description ("To the extent that an occasional outlier is a symptom of an overloaded persistence backend, the resequencer introduces a small amount of backpressure on persists by delaying the next persist for an actor waiting for the head-of-line to unblock") and in the With a slight tweak to make the resequencer specifics internal to the The benchmark plugin effectively does a more severe version of "something like a wrapper journal that delegates to the in-mem plugin and delays the completed future with the scheduler" (potentially delaying for more than 10 seconds and imposing >500ms delay on more than 1% of persists), but it can be tweaked into that form. Percentiles on the persist asks can be added, too. |
|
Using in-mem with a configurable percentage chance of delaying by 100-500ms (100 total actors, 100,100 total events; 4 core/8 thread x86 processor), times in ms Percentiles are a windowless estimate... the persist latency is only on the first 100k persists (~99.9% of the total), so the cases where ask latency is less than persist latency at a given percentile plausibly are due to the last asks completing faster (e.g. due to less delay or less contention) |
|
Can you follow up with:
|
bench: |
|
The in-memory numbers show that the resequencer itself stops being the bottleneck. They cannot show what happens when a real backend is the bottleneck, which is the case where anyone would turn this on. Please run the same open-loop bench against akka-persistence-r2dbc with Postgres. Three reasons:
Concretely, for R = 1, 8 and 16:
The result decides the recommendation we put in the reference.conf comment. If p99 improves without hurting throughput or the healthy case, the PR is good to go and we can suggest a value. If the pool becomes the bottleneck, the comment should say so and the setting stays a niche tuning knob. |
|
Open-loop with R2DBC
One open question is if it's worth allowing |
|
Thanks for the R2DBC numbers btw, looks good. |
|
Looking good. 🤖 review:
|
There was a problem hiding this comment.
Blocking
AsyncWriteJournal.scala:52: the validation rejects valid configuration. It checksvalueType != NUMBER, but values coming from-Dsystem properties or${?ENV_VAR}substitutions are strings, whichgetIntparses fine. Withwrite-reply-ordering-groups = "4"the journal fails at startup withwrite-reply-ordering-groups must be a positive integer, and persistent actors then hang until recovery timeout.AsyncWriteJournal.scala:65:0and negative values are silently clamped to 1 via.max(1). As requested earlier these should fail journal startup. Verified locally that0and-3start without any error.- Both are solved by dropping the block at lines 49-56 (and the
ConfigValueTypeimport) and validating inside thereceiveWriteJournalinitializer instead. That initializer is an eagerfinal val, so it already runs at construction:Please add a test thatval numResequencers = config.getInt("write-reply-ordering-groups") require(numResequencers >= 1, s"write-reply-ordering-groups must be >= 1, was [$numResequencers]")
0fails journal startup.
Should fix
ControlledInMemJournal.scala:96:asyncWriteMessagesreturnspromise.futureinstead ofsuperPromise.future. The write is acknowledged before the event is stored in memory, and a failure from the real write (for example serialization) is dropped.superPromiseis otherwise unused.JournalReplyOrderingSpec: test cases are not isolated. All cases share the same actors and journal, so one failing case leaves promises uncompleted and the following cases fail with unrelatedreceiveNtimeouts. In my mutation runs one real failure turned into four. Complete any outstanding promises in a cleanup step, or use fresh actors per case.JournalReplyOrderingSpec.scala:198-202: drop the 4 and 5 group variants. They account for 840 of the 872 test cases and exercise no behaviour beyond 1, 2 and 3 groups.- Coverage with more than one group. Every write in the spec carries exactly one event. Could you add a case with
persistAllordefer(counter advancing by more than 2) and one with a failed write, for a group count above 1? CI only runs the existing suites with one group. reference.conf:154-158: the comment does not say what the setting is. Suggestion, also wrapped like the neighbouring comments:Given the R2DBC numbers, should we also suggest a value, for example 8 or 16?# Number of groups used for ordering the write replies from the journal. # Replies within a group are delivered in the order the writes were # received, so one slow write delays the replies of all later writes in # the same group. A persistent actor is assigned to a group by the uid # of its ActorRef. # Increasing this reduces such head-of-line blocking when write latency # varies a lot, but it also removes a source of backpressure towards an # overloaded database. Must be at least 1.- Documentation. The setting is not mentioned in the paradox docs. Should it be, or is
reference.confenough for a tuning knob like this?
Nits
AsyncWriteJournal.scala:66-68:Vector.fill(numResequencers)(...)instead of(1 to n).iterator.map(_ => ...).toVector.AsyncWriteJournal.scala:73-74:Math.floorMod(actor.hashCode, numResequencers)instead of the sign-bit mask and%.AsyncWriteJournal.scala:79-83: capturehandle.resequencerin a localvalbefore the callback, so the closure holds no mutable object and the two-line safety comment is not needed.JournalReplyOrderingSpec.scala:78: the spec duplicates the routing formula. Aprivate[persistence]helper in theAsyncWriteJournalcompanion used by both would keep them in sync.- There is no upper bound on the setting. Each group is an actor created at journal startup, so a typo like
100000goes unnoticed. Worth a cap or a logged warning? JournalReplyOrderingSpec.scala:198-202:OneResequencerSpecetc. are generic names, and the setting is no longer called resequencers. Something likeJournalReplyOrderingOneGroupSpec.JournalReplyOrderingSpec.scala:110: the test name starts with "must" inside amustblock, reports read "must must properly order".JournalReplyOrderingSpec.scala:88:Random.nextIntmakes a failure non-reproducible. Use a fixed group, or log the chosen one.JournalReplyOrderingSpec.scala:152: the assertion "futures in other groups should not be completed" cannot fail. It runs immediately after completing the promise, and mostly checks asks whose own write has not completed yet.JournalReplyOrderingSpec.scala:145-189: the fold is hard to follow, with special cases for the group of two and for reversed completion. A model would be simpler: after each step an ask is expected to be complete if its write completed and no earlier write in its group is still open.JournalReplyOrderingSpec.scala:136-141:reversedCompletionisordering.indexOf(first) > ordering.indexOf(second).JournalReplyOrderingSpec.scala:69:journalInstanceIdis read back from config but is alwaysspecName.JournalReplyOrderingSpec.scala:34-57:persistentActorCounter,persistentActorPropsandoneSuccessUnitcan be private.ControlledInMemJournal.scala: the file name saysInMem, the class saysInmem.ControlledInMemJournal.scala:65-66:persistProbeandinstanceIdcan be private.ControlledInMemJournal.scala:81,95:selfinstead ofcontext.self, the second one is called from a future callback.- New files: scaladoc starts on the
/**line. The rest of the code base puts the text on the following line with*.
| # persistent actor is assigned to a group based on the UID of its | ||
| # incarnation. Increasing this reduces such head-of-line blocking when | ||
| # the write latency varies a lot, but it also removes a source of | ||
| # backpressure towards an overloaded database. Must be at least 1. |
There was a problem hiding this comment.
As far as recommending a value, it's going to vary based on the write rate and tail latency.
johanandren
left a comment
There was a problem hiding this comment.
Almost there. Some feedback still
Should fix
ControlledInmemJournal.scala:29-33: the scaladoc promises rejections that cannot happen. The contents of the completed sequence are discarded. Only the length is checked, then the result of the real in-memory write is used. AFailureelement never givesWriteMessageRejected. Either pass the results from the test through, or change the doc.LoadPluginSpec.scala:70-80: the actor system is not shut down reliably.terminate()is not awaited, and it is skipped when the assertion fails. UseTestKit.shutdownActorSystem(sys)in afinally. The test name also still says "resequencers".AsyncWriteJournal.scala:56: include the value in therequiremessage, for examplewrite-reply-ordering-groups must be >= 1, was [$numResequencers].JournalReplyOrderingSpec.scala:89-91:"cull"is never sent. Remove it.JournalReplyOrderingSpec.scala:415: the respawn message is consumed only at the end of the "first fails" cases. If such a case fails earlier, the message stays in the probe and the drain inafterEachmiscounts for the next case.- Open questions from the last round: should the setting be mentioned in the Paradox docs, and should there be an upper bound or a logged warning for very large values? Each group is an actor created at journal startup.
Simplifications in JournalReplyOrderingSpec
The spec is large compared to the change it covers. These would make it easier to maintain:
- Lines 317-416: the two test bodies are near copies. One helper that takes the pid to fail as an
Optionwould halve the file. - Lines 208-230: the
waitingfold overOption[Boolean]can bewhich also removesattempts.dropWhile(_.promise.isCompleted).drop(1).exists(_.promise.isCompleted)SomeTrueandSomeFalse. - Lines 204-207: a small case class instead of the
(String, Int, Promise[...])tuples. The_1and_3accesses hide what is compared. - Lines 232-259:
successandfailurediffer only in how the promise is completed. - Lines 98-108: the
var promisenull handling in thepersistAllhandler can bep.trySuccess(Done). - Lines 43-70: fresh actors per test case, created in a loop until the hash fits the wanted group, would remove the backoff supervisor, the self-stop in
preStart, the spawn probe and the retry inresolvePid.
Nits
reference.conf:155-160: double spaces after full stops. The rest of the file uses one.AsyncWriteJournal.scala:60-63: braces around a single expression in theelsebranch.AsyncWriteJournal.scala:344-346:ResequencerHandlecan beprivate,hashForResequencingcan beprivate[journal]. A helper that returns the group index for an actor and a group count would let the spec share the whole routing formula, not only the hash.ControlledInmemJournal.scala:85-87:persistProbe,instanceIdandpromisescan be private.ControlledInmemJournal.scala:115-119:tryFailureon a promise that has already completed does nothing.ControlledInmemJournal.scala:144:selfinstead ofcontext.self.ControlledInmemJournal.scala:46: the//comment above the scaladoc ofgetProbecan go.ControlledInmemJournal.scala:47,76: scaladoc text starts on the/**line. The rest of the code base puts it on the next line.JournalReplyOrderingSpec.scala:38,124:numResequencersshould follow the name of the setting, for examplegroups.JournalReplyOrderingSpec.scala:146: the log text says "two resequencers", it is two persistent actors.JournalReplyOrderingSpec.scala:57-66: thepreStartcomment can be one line on why the actor stops itself.JournalReplyOrderingSpec.scala:49-50:10.millinext to10.millis.JournalReplyOrderingSpec.scala:130: therequireduplicates the validation in the journal.JournalReplyOrderingSpec.scala:393:asks(pid).failed.futureValue.getMessage should include(...)is the usual form.JournalReplyOrderingSpec.scala:118-121,278-279:oneSuccessUnit,twoSuccessUnit,BackingOff,SomeTrueandSomeFalsecan be private.JournalReplyOrderingSpec.scala:421-422: the journal instance ids"one"and"two"are keys in a JVM-global registry. Something longer, like the spec name, avoids a collision with another spec.- Both new files: imports are not in project order.
I've added a configurable limit defaulting to 10k (which would be less than 10 MiB of heap in typical openjdk-derived JVMs). If one really needs more, they then have to override both the regular and limit configurations.
|
johanandren
left a comment
There was a problem hiding this comment.
LGTM, but let's wait for a second Ok from @patriknw before we merge.
patriknw
left a comment
There was a problem hiding this comment.
LGTM
One remark from 🤖 about something that I think was also discussed in previous reviews.
- [should-fix]
akka-persistence/src/main/scala/akka/persistence/journal/AsyncWriteJournal.scala:344:max-write-reply-ordering-groupsat 0 or below is raised to 1 by.max(1)instead of being refused.reference.conf:169says that it must be at least 1. With a limit of 0 and one group, the journal starts without an error. With a limit of 0 andwrite-reply-ordering-groups = 4, the journal fails withmust have no more than [1] write-reply-ordering-groups, was [4]. That message names neither the limit setting nor its configured value (line 348). Refuse a limit below 1 the same way as the group count, and namemax-write-reply-ordering-groupsin both messages. (by reading)
|
OK, removed that max. |
The JournalProtocol used by persistent actors (typed and classic) requires an ordering per-actor of responses to persists matching the order of those persists. Typed and classic persistent actors ensure that ordering holds by not issuing another
WriteMessageswhile aWriteMessagesis still outstanding. TheAsyncWriteJournalin turn ensures this order by using a stronger ordering at journal level, covering all persistent actors using a particular journal. This ordering is enforced by theResequenceractor.The
Resequencer's enforcement of a per-journal ordering can create a head-of-line-blocking followed by a thundering-herd if there are persists which take sufficiently abnormally long relative to more normal persists started immediately afterwards, especially if the amount by which the outliers deviate exceeeds the usual time between persists from different actors. For example, if most persists take 10ms and there are usually 100 distinct actors persisting events in a second (so persists come, on average, every 10ms), a persist taking 500ms would be expected to have ~50 actors whose persists succeeded but which won't report success until the 500ms persist completes (injecting an effective delay of ~250ms on average). The 50 responses would then be sent out within a few microseconds, with each response going to a different mailbox, with a likely surge in dispatcher utilization followed by (for the persistent actors send replies after persist across the cluster) a spike in messages being sent through remoting.This change introduces the ability to configure the number of resequencers to use. The UID of the persistent actor which sent
WriteMessagesis used to consistently route messages for that actor via the same resequencer. Actors assigned to the same resequencer may still cause head-of-line-blocking for each other, but the impact of outliers is limited to only that subset of actors: actors using other resequencers are unaffected.The default config is no change from prior behavior. To the extent that an occasional outlier is a symptom of an overloaded persistence backend, the resequencer introduces a small amount of backpressure on persists by delaying the next persist for an actor waiting for the head-of-line to unblock, so some caution would be advised in increasing this.