This is an automated email from the ASF dual-hosted git repository.
He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git
The following commit(s) were added to refs/heads/main by this push:
new a054f9cef4 fix: preserve StageActor demand across fused streams (#3461)
a054f9cef4 is described below
commit a054f9cef404c71d1c1770d9ae95f5039b5ac3ce
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Thu Aug 27 14:55:06 2026 +0800
fix: preserve StageActor demand across fused streams (#3461)
Motivation:
Batched StageActor callbacks can observe stale outlet demand before
interpreter events propagate across a fused graph. Fixed nested execute limits
are topology-dependent and bypass the shell processing budget.
Modification:
Share the shell event quota with lazy StageActor dispatch. Drain or park
callback-generated interpreter work before the next message, resume after the
queue becomes idle, preserve active-stage ownership, and stop queued dispatch
after completion or failure. Skip empty interpreter executions on callbacks
that enqueue no graph work. Add directional coverage for deep fusion, a
processing limit of one, completion, and failure.
Result:
Demand replenishment works for arbitrary fused topology depths without a
magic event limit, while interpreter fairness and failure semantics remain
effective. The direct StageActor benchmark finds no measurable throughput
regression against main.
Tests:
- FusingSpec and related StageActor/stream specs: 80/80 passed
- FusingSpec with fuzzing mode: 24/24 passed
- LifecycleInterpreterSpec: 13/13 passed
- stream MiMa: passed on Scala 2.13 and Scala 3.3.8
- scalafmt and git diff --check: passed
- StageActorRefBenchmark: main 26.286M +/- 6.002M, PR 27.988M +/- 3.124M
ops/s
References:
Fixes #3459
---
.../scala/org/apache/pekko/stream/FusingSpec.scala | 166 ++++++++++++++++++++-
.../stream/impl/fusing/ActorGraphInterpreter.scala | 84 ++++++++---
.../stream/impl/fusing/GraphInterpreter.scala | 48 ++++++
.../org/apache/pekko/stream/stage/GraphStage.scala | 114 ++++++++++----
4 files changed, 362 insertions(+), 50 deletions(-)
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/FusingSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/FusingSpec.scala
index 1610f1c3ff..e5ada5fedf 100644
--- a/stream-tests/src/test/scala/org/apache/pekko/stream/FusingSpec.scala
+++ b/stream-tests/src/test/scala/org/apache/pekko/stream/FusingSpec.scala
@@ -13,7 +13,7 @@
package org.apache.pekko.stream
-import scala.concurrent.{ duration, Await, Promise }
+import scala.concurrent.{ duration, Await, Future, Promise }
import duration._
@@ -23,7 +23,7 @@ import pekko.stream.QueueOfferResult
import pekko.stream.impl.UnfoldResourceSource
import pekko.stream.impl.fusing.GraphInterpreter
import pekko.stream.scaladsl._
-import pekko.stream.stage.GraphStage
+import pekko.stream.stage.{ GraphStage, GraphStageLogic,
GraphStageWithMaterializedValue, OutHandler }
import pekko.stream.testkit.TestPublisher
import pekko.stream.testkit.StreamSpec
import pekko.stream.testkit.Utils.TE
@@ -249,6 +249,168 @@ class FusingSpec extends StreamSpec {
.expectError(ex)
}
+ // Regression tests for https://github.com/apache/pekko/issues/3459
+ // Matches the UDP connector pattern: getStageActor receives messages from
an external
+ // actor, pushes only when isAvailable(out) is true, drops otherwise.
+ def externalActorSource(
+ dropped: java.util.concurrent.atomic.AtomicInteger,
+ ready: Promise[Done],
+ beforePush: GraphStageLogic => Unit = (_: GraphStageLogic) => ()):
Source[Int, Future[pekko.actor.ActorRef]] = {
+ val stageActorPromise = Promise[pekko.actor.ActorRef]()
+ Source.fromGraph(new GraphStageWithMaterializedValue[SourceShape[Int],
Future[pekko.actor.ActorRef]] {
+ val out = Outlet[Int]("external.out")
+ override val shape = SourceShape(out)
+ override def createLogicAndMaterializedValue(
+ inheritedAttributes: Attributes): (GraphStageLogic,
Future[pekko.actor.ActorRef]) = {
+ val logic = new GraphStageLogic(shape) {
+ override def preStart(): Unit =
+ stageActorPromise.success(getStageActor {
+ case (_, elem: Int) =>
+ beforePush(this)
+ if (isAvailable(out)) push(out, elem)
+ else dropped.incrementAndGet()
+ case _ => // ignore non-Int messages
+ }.ref)
+ setHandler(out,
+ new OutHandler {
+ // Let tests wait until initial demand reaches the source port
before sending a burst.
+ override def onPull(): Unit = ready.trySuccess(Done)
+ })
+ }
+ (logic, stageActorPromise.future)
+ }
+ })
+ }
+
+ def sendBurst(
+ stageActorFuture: Future[pekko.actor.ActorRef],
+ ready: Promise[Done],
+ downstream: pekko.stream.testkit.TestSubscriber.Probe[Int],
+ elementCount: Int): Unit = {
+ downstream.request(elementCount)
+ val stageActor = Await.result(stageActorFuture, 3.seconds)
+ Await.result(ready.future, 3.seconds)
+ (1 to elementCount).foreach(stageActor ! _)
+ within(5.seconds) {
+ downstream.expectNextN(elementCount.toLong)
+ }
+ downstream.cancel()
+ }
+
+ "replenish demand per-element for external async sources across an async
boundary" in {
+ val elementCount = 100
+ val dropped = new java.util.concurrent.atomic.AtomicInteger(0)
+ val ready = Promise[Done]()
+
+ val (stageActorFuture, downstream) = externalActorSource(dropped, ready)
+ .async
+ .addAttributes(asyncBoundaryInputBuffer)
+ .toMat(TestSink[Int]())(Keep.both)
+ .run()
+
+ sendBurst(stageActorFuture, ready, downstream, elementCount)
+ dropped.get() should ===(0)
+ }
+
+ "replenish demand independently of fused topology depth" in {
+ val elementCount = 8
+ val dropped = new java.util.concurrent.atomic.AtomicInteger(0)
+ val ready = Promise[Done]()
+ val deepSource = (1 to 256).foldLeft(externalActorSource(dropped,
ready)) {
+ case (source, _) => source.map(identity)
+ }
+
+ val (stageActorFuture, downstream) = deepSource
+ .async
+ .addAttributes(asyncBoundaryInputBuffer)
+ .toMat(TestSink[Int]())(Keep.both)
+ .run()
+
+ sendBurst(stageActorFuture, ready, downstream, elementCount)
+ dropped.get() should ===(0)
+ }
+
+ "replenish demand when the shell sync processing limit is one" in {
+ val elementCount = 8
+ val dropped = new java.util.concurrent.atomic.AtomicInteger(0)
+ val ready = Promise[Done]()
+ val deepSource = (1 to 32).foldLeft(externalActorSource(dropped, ready))
{
+ case (source, _) => source.map(identity)
+ }
+
+ val (stageActorFuture, downstream) = deepSource
+ .async
+ .addAttributes(ActorAttributes.syncProcessingLimit(1) and
asyncBoundaryInputBuffer)
+ .toMat(TestSink[Int]())(Keep.both)
+ .run()
+
+ sendBurst(stageActorFuture, ready, downstream, elementCount)
+ dropped.get() should ===(0)
+ }
+
+ "leave activeStage cleared when a stage actor callback completes its
stage" in {
+ val interpreterPromise = Promise[GraphInterpreter]()
+ val stageActorPromise = Promise[pekko.actor.ActorRef]()
+ val ready = Promise[Done]()
+
+ val source =
+ Source.fromGraph(new GraphStageWithMaterializedValue[SourceShape[Int],
Future[pekko.actor.ActorRef]] {
+ val out = Outlet[Int]("completion.out")
+ override val shape = SourceShape(out)
+ override def createLogicAndMaterializedValue(
+ inheritedAttributes: Attributes): (GraphStageLogic,
Future[pekko.actor.ActorRef]) = {
+ val logic = new GraphStageLogic(shape) {
+ override def preStart(): Unit = {
+ interpreterPromise.success(interpreter)
+ stageActorPromise.success(getStageActor { case _ =>
completeStage() }.ref)
+ }
+ setHandler(out,
+ new OutHandler {
+ override def onPull(): Unit = ready.trySuccess(Done)
+ })
+ }
+ (logic, stageActorPromise.future)
+ }
+ })
+
+ val (stageActorFuture, done) = source.toMat(Sink.ignore)(Keep.both).run()
+ Await.result(ready.future, 3.seconds)
+ Await.result(stageActorFuture, 3.seconds) ! "complete"
+ Await.result(done, 3.seconds) should ===(Done)
+ Await.result(interpreterPromise.future, 3.seconds).activeStage should
be(null)
+ }
+
+ "stop a lazy stage actor dispatch after its handler fails" in {
+ val failure = TE("stage actor handler failed")
+ val invocations = new java.util.concurrent.atomic.AtomicInteger(0)
+ val entered = new java.util.concurrent.CountDownLatch(1)
+ val release = new java.util.concurrent.CountDownLatch(1)
+ val ready = Promise[Done]()
+ val dropped = new java.util.concurrent.atomic.AtomicInteger(0)
+ val source = externalActorSource(
+ dropped,
+ ready,
+ _ => {
+ invocations.incrementAndGet()
+ entered.countDown()
+ release.await(3, java.util.concurrent.TimeUnit.SECONDS)
+ throw failure
+ })
+
+ val (stageActorFuture, done) = source.toMat(Sink.ignore)(Keep.both).run()
+ Await.result(ready.future, 3.seconds)
+ val stageActor = Await.result(stageActorFuture, 3.seconds)
+ stageActor ! 1
+ try {
+ entered.await(3, java.util.concurrent.TimeUnit.SECONDS) should be(true)
+ (2 to 100).foreach(stageActor ! _)
+ } finally release.countDown()
+
+ Await.result(done.failed, 3.seconds) should ===(failure)
+ invocations.get() should ===(1)
+ dropped.get() should ===(0)
+ }
+
"use multiple actors when there are asynchronous boundaries in the
subflows (manual)" in {
val async = Flow[Int].map(x => { testActor ! actorRunningStage; x
}).async
Source(0 to 9)
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
index 8110807300..b2435e792f 100644
---
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
@@ -629,12 +629,17 @@ import org.reactivestreams.Subscription
@InternalStableApi
override def execute(eventLimit: Int): Int = {
if (!shell.waitingForShutdown) {
- shell.interpreter.runAsyncInput(logic, evt, promise, handler)
- if (eventLimit == 1 && shell.interpreter.isSuspended) {
- shell.flushOutputs()
- shell.sendResume(true)
- 0
- } else shell.runBatch(eventLimit - 1)
+ handler match {
+ case eventLimitHandler: GraphInterpreter.EventLimitHandler =>
+ shell.runEventLimitHandler(logic, evt, promise, eventLimitHandler,
eventLimit)
+ case _ =>
+ shell.interpreter.runAsyncInput(logic, evt, promise, handler)
+ if (eventLimit == 1 && shell.interpreter.isSuspended) {
+ shell.flushOutputs()
+ shell.sendResume(true)
+ 0
+ } else shell.runBatch(eventLimit - 1)
+ }
} else {
eventLimit
}
@@ -719,6 +724,7 @@ import org.reactivestreams.Subscription
// TODO: Better heuristic here
private val abortLimit = shellEventLimit * 2
private var resumeScheduled = false
+ private var waitingEventLimitHandlers:
util.ArrayDeque[GraphInterpreter.EventLimitHandler] = _
def isInitialized: Boolean = self != null
def init(
@@ -798,22 +804,27 @@ import org.reactivestreams.Subscription
else enqueueToShortCircuit(resume)
}
- def runBatch(actorEventLimit: Int): Int = {
+ def runEventLimitHandler(
+ logic: GraphStageLogic,
+ evt: Any,
+ promise: Promise[Done],
+ handler: GraphInterpreter.EventLimitHandler,
+ actorEventLimit: Int): Int = {
try {
- val usingShellLimit = shellEventLimit < actorEventLimit
- val remainingQuota = interpreter.execute(Math.min(actorEventLimit,
shellEventLimit))
- flushOutputs()
- if (interpreter.isCompleted) {
- // Cannot stop right away if not completely subscribed
- if (canShutDown) interpreterCompleted = true
- else {
- waitingForShutdown = true
- val subscriptionTimeout =
attributes.mandatoryAttribute[ActorAttributes.StreamSubscriptionTimeout].timeout
- mat.scheduleOnce(subscriptionTimeout, () => self ! new
Abort(GraphInterpreterShell.this))
- }
- } else if (interpreter.isSuspended && !resumeScheduled)
sendResume(!usingShellLimit)
+ val actorLimitAfterAsyncInput = actorEventLimit - 1
+ val interpreterLimit = Math.min(actorLimitAfterAsyncInput,
shellEventLimit)
+ val handlerRemaining = interpreter.runAsyncInput(logic, evt, promise,
handler, interpreterLimit)
+ val remaining = interpreter.execute(handlerRemaining)
+
+ if (handler.isWaitingForInterpreter) {
+ if (interpreter.isSuspended) {
+ if (waitingEventLimitHandlers eq null)
+ waitingEventLimitHandlers = new
util.ArrayDeque[GraphInterpreter.EventLimitHandler]()
+ waitingEventLimitHandlers.addLast(handler)
+ } else handler.onInterpreterIdle()
+ }
- if (usingShellLimit) actorEventLimit - shellEventLimit + remainingQuota
else remainingQuota
+ finishBatch(actorLimitAfterAsyncInput - interpreterLimit + remaining)
} catch {
case NonFatal(e) =>
tryAbort(e)
@@ -821,6 +832,39 @@ import org.reactivestreams.Subscription
}
}
+ def runBatch(actorEventLimit: Int): Int = {
+ try {
+ val interpreterLimit = Math.min(actorEventLimit, shellEventLimit)
+ val remaining = interpreter.execute(interpreterLimit)
+ finishBatch(actorEventLimit - interpreterLimit + remaining)
+ } catch {
+ case NonFatal(e) =>
+ tryAbort(e)
+ actorEventLimit - 1
+ }
+ }
+
+ private def finishBatch(remainingQuota: Int): Int = {
+ flushOutputs()
+ if (interpreter.isCompleted) {
+ // Cannot stop right away if not completely subscribed
+ if (canShutDown) interpreterCompleted = true
+ else {
+ waitingForShutdown = true
+ val subscriptionTimeout =
attributes.mandatoryAttribute[ActorAttributes.StreamSubscriptionTimeout].timeout
+ mat.scheduleOnce(subscriptionTimeout, () => self ! new
Abort(GraphInterpreterShell.this))
+ }
+ } else if (interpreter.isSuspended && !resumeScheduled)
sendResume(remainingQuota == 0)
+
+ if (!interpreter.isSuspended && (waitingEventLimitHandlers ne null)) {
+ val handlers = waitingEventLimitHandlers
+ waitingEventLimitHandlers = null
+ while (!handlers.isEmpty) handlers.removeFirst().onInterpreterIdle()
+ }
+
+ remainingQuota
+ }
+
private def flushOutputs(): Unit = {
val outputBoundaries = outputs
val count = outputBoundaries.length
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
index 6d79526528..e91c25c508 100644
---
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
@@ -60,6 +60,19 @@ import pekko.stream.stage._
final val KeepGoingFlag = 0x4000000
final val KeepGoingMask = 0x3FFFFFF
+ /**
+ * INTERNAL API
+ *
+ * An async input handler that shares the interpreter event budget of the
enclosing shell. The handler returns the
+ * unused part of `eventLimit`. If it has to stop while interpreter events
are still pending, the shell invokes
+ * `onInterpreterIdle` after those events have been processed.
+ */
+ @InternalApi private[stream] trait EventLimitHandler extends (Any => Unit) {
+ def apply(event: Any, eventLimit: Int): Int
+ def isWaitingForInterpreter: Boolean
+ def onInterpreterIdle(): Unit
+ }
+
/**
* Marker object that indicates that a port holds no element since it was
already grabbed. The port is still pullable,
* but there is no more element to grab.
@@ -593,6 +606,41 @@ import pekko.stream.stage._
} finally currentInterpreterHolder(0) = previousInterpreter
}
+ /** INTERNAL API */
+ @InternalApi private[stream] def runAsyncInput(
+ logic: GraphStageLogic,
+ evt: Any,
+ promise: Promise[Done],
+ handler: EventLimitHandler,
+ eventLimit: Int): Int = {
+ var eventsRemaining = eventLimit
+ if (!isStageCompleted(logic)) {
+ if (GraphInterpreter.Debug) println(s"$Name ASYNC $evt ($handler)
[$logic]")
+ val currentInterpreterHolder = _currentInterpreter.get()
+ val previousInterpreter = currentInterpreterHolder(0)
+ currentInterpreterHolder(0) = this
+ try {
+ activeStage = logic
+ try {
+ eventsRemaining = handler(evt, eventLimit)
+ if (promise ne GraphStageLogic.NoPromise) {
+ promise.success(Done)
+ logic.onFeedbackDispatched(promise)
+ }
+ } catch {
+ case NonFatal(ex) =>
+ if (promise ne GraphStageLogic.NoPromise) {
+ promise.failure(ex)
+ logic.onFeedbackDispatched(promise)
+ }
+ logic.failStage(ex)
+ }
+ afterStageHasRun(logic)
+ } finally currentInterpreterHolder(0) = previousInterpreter
+ }
+ eventsRemaining
+ }
+
// Decodes and processes a single event for the given connection
@InternalStableApi
private def processEvent(connection: Connection): Unit = {
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
b/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
index bae071f83a..8f2764cc1f 100644
--- a/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
@@ -344,6 +344,7 @@ object GraphStageLogic {
private final val SchedStateIdle: Int = 0
private final val SchedStateScheduled: Int = 1
+ private final val SchedStateStopped: Int = 2
/**
* Lazy-path dispatch: producers enqueue into a Vyukov MPSC queue and
elect a single drain via
@@ -375,7 +376,7 @@ object GraphStageLogic {
handler: Any => Unit,
drainBatchSize: Int)
extends AbstractNodeQueue[(ActorRef, Any)]
- with (Any => Unit) {
+ with GraphInterpreter.EventLimitHandler {
// IDLE/SCHEDULED election state. VarHandle avoids per-instance
AtomicInteger.
@volatile var state: Int = SchedStateIdle
@@ -388,12 +389,13 @@ object GraphStageLogic {
private def casState(expect: Int, update: Int): Boolean =
LazyDispatch.stateHandle.compareAndSet(this, expect, update)
+ private var waitingForInterpreter = false
+
// null msg = drain signal from onAsyncInput; non-null = (ActorRef, Any)
tuple from FunctionRef.
override def apply(msg: Any): Unit =
- if (msg == null) drain()
- else {
+ if (msg != null) {
val pair = msg.asInstanceOf[(ActorRef, Any)]
- if (!interpreter.isStageCompleted(logic)) {
+ if (!interpreter.isStageCompleted(logic) && getState() !=
SchedStateStopped) {
add(pair)
if (getState() == SchedStateIdle && casState(SchedStateIdle,
SchedStateScheduled)) {
if (interpreter.isStageCompleted(logic)) setState(SchedStateIdle)
@@ -404,36 +406,92 @@ object GraphStageLogic {
private def scheduleDrain(): Unit =
// 1 AsyncInput + 1 Envelope per drain batch (amortized across up to
drainBatchSize tells).
- // `this` serves as the drain callback (Any => Unit); onAsyncInput
calls apply(null).
+ // `this` serves as both the producer callback and the
event-limit-aware drain callback.
interpreter.onAsyncInput(logic, null, NoPromise, this)
- private def drain(): Unit = {
- val limit = drainBatchSize
- var processed = 0
- while (processed < limit) {
- if (interpreter.isStageCompleted(logic)) {
- while (poll() ne null) ()
- setState(SchedStateIdle)
- return
+ override def apply(event: Any, eventLimit: Int): Int = {
+ require(event == null, "LazyDispatch drain event must be null")
+ drain(eventLimit)
+ }
+
+ override def isWaitingForInterpreter: Boolean = waitingForInterpreter
+
+ override def onInterpreterIdle(): Unit = {
+ waitingForInterpreter = false
+ if (interpreter.isStageCompleted(logic)) stopDispatch()
+ else publishIdleOrSchedule()
+ }
+
+ private def drain(eventLimit: Int): Int = {
+ var eventsRemaining = eventLimit
+ waitingForInterpreter = false
+ try {
+ // BoundaryEvents can overtake a shell resume when the
shell-specific limit is reached. Finish any
+ // previously queued interpreter work before dispatching another
StageActor message.
+ if (interpreter.isSuspended) {
+ eventsRemaining = interpreter.execute(eventsRemaining)
+ if (interpreter.isSuspended) {
+ waitingForInterpreter = true
+ return eventsRemaining
+ }
}
- val item = poll()
- if (item eq null) {
- setState(SchedStateIdle)
- // Recheck race: a producer may have added between `poll == null`
and the IDLE publish above.
- if (!isEmpty && casState(SchedStateIdle, SchedStateScheduled))
- scheduleDrain()
- return
+
+ val limit = drainBatchSize
+ var processed = 0
+ // The AsyncInput itself accounts for the first callback. Even when
it consumed the last actor quota,
+ // dispatch one message and defer any interpreter work it creates to
the next shell resume.
+ while (processed < limit && (eventsRemaining > 0 || processed == 0))
{
+ if (interpreter.isStageCompleted(logic)) {
+ stopDispatch()
+ return eventsRemaining
+ }
+ val item = poll()
+ if (item eq null) {
+ publishIdleOrSchedule()
+ return eventsRemaining
+ }
+
+ // execute() changes activeStage while propagating the element.
Set it explicitly for every
+ // StageActor callback; never restore a logic that execute() may
have finalized and released.
+ interpreter.activeStage = logic
+ handler(item)
+ // Most StageActor callbacks only update local stage state. Avoid
entering execute() unless the
+ // callback actually enqueued interpreter work; demand-producing
callbacks still drain that work
+ // before another message is dispatched.
+ if (interpreter.isSuspended) eventsRemaining =
interpreter.execute(eventsRemaining)
+ processed += 1
+
+ if (interpreter.isStageCompleted(logic)) {
+ stopDispatch()
+ return eventsRemaining
+ }
+ if (interpreter.isSuspended) {
+ waitingForInterpreter = true
+ return eventsRemaining
+ }
}
- handler(item)
- processed += 1
+
+ publishIdleOrSchedule()
+ eventsRemaining
+ } catch {
+ case ex: Throwable =>
+ stopDispatch()
+ throw ex
}
- // The last handler(item) may have completed the stage; check before
re-scheduling.
- if (interpreter.isStageCompleted(logic)) {
- while (poll() ne null) ()
+ }
+
+ private def publishIdleOrSchedule(): Unit = {
+ if (isEmpty) {
setState(SchedStateIdle)
- return
- }
- scheduleDrain()
+ // Recheck race: a producer may have added between observing empty
and publishing IDLE above.
+ if (!isEmpty && casState(SchedStateIdle, SchedStateScheduled))
scheduleDrain()
+ } else scheduleDrain()
+ }
+
+ private def stopDispatch(): Unit = {
+ waitingForInterpreter = false
+ setState(SchedStateStopped)
+ while (poll() ne null) ()
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]