WIP event handling
authorChristos KK Loverdos <loverdos@gmail.com>
Thu, 31 May 2012 15:18:09 +0000 (18:18 +0300)
committerChristos KK Loverdos <loverdos@gmail.com>
Thu, 31 May 2012 15:18:09 +0000 (18:18 +0300)
12 files changed:
src/main/scala/gr/grnet/aquarium/Main.scala
src/main/scala/gr/grnet/aquarium/actor/message/GetUserStateResponse.scala
src/main/scala/gr/grnet/aquarium/actor/service/rest/RESTActor.scala
src/main/scala/gr/grnet/aquarium/actor/service/user/UserActor.scala
src/main/scala/gr/grnet/aquarium/computation/UserState.scala
src/main/scala/gr/grnet/aquarium/computation/data/IMStateSnapshot.scala
src/main/scala/gr/grnet/aquarium/computation/data/RoleHistory.scala
src/main/scala/gr/grnet/aquarium/computation/reason/UserStateChangeReason.scala
src/main/scala/gr/grnet/aquarium/computation/reason/UserStateChangeReasonCodes.scala
src/main/scala/gr/grnet/aquarium/store/mongodb/MongoDBStore.scala
src/main/scala/gr/grnet/aquarium/util/LazyLoggable.scala
src/main/scala/gr/grnet/aquarium/util/Loggable.scala

index 4746eda..2cc7844 100644 (file)
@@ -66,15 +66,18 @@ object Main extends LazyLoggable {
   def main(args: Array[String]) = {
     configureLogging()
 
+    logSeparator()
     logStarting("Aquarium")
     val ms0 = TimeHelpers.nowMillis()
     try {
       doStart()
       val ms1 = TimeHelpers.nowMillis()
       logStarted(ms0, ms1, "Aquarium")
+      logSeparator()
     } catch {
       case e: Throwable ⇒
       logger.error("Aquarium not started\n%s".format(gr.grnet.aquarium.util.chainOfCausesForLogging(e, 1)), e)
+      logSeparator()
       System.exit(1)
     }
   }
index b7158fd..1d4904c 100644 (file)
@@ -43,6 +43,7 @@ import gr.grnet.aquarium.computation.UserState
  */
 
 case class GetUserStateResponse(
-    userID: String,
-    state: Either[String, UserState])
-extends RouterResponseMessage(state)
+    state: Either[String, UserState],
+    override val suggestedHTTPStatus: Int = 200)
+extends RouterResponseMessage(state, suggestedHTTPStatus)
+
index e26f4a3..69cfdf6 100644 (file)
@@ -60,6 +60,8 @@ class RESTActor private(_id: String) extends RoleableActor with Loggable {
 
   self.id = _id
 
+  private[this] def aquarium = Aquarium.Instance
+
   private def stringResponse(status: Int, stringBody: String, contentType: String = "application/json"): HttpResponse = {
     HttpResponse(
       status,
@@ -101,14 +103,16 @@ class RESTActor private(_id: String) extends RoleableActor with Loggable {
       val millis = TimeHelpers.nowMillis()
       uri match {
         case UserBalancePath(userID) ⇒
+          // /user/(.+)/balance/?
           callRouter(GetUserBalanceRequest(userID, millis), responder)
 
         case UserStatePath(userId) ⇒
+          // /user/(.+)/state/?
           callRouter(GetUserStateRequest(userId, millis), responder)
 
         case AdminPingAll() ⇒
-          val mc = Aquarium.Instance
-          mc.adminCookie match {
+          // /admin/ping/all/?
+          aquarium.adminCookie match {
             case Just(adminCookie) ⇒
               headers.find(_.name.toLowerCase == Aquarium.HTTP.RESTAdminHeaderNameLowerCase) match {
                 case Some(cookieHeader) if(cookieHeader.value == adminCookie) ⇒
index 23a9413..a79a64f 100644 (file)
@@ -40,17 +40,16 @@ package user
 import gr.grnet.aquarium.actor._
 
 import akka.config.Supervision.Temporary
-import gr.grnet.aquarium.Aquarium
-import gr.grnet.aquarium.util.{shortClassNameOf, shortNameOfClass}
+import gr.grnet.aquarium.util.{shortClassNameOf, shortNameOfClass, shortNameOfType}
 import gr.grnet.aquarium.actor.message.event.{ProcessResourceEvent, ProcessIMEvent}
 import gr.grnet.aquarium.computation.data.IMStateSnapshot
-import gr.grnet.aquarium.event.model.im.IMEventModel
 import gr.grnet.aquarium.actor.message.config.{InitializeUserState, ActorProviderConfigured, AquariumPropertiesLoaded}
-import gr.grnet.aquarium.actor.message.{GetUserBalanceResponseData, GetUserBalanceResponse, GetUserStateRequest, GetUserBalanceRequest}
 import gr.grnet.aquarium.computation.{BillingMonthInfo, UserStateBootstrappingData, UserState}
 import gr.grnet.aquarium.util.date.TimeHelpers
-import gr.grnet.aquarium.logic.accounting.Policy
-import gr.grnet.aquarium.computation.reason.InitialUserStateSetup
+import gr.grnet.aquarium.event.model.im.IMEventModel
+import gr.grnet.aquarium.{AquariumException, Aquarium}
+import gr.grnet.aquarium.actor.message.{GetUserStateResponse, GetUserBalanceResponseData, GetUserBalanceResponse, GetUserStateRequest, GetUserBalanceRequest}
+import gr.grnet.aquarium.computation.reason.{InitialUserActorSetup, UserStateChangeReason, IMEventArrival, InitialUserStateSetup}
 
 /**
  *
@@ -65,7 +64,7 @@ class UserActor extends ReflectiveRoleableActor {
   self.lifeCycle = Temporary
 
   private[this] def _shutmedown(): Unit = {
-    if(_haveUserState) {
+    if(haveUserState) {
       UserActorCache.invalidate(_userID)
     }
 
@@ -88,11 +87,11 @@ class UserActor extends ReflectiveRoleableActor {
     aquarium.props.getLong(Aquarium.Keys.user_state_timestamp_threshold).getOr(10000)
   }
 
-  private[this] def _haveUserState = {
+  private[this] def haveUserState = {
     this._userState ne null
   }
 
-  private[this] def _haveIMState = {
+  private[this] def haveIMState = {
     this._imState ne null
   }
 
@@ -102,40 +101,64 @@ class UserActor extends ReflectiveRoleableActor {
   def onActorProviderConfigured(event: ActorProviderConfigured): Unit = {
   }
 
-  private[this] def createIMState(event: InitializeUserState): Unit = {
+  private[this] def _updateIMStateRoleHistory(imEvent: IMEventModel): (Boolean, Boolean, String) = {
+    if(haveIMState) {
+      val currentRole = this._imState.roleHistory.lastRole.map(_.name).getOrElse(null)
+//      logger.debug("Current role = %s".format(currentRole))
+
+      if(imEvent.role != currentRole) {
+//        logger.debug("New role = %s".format(imEvent.role))
+        this._imState = this._imState.updateRoleHistoryWithEvent(imEvent)
+        (true, false, "")
+      } else {
+        val noUpdateReason = "Same role '%s'".format(currentRole)
+//        logger.debug(noUpdateReason)
+        (false, false, noUpdateReason)
+      }
+    } else {
+      this._imState = IMStateSnapshot.initial(imEvent)
+      (true, true, "")
+    }
+  }
+
+  /**
+   * Creates the IMStateSnapshot and returns the number of updates it made to it.
+   */
+  private[this] def createIMState(event: InitializeUserState): Int = {
     val userID = event.userID
     val store = aquarium.imEventStore
-    // TODO: Optimization: Since IMState only records roles, we should incrementally
-    // TODO:               built it only for those IMEvents that changed the role.
-    store.replayIMEventsInOccurrenceOrder(userID) { imEvent ⇒
-      logger.debug("Replaying %s".format(imEvent))
 
-      val newState = this._imState match {
-        case null ⇒
-          IMStateSnapshot.initial(imEvent)
+    var _updateCount = 0
 
-        case currentState ⇒
-          currentState.updateHistoryWithEvent(imEvent)
+    store.replayIMEventsInOccurrenceOrder(userID) { imEvent ⇒
+      DEBUG("Replaying %s", imEvent)
+
+      val (updated, firstUpdate, noUpdateReason) = _updateIMStateRoleHistory(imEvent)
+      if(updated) {
+        _updateCount = _updateCount + 1
+        DEBUG("Updated %s for role '%s'", shortNameOfType[IMStateSnapshot], imEvent.role)
+      } else {
+        DEBUG("Not updated %s due to: %s", shortNameOfType[IMStateSnapshot], noUpdateReason)
       }
-
-      this._imState = newState
     }
 
-    DEBUG("Recomputed %s = %s", shortNameOfClass(classOf[IMStateSnapshot]), this._imState)
+    if(_updateCount > 0)
+      DEBUG("Computed %s = %s", shortNameOfType[IMStateSnapshot], this._imState)
+    else
+      DEBUG("Not computed %s", shortNameOfType[IMStateSnapshot])
+
+    _updateCount
   }
 
   /**
    * Resource events are processed only if the user has been activated.
    */
   private[this] def shouldProcessResourceEvents: Boolean = {
-    _haveIMState && this._imState.hasBeenActivated
+    haveIMState && this._imState.hasBeenActivated
   }
 
   private[this] def createUserState(event: InitializeUserState): Unit = {
-    val userID = event.userID
-    val referenceTime = event.referenceTimeMillis
-
-    if(!_haveIMState) {
+    if(!haveIMState) {
       // Should have been created from `createIMState()`
       DEBUG("Cannot create user state from %s, since %s = %s", event, shortNameOfClass(classOf[IMStateSnapshot]), this._imState)
       return
@@ -143,7 +166,7 @@ class UserActor extends ReflectiveRoleableActor {
 
     if(!this._imState.hasBeenActivated) {
       // Cannot set the initial state!
-      DEBUG("Cannot create user state from %s, since user is inactive", event)
+      DEBUG("Cannot create %s from %s, since user is inactive", shortNameOfType[UserState], event)
       return
     }
 
@@ -158,13 +181,27 @@ class UserActor extends ReflectiveRoleableActor {
       aquarium.initialBalanceForRole(initialRole, userActivationMillis)
     )
 
-    userStateComputations.doFullMonthlyBilling(
+    val userState = userStateComputations.doFullMonthlyBilling(
       userStateBootstrap,
       BillingMonthInfo.fromMillis(TimeHelpers.nowMillis()),
       aquarium.currentResourcesMap,
       InitialUserStateSetup,
       None
     )
+
+    this._userState = userState
+
+    // Final touch: Update role history
+    if(haveIMState && haveUserState) {
+      // FIXME: Not satisfied with this redundant info
+      if(this._userState.roleHistory != this._imState.roleHistory) {
+        this._userState = newUserStateWithUpdatedRoleHistory(InitialUserActorSetup)
+      }
+    }
+
+    if(haveUserState) {
+      DEBUG("%s = %s", shortNameOfType[UserState], this._userState)
+    }
   }
 
   def onInitializeUserState(event: InitializeUserState): Unit = {
@@ -177,6 +214,20 @@ class UserActor extends ReflectiveRoleableActor {
   }
 
   /**
+   * Creates a new user state, taking into account the latest role history in IM state snapshot.
+   * Having an IM state snapshot is a prerequisite, otherwise you get an exception; so check before you
+   * call this.
+   */
+  private[this] def newUserStateWithUpdatedRoleHistory(stateChangeReason: UserStateChangeReason): UserState = {
+    this._userState.copy(
+      roleHistory = this._imState.roleHistory,
+      // FIXME: Also update agreement
+      stateChangeCounter = this._userState.stateChangeCounter + 1,
+      lastChangeReason = stateChangeReason
+    )
+  }
+
+  /**
    * Process [[gr.grnet.aquarium.event.model.im.IMEventModel]]s.
    * When this method is called, we assume that all proper checks have been made and it
    * is OK to proceed with the event processing.
@@ -184,22 +235,41 @@ class UserActor extends ReflectiveRoleableActor {
   def onProcessIMEvent(processEvent: ProcessIMEvent): Unit = {
     val imEvent = processEvent.imEvent
 
-    if(!_haveIMState) {
+    if(!haveIMState) {
       // This is an error. Should have been initialized from somewhere ...
-      throw new Exception("Got %s while being uninitialized".format(processEvent))
+      throw new AquariumException("Got %s while being uninitialized".format(processEvent))
     }
 
     if(this._imState.latestIMEvent.id == imEvent.id) {
       // This happens when the actor is brought to life, then immediately initialized, and then
       // sent the first IM event. But from the initialization procedure, this IM event will have
       // already been loaded from DB!
-      INFO("Ignoring first %s after birth", imEvent.toDebugString)
+      INFO("Ignoring first %s just after %s birth", imEvent.toDebugString, shortClassNameOf(this))
+      logSeparator()
       return
     }
 
-    this._imState = this._imState.updateHistoryWithEvent(imEvent)
+    val (updated, firstUpdate, noUpdateReason) = _updateIMStateRoleHistory(imEvent)
+
+    if(updated) {
+      DEBUG("Updated %s = %s", shortClassNameOf(this._imState), this._imState)
 
-    INFO("Update %s = %s", shortClassNameOf(this._imState), this._imState)
+      // Must also update user state
+      if(shouldProcessResourceEvents) {
+        if(haveUserState) {
+          DEBUG("Also updating %s with new %s",
+            shortClassNameOf(this._userState),
+            shortClassNameOf(this._imState.roleHistory)
+          )
+
+          this._userState = newUserStateWithUpdatedRoleHistory(IMEventArrival(imEvent))
+        }
+      }
+    } else {
+      DEBUG("Not updating %s from %s due to: %s", shortNameOfType[IMStateSnapshot], imEvent, noUpdateReason)
+    }
+
+    logSeparator()
   }
 
   def onProcessResourceEvent(event: ProcessResourceEvent): Unit = {
@@ -208,6 +278,7 @@ class UserActor extends ReflectiveRoleableActor {
     if(!shouldProcessResourceEvents) {
       // This means the user has not been activated. So, we do not process any resource event
       DEBUG("Not processing %s", rcEvent.toJsonString)
+      logSeparator()
       return
     }
   }
@@ -216,26 +287,52 @@ class UserActor extends ReflectiveRoleableActor {
   def onGetUserBalanceRequest(event: GetUserBalanceRequest): Unit = {
     val userID = event.userID
 
-    if(!_haveIMState) {
-      // No IMEvent has arrived, so this user is virtually unknown
-      self reply GetUserBalanceResponse(Left("User not found"), 404/*Not found*/)
-    }
-    else if(!_haveUserState) {
-      // The user is known but we have no state.
-      // Ridiculous. Should have been created at least during initialization.
-    }
-
-    if(!_haveUserState) {
-      self reply GetUserBalanceResponse(Left("Not found"), 404/*Not found*/)
-    } else {
-      self reply GetUserBalanceResponse(Right(GetUserBalanceResponseData(userID, this._userState.totalCredits)))
+    (haveIMState, haveUserState) match {
+      case (true, true) ⇒
+        // (have IMState, have UserState)
+        this._imState.hasBeenActivated match {
+          case true ⇒
+            // (have IMState, activated, have UserState)
+            self reply GetUserBalanceResponse(Right(GetUserBalanceResponseData(userID, this._userState.totalCredits)))
+
+          case false ⇒
+            // (have IMState, not activated, have UserState)
+            // Since we have user state, we should have been activated
+            self reply GetUserBalanceResponse(Left("Internal Server Error [AQU-BAL-0001]"), 500)
+        }
+
+      case (true, false) ⇒
+        // (have IMState, no UserState)
+        this._imState.hasBeenActivated match {
+          case true  ⇒
+            // (have IMState, activated, no UserState)
+            // Since we are activated, we should have some state.
+            self reply GetUserBalanceResponse(Left("Internal Server Error [AQU-BAL-0002]"), 500)
+          case false ⇒
+            // (have IMState, not activated, no UserState)
+            // The user is virtually unknown
+            self reply GetUserBalanceResponse(Left("User %s not activated [AQU-BAL-0003]".format(userID)), 404 /*Not found*/)
+        }
+
+      case (false, true) ⇒
+        // (no IMState, have UserState
+        // A bit ridiculous situation
+        self reply GetUserBalanceResponse(Left("Unknown user %s [AQU-BAL-0004]".format(userID)), 404/*Not found*/)
+
+      case (false, false) ⇒
+        // (no IMState, no UserState)
+        self reply GetUserBalanceResponse(Left("Unknown user %s [AQU-BAL-0005]".format(userID)), 404/*Not found*/)
     }
   }
 
   def onGetUserStateRequest(event: GetUserStateRequest): Unit = {
-    val userId = event.userID
-   // FIXME: Implement
-//    self reply GetUserStateResponse(userId, Right(this._userState))
+    haveUserState match {
+      case true ⇒
+        self reply GetUserStateResponse(Right(this._userState))
+
+      case false ⇒
+        self reply GetUserStateResponse(Left("No state for user %s [AQU-STA-0006]".format(event.userID)))
+    }
   }
 
   private[this] def D_userID = {
@@ -243,17 +340,17 @@ class UserActor extends ReflectiveRoleableActor {
   }
 
   private[this] def DEBUG(fmt: String, args: Any*) =
-    logger.debug("User[%s]: %s".format(D_userID, fmt.format(args: _*)))
+    logger.debug("[%s] - %s".format(D_userID, fmt.format(args: _*)))
 
   private[this] def INFO(fmt: String, args: Any*) =
-    logger.info("User[%s]: %s".format(D_userID, fmt.format(args: _*)))
+    logger.info("[%s] - %s".format(D_userID, fmt.format(args: _*)))
 
   private[this] def WARN(fmt: String, args: Any*) =
-    logger.warn("User[%s]: %s".format(D_userID, fmt.format(args: _*)))
+    logger.warn("[%s] - %s".format(D_userID, fmt.format(args: _*)))
 
   private[this] def ERROR(fmt: String, args: Any*) =
-    logger.error("User[%s]: %s".format(D_userID, fmt.format(args: _*)))
+    logger.error("[%s] - %s".format(D_userID, fmt.format(args: _*)))
 
   private[this] def ERROR(t: Throwable, fmt: String, args: Any*) =
-    logger.error("User[%s]: %s".format(D_userID, fmt.format(args: _*)), t)
+    logger.error("[%s] - %s".format(D_userID, fmt.format(args: _*)), t)
 }
index e1c4cbb..8b8c815 100644 (file)
@@ -148,7 +148,7 @@ case class UserState(
     // The user state we used to compute this one. Normally the (cached)
     // state at the beginning of the billing period.
     parentUserStateId: Option[String] = None,
-    _id: String = new ObjectId().toString
+    _id: String = null
 ) extends JsonSupport {
 
   def idOpt: Option[String] = _id match {
@@ -185,9 +185,19 @@ case class UserState(
   }
 
   def copyForChangeReason(changeReason: UserStateChangeReason) = {
-    this.copy(lastChangeReason = changeReason)
+    this.copy(
+      lastChangeReason = changeReason,
+      stateChangeCounter = this.stateChangeCounter + 1
+    )
   }
 
+//  def copyForRoleHistory(newRoleHistory: RoleHistory) = {
+//    this.copy(
+//      roleHistory = newRoleHistory,
+//      stateChangeCounter = this.stateChangeCounter + 1
+//    )
+//  }
+
   def resourcesMap = ownedResourcesSnapshot.toResourcesMap
 
 //  def modifyFromIMEvent(imEvent: IMEventModel, snapshotMillis: Long): UserState = {
index fbec707..0ac8119 100644 (file)
@@ -67,7 +67,7 @@ case class IMStateSnapshot(
     userActivationMillis.isDefined
   }
 
-  def updateHistoryWithEvent(imEvent: IMEventModel) = {
+  def updateRoleHistoryWithEvent(imEvent: IMEventModel) = {
     copy(
       userActivationMillis = if(imEvent.isStateActive) Some(imEvent.occurredMillis) else this.userActivationMillis,
       latestIMEvent = imEvent,
index 760d523..59b2be9 100644 (file)
@@ -46,9 +46,9 @@ import scala.annotation.tailrec
  */
 
 case class RoleHistory(
-                        /**
-                         * The head role is the most recent. The same rule applies for the tail.
-                         */
+                       /**
+                        * The head role is the most recent. The same rule applies for the tail.
+                        */
                        roles: List[RoleHistoryItem]) {
 
   def roleNamesByTimeslot: SortedMap[Timeslot, String] = {
@@ -60,6 +60,9 @@ case class RoleHistory(
   }
 
   def updateWithRole(role: String, validFrom: Long) = {
+    // TODO: Review this when Timeslot is also reviewed.
+    //       Currently, we need `fixValidTo` because Timeslot does not validate when `validFrom` and `validTo`
+    //       are equal.
     def fixValidTo(validFrom: Long, validTo: Long): Long = {
       if(validTo == validFrom) {
         // Since validTo is exclusive, make at least 1ms gap
@@ -114,28 +117,28 @@ case class RoleHistory(
    * Returns the first, chronologically, role.
    */
   def firstRole: Option[RoleHistoryItem] = {
-    rolesByTimeslot.valuesIterator.toList.lastOption
+    rolesByTimeslot.valuesIterator.toList.headOption
   }
 
   /**
    * Returns the name of the first, chronologically, role.
    */
   def firstRoleName: Option[String] = {
-    roleNamesByTimeslot.valuesIterator.toList.lastOption
+    roleNamesByTimeslot.valuesIterator.toList.headOption
   }
 
   /**
    * Returns the last, chronologically, role.
    */
   def lastRole: Option[RoleHistoryItem] = {
-    rolesByTimeslot.valuesIterator.toList.headOption
+    rolesByTimeslot.valuesIterator.toList.lastOption
   }
 
   /**
    * Returns the name of the last, chronologically, role.
    */
   def lastRoleName: Option[String] = {
-    roleNamesByTimeslot.valuesIterator.toList.headOption
+    roleNamesByTimeslot.valuesIterator.toList.lastOption
   }
 }
 
index 52a8fa3..e457199 100644 (file)
@@ -55,6 +55,9 @@ sealed trait UserStateChangeReason {
   def code: UserStateChangeReasonCodes.ChangeReasonCode
 }
 
+/**
+ * When the user state is initially set up.
+ */
 case object InitialUserStateSetup extends UserStateChangeReason {
   def shouldStoreUserState = true
 
@@ -66,6 +69,21 @@ case object InitialUserStateSetup extends UserStateChangeReason {
 
   def code = UserStateChangeReasonCodes.InitialSetupCode
 }
+
+/**
+ * When the user processing unit (actor) is initially set up.
+ */
+case object InitialUserActorSetup extends UserStateChangeReason {
+  def shouldStoreUserState = true
+
+  def shouldStoreCalculatedWalletEntries = false
+
+  def forPreviousBillingMonth = this
+
+  def calculateCreditsForImplicitlyTerminated = false
+
+  def code = UserStateChangeReasonCodes.InitialUserActorSetup
+}
 /**
  * A calculation made for no specific reason. Can be for testing, for example.
  *
index 9d0e3cc..039ebd8 100644 (file)
@@ -36,6 +36,7 @@
 package gr.grnet.aquarium.computation.reason
 
 /**
+ * Codes that characterize reasons for user state change.
  *
  * @author Christos KK Loverdos <loverdos@gmail.com>
  */
@@ -43,9 +44,10 @@ package gr.grnet.aquarium.computation.reason
 object UserStateChangeReasonCodes {
   type ChangeReasonCode = Int
 
-  final val InitialSetupCode = 1
-  final val NoSpecificChangeCode = 2
-  final val MonthlyBillingCode = 3
-  final val RealtimeBillingCode = 4
-  final val IMEventArrivalCode = 5
+  final val InitialSetupCode      = 1
+  final val NoSpecificChangeCode  = 2
+  final val MonthlyBillingCode    = 3
+  final val RealtimeBillingCode   = 4
+  final val IMEventArrivalCode    = 5
+  final val InitialUserActorSetup = 6
 }
index c27fe2c..100f68b 100644 (file)
@@ -199,7 +199,11 @@ class MongoDBStore(
 
   //+ UserStateStore
   def insertUserState(userState: UserState) = {
-    MongoDBStore.insertUserState(userState, userStates, MongoDBStore.jsonSupportToDBObject)
+    MongoDBStore.insertUserState(
+      userState.copy(_id = new ObjectId().toString),
+      userStates,
+      MongoDBStore.jsonSupportToDBObject
+    )
   }
 
   def findUserStateByUserID(userID: String): Option[UserState] = {
index 873a09b..da97991 100644 (file)
@@ -78,4 +78,9 @@ trait LazyLoggable {
   protected def logChainOfCauses(t: Throwable, caughtTraceIndex: Int = 2): Unit = {
     logger.error("Oops!\n{}", chainOfCausesForLogging(t, caughtTraceIndex + 1))
   }
+
+  protected def logSeparator(): Unit = {
+    // With this, we should be 120 characters wide (full log line)
+    logger.debug("================================================")
+  }
 }
index d71558e..e08b314 100644 (file)
@@ -102,4 +102,9 @@ trait Loggable {
   protected def logChainOfCauses(t: Throwable): Unit = {
     logger.error("Oops!\n{}", chainOfCausesForLogging(t))
   }
-}
\ No newline at end of file
+
+  protected def logSeparator(): Unit = {
+    // With this, we should be 120 characters wide (full log line)
+    logger.debug("================================================")
+  }
+}