All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.netflix.atlas.lwcapi.SubscribeApi.scala Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2014-2024 Netflix, Inc.
 *
 * Licensed 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 com.netflix.atlas.lwcapi

import org.apache.pekko.NotUsed
import org.apache.pekko.http.scaladsl.model.ws.BinaryMessage
import org.apache.pekko.http.scaladsl.model.ws.Message
import org.apache.pekko.http.scaladsl.model.ws.TextMessage
import org.apache.pekko.http.scaladsl.server.Directives.*
import org.apache.pekko.http.scaladsl.server.Route
import org.apache.pekko.stream.Materializer
import org.apache.pekko.stream.scaladsl.Flow
import org.apache.pekko.stream.scaladsl.Keep
import org.apache.pekko.stream.scaladsl.Sink
import org.apache.pekko.stream.scaladsl.Source
import org.apache.pekko.util.ByteString
import com.netflix.atlas.eval.model.LwcDataExpr
import com.netflix.atlas.eval.model.LwcHeartbeat
import com.netflix.atlas.eval.model.LwcMessages
import com.netflix.atlas.eval.model.LwcSubscriptionV2
import com.netflix.atlas.json.JsonSupport
import com.netflix.atlas.pekko.CustomDirectives.*
import com.netflix.atlas.pekko.DiagnosticMessage
import com.netflix.atlas.pekko.StreamOps
import com.netflix.atlas.pekko.WebApi
import com.netflix.iep.config.NetflixEnvironment
import com.netflix.spectator.api.Registry
import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging

import java.io.ByteArrayOutputStream
import java.util.concurrent.ArrayBlockingQueue
import scala.concurrent.duration.*
import scala.util.Failure
import scala.util.Success
import scala.util.control.NonFatal

class SubscribeApi(
  config: Config,
  registry: Registry,
  sm: StreamSubscriptionManager,
  splitter: ExpressionSplitter,
  implicit val materializer: Materializer
) extends WebApi
    with StrictLogging {

  import SubscribeApi.*
  import com.netflix.atlas.pekko.OpportunisticEC.*

  private val queueSize = config.getInt("atlas.lwcapi.queue-size")
  private val batchSize = config.getInt("atlas.lwcapi.batch-size")

  private val dropNew = config.getBoolean("atlas.lwcapi.drop-new")

  private val evalsCounter = registry.counter("atlas.lwcapi.subscribe.count", "action", "subscribe")

  private val itemsCounter =
    registry.counter("atlas.lwcapi.subscribe.itemCount", "action", "subscribe")

  def routes: Route = {
    extractClientIP { addr =>
      endpointPathPrefix("api" / "v2" / "subscribe") {
        path(Remaining) { streamId =>
          val meta = StreamMetadata(streamId, addr.value)
          handleWebSocketMessages(createHandlerFlowV2(meta))
        }
      }
    }
  }

  /**
    * Drop any other connections that may already be using the same id
    */
  private def dropSameIdConnections(streamMeta: StreamMetadata): Unit = {
    val streamId = streamMeta.streamId
    sm.unregister(streamId).foreach { queue =>
      val msg = DiagnosticMessage.info(s"dropped: another connection is using id: $streamId")
      queue.offer(Seq(msg))
      queue.complete()
    }
  }

  /**
    * Uses a binary format for the messages and batches output to achieve higher throughput.
    */
  private def createHandlerFlowV2(streamMeta: StreamMetadata): Flow[Message, Message, Any] = {
    dropSameIdConnections(streamMeta)

    Flow[Message]
      .flatMapConcat {
        case msg: TextMessage =>
          // Text messages are not supported, ignore
          msg.textStream.runWith(Sink.ignore)
          Source.empty
        case BinaryMessage.Strict(str) =>
          Source.single(str)
        case msg: BinaryMessage =>
          msg.dataStream.fold(ByteString.empty)(_ ++ _)
      }
      .via(new WebSocketSessionManager(streamMeta, register, subscribe))
      .flatMapMerge(Int.MaxValue, msg => msg)
      .groupedWithin(batchSize, 1.second)
      .statefulMap(() => new ByteArrayOutputStream())(
        (baos, seq) => baos -> List(BinaryMessage(LwcMessages.encodeBatch(seq, baos))),
        _ => None
      )
      .mapConcat(identity)
      .watchTermination() { (_, f) =>
        f.onComplete {
          case Success(_) =>
            logger.debug(s"lost client for $streamMeta.streamId")
            sm.unregister(streamMeta.streamId)
          case Failure(t) =>
            logger.debug(s"lost client for $streamMeta.streamId", t)
            sm.unregister(streamMeta.streamId)
        }
      }
  }

  private def stepAlignedTime(step: Long): Long = {
    registry.clock().wallTime() / step * step
  }

  private def register(streamMeta: StreamMetadata): Source[JsonSupport, NotUsed] = {

    val streamId = streamMeta.streamId

    // Create queue to allow messages coming into /evaluate to be passed to this stream
    // TODO - A client can connect but not send a message. When that happens, the
    // publisher sink from this queue will shutdown and complete the queue. See
    // pekko.http.client.stream-cancellation-delay. Unfortunately
    // the websocket flow is not notified of the shutdown. If the client sends a message
    // after shutdown, the client flow will be terminated. (that's fine).
    // There is likely another way to wire this up. Alternatively we could hold a
    // kill switch on the createHandlerFlow...()s but some state flags are needed and
    // it gets messy.
    // For now, the queue will close and if no messages are sent from the client, the
    // pekko.http.server.idle-timeout will kill the client connection and we'll try to
    // close a closed queue.
    val blockingQueue = new ArrayBlockingQueue[Seq[JsonSupport]](queueSize)
    val (queue, pub) = StreamOps
      .wrapBlockingQueue[Seq[JsonSupport]](registry, "SubscribeApi", blockingQueue, dropNew)
      .toMat(Sink.asPublisher(true))(Keep.both)
      .run()

    // Send initial setup messages
    queue.offer(Seq(DiagnosticMessage.info(s"setup stream $streamId on $instanceId")))
    val handler = new QueueHandler(streamMeta, queue)
    sm.register(streamMeta, handler)

    // Heartbeat messages to ensure that the socket is never idle
    val heartbeatSrc = Source
      .tick(0.seconds, 5.seconds, NotUsed)
      .flatMapConcat { _ =>
        val steps = sm
          .subscriptionsForStream(streamId)
          .map { sub =>
            // For events where step doesn't really matter use 5s as that is the typical heartbeat
            // frequency. This only gets used for the time associated with the heartbeat messages.
            if (sub.metadata.frequency == 0L) 5_000L else sub.metadata.frequency
          }
          .distinct
          .map { step =>
            // To account for some delays for data coming from real systems, the heartbeat
            // timestamp is delayed by one interval
            LwcHeartbeat(stepAlignedTime(step) - step, step)
          }
        Source(steps)
      }

    Source
      .fromPublisher(pub)
      .flatMapConcat(Source.apply)
      .merge(heartbeatSrc)
      .viaMat(StreamOps.monitorFlow(registry, "StreamApi"))(Keep.left)
  }

  private def subscribe(
    streamId: String,
    expressions: List[ExpressionMetadata]
  ): List[JsonSupport] = {
    evalsCounter.increment()
    itemsCounter.increment(expressions.size)

    val messages = List.newBuilder[JsonSupport]
    val subIdsBuilder = Set.newBuilder[String]

    expressions.foreach { expr =>
      try {
        val splits = splitter.split(expr.expression, expr.exprType, expr.frequency)

        // Add any new expressions
        val (_, addedSubs) = sm.subscribe(streamId, splits)
        val subMessages = addedSubs.map { sub =>
          val meta = sub.metadata
          val exprInfo = LwcDataExpr(meta.id, meta.expression, meta.frequency)
          LwcSubscriptionV2(expr.expression, expr.exprType, List(exprInfo))
        }
        messages ++= subMessages

        // Add expression ids in use by this split
        subIdsBuilder ++= splits.map(_.metadata.id)
      } catch {
        case NonFatal(e) =>
          logger.error(s"Unable to subscribe to expression ${expr.expression}", e)
          messages += DiagnosticMessage.error(s"[${expr.expression}] ${e.getMessage}")
      }
    }

    // Remove any expressions that are no longer required
    val subIds = subIdsBuilder.result()
    sm.subscriptionsForStream(streamId)
      .filter(s => !subIds.contains(s.metadata.id))
      .foreach(s => sm.unsubscribe(streamId, s.metadata.id))

    messages.result()
  }
}

object SubscribeApi {

  private val instanceId = NetflixEnvironment.instanceId()

  case class SubscribeRequest(streamId: String, expressions: List[ExpressionMetadata])
      extends JsonSupport {

    require(streamId != null && streamId.nonEmpty, "streamId attribute is missing or empty")

    require(
      expressions != null && expressions.nonEmpty,
      "expressions attribute is missing or empty"
    )
  }

  case class ErrorMsg(expression: String, message: String)

  case class Errors(`type`: String, message: String, errors: List[ErrorMsg]) extends JsonSupport
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy