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

com.akkaserverless.javasdk.impl.DiscoveryImpl.scala Maven / Gradle / Ivy

/*
 * Copyright 2021 Lightbend 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.akkaserverless.javasdk.impl

import akka.Done
import akka.actor.{ ActorSystem, CoordinatedShutdown }
import com.akkaserverless.javasdk.replicatedentity.{ ReplicatedEntityOptions, WriteConsistency }
import com.akkaserverless.javasdk.{ BuildInfo, EntityOptions }
import com.akkaserverless.protocol.action.Actions
import com.akkaserverless.protocol.discovery.PassivationStrategy.Strategy
import com.akkaserverless.protocol.discovery._
import com.google.protobuf.DescriptorProtos
import com.google.protobuf.empty.Empty
import org.slf4j.LoggerFactory

import java.time.Duration
import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.{ Future, Promise }
import scala.io.Source
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal

class DiscoveryImpl(system: ActorSystem, services: Map[String, Service]) extends Discovery {

  private val log = LoggerFactory.getLogger(getClass)

  // Delay CoordinatedShutdown until the proxy has been terminated.
  // This is updated from the `discover` call with a new Promise. Completed in the `proxyTerminated` call.
  private val proxyTerminatedRef = new AtomicReference[Promise[Done]](Promise.successful(Done))

  CoordinatedShutdown(system).addTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "wait-for-proxy-terminated") { () =>
    proxyTerminatedRef.get().future
  }

  private def configuredOrElse(key: String, default: String): String =
    if (system.settings.config.hasPath(key)) system.settings.config.getString(key) else default

  private def configuredIntOrElse(key: String, default: Int): Int =
    if (system.settings.config.hasPath(key)) system.settings.config.getInt(key) else default

  private val serviceInfo = ServiceInfo(
    serviceRuntime = sys.props.getOrElse("java.runtime.name", "")
      + " " + sys.props.getOrElse("java.runtime.version", ""),
    supportLibraryName = configuredOrElse("akkaserverless.library.name", BuildInfo.name),
    supportLibraryVersion = configuredOrElse("akkaserverless.library.version", BuildInfo.version),
    protocolMajorVersion =
      configuredIntOrElse("akkaserverless.library.protocol-major-version", BuildInfo.protocolMajorVersion),
    protocolMinorVersion =
      configuredIntOrElse("akkaserverless.library.protocol-minor-version", BuildInfo.protocolMinorVersion))

  // detect hybrid proxy version probes when protocol version 0.0
  private def isVersionProbe(info: ProxyInfo): Boolean = {
    info.protocolMajorVersion == 0 && info.protocolMinorVersion == 0
  }

  /**
   * Discover what components the user function wishes to serve.
   */
  override def discover(in: ProxyInfo): scala.concurrent.Future[Spec] = {
    if (isVersionProbe(in)) {
      // only (silently) send service info for hybrid proxy version probe
      Future.successful(Spec(serviceInfo = Some(serviceInfo)))
    } else {
      // don't wait for proxy termination in dev-mode, because the user function may be stopped without stopping the proxy
      val proxyTerminatedPromise = if (in.devMode) Promise.successful[Done](Done) else Promise[Done]()
      proxyTerminatedRef.getAndSet(proxyTerminatedPromise).trySuccess(Done)

      log.info(
        "Received discovery call from [{} {}]{} supporting Akka Serverless protocol {}.{}",
        in.proxyName,
        in.proxyVersion,
        if (in.deploymentName.isEmpty) { "" }
        else { s" at [${in.deploymentName}]" }, // added in protocol 0.7.4
        in.protocolMajorVersion,
        in.protocolMinorVersion)
      log.debug(s"Supported sidecar entity types: {}", in.supportedEntityTypes.mkString("[", ",", "]"))

      val unsupportedServices = services.values.filterNot { service =>
        in.supportedEntityTypes.contains(service.componentType)
      }

      // pass the deployed name of the service on to GrpcClients for cross component calls
      if (in.deploymentName.nonEmpty) {
        GrpcClients.get(system).setSelfServiceName(in.deploymentName)
      }

      if (unsupportedServices.nonEmpty) {
        log.error(
          "Proxy doesn't support the entity types for the following services: {}",
          unsupportedServices
            .map(s => s.descriptor.getFullName + ": " + s.componentType)
            .mkString(", "))
        // Don't fail though. The proxy may give us more information as to why it doesn't support them if we send back unsupported services.
        // eg, the proxy doesn't have a configured journal, and so can't support event sourcing.
      }

      val descriptorsWithSource = loadDescriptorsWithSource(
        system.settings.config.getString("akkaserverless.discovery.protobuf-descriptor-with-source-info-path"))
      val allDescriptors = AnySupport.flattenDescriptors(services.values.map(_.descriptor.getFile).toSeq)
      val builder = DescriptorProtos.FileDescriptorSet.newBuilder()
      allDescriptors.values.foreach { fd =>
        val proto = fd.toProto
        // We still use the descriptor as passed in by the user, but if we have one that we've read from the
        // descriptors file that has the source info, we add that source info to the one passed in, and use that.
        val protoWithSource = descriptorsWithSource.get(proto.getName).fold(proto) { withSource =>
          proto.toBuilder.setSourceCodeInfo(withSource.getSourceCodeInfo).build()
        }
        builder.addFile(protoWithSource)
      }
      val fileDescriptorSet = builder.build().toByteString

      val components = services.map { case (name, service) =>
        val forwardHeaders = service.componentOptions.map(_.forwardHeaders().asScala.toSeq).getOrElse(Seq.empty)
        service.componentType match {
          case Actions.name =>
            Component(
              service.componentType,
              name,
              Component.ComponentSettings.Component(GenericComponentSettings(forwardHeaders)))
          case _ =>
            val passivationStrategy = entityPassivationStrategy(service.componentOptions.collect {
              case e: EntityOptions => e
            })
            val replicatedEntitySpecificSettings = specificSettings(service.componentOptions.collect {
              case options: ReplicatedEntityOptions => options
            })
            Component(
              service.componentType,
              name,
              Component.ComponentSettings.Entity(
                EntitySettings(
                  service.entityType,
                  passivationStrategy,
                  service.componentOptions.map(_.forwardHeaders().asScala.toSeq).getOrElse(Nil),
                  replicatedEntitySpecificSettings)))
        }
      }.toSeq

      Future.successful(Spec(fileDescriptorSet, components, Some(serviceInfo)))
    }
  }

  /**
   * Report an error back to the user function. This will only be invoked to tell the user function that it has done
   * something wrong, eg, violated the protocol, tried to use an entity type that isn't supported, or attempted to
   * forward to an entity that doesn't exist, etc. These messages should be logged clearly for debugging purposes.
   */
  override def reportError(in: UserFunctionError): scala.concurrent.Future[com.google.protobuf.empty.Empty] = {
    val sourceMsgs = in.sourceLocations.map { location =>
      loadSource(location) match {
        case None if location.startLine == 0 && location.startCol == 0 =>
          s"At ${location.fileName}"
        case None =>
          s"At ${location.fileName}:${location.startLine + 1}:${location.startCol + 1}"
        case Some(source) =>
          s"At ${location.fileName}:${location.startLine + 1}:${location.startCol + 1}:${"\n"}$source"
      }
    }.toList
    val message = s"Error reported from Akka Serverless system: ${in.code} ${in.message}"
    val detail = if (in.detail.isEmpty) Nil else List(in.detail)
    val seeDocs = DocLinks.forErrorCode(in.code).map(link => s"See documentation: $link").toList
    val messages = message :: detail ::: seeDocs ::: sourceMsgs

    log.error(messages.mkString("\n\n"))

    Future.successful(com.google.protobuf.empty.Empty.defaultInstance)
  }

  override def healthCheck(in: Empty): Future[Empty] =
    Future.successful(Empty.defaultInstance)

  private def loadSource(location: UserFunctionError.SourceLocation): Option[String] =
    if (location.endLine == 0 && location.endCol == 0) {
      // It's been sent without line/col data
      None
    } else {
      val resourceStream = getClass.getClassLoader.getResourceAsStream(location.fileName)
      if (resourceStream != null) {
        val lines = Source
          .fromInputStream(resourceStream, "utf-8")
          .getLines()
          .slice(location.startLine, location.endLine + 1)
          .take(6) // Don't render more than 6 lines, we don't want to fill the logs too much
          .toList
        if (lines.size > 1) {
          Some(lines.mkString("\n"))
        } else {
          lines.headOption
            .map { line =>
              line + "\n" + line.take(location.startCol).map {
                case '\t' => '\t'
                case _    => ' '
              } + "^"
            }
        }
      } else None
    }

  private def entityPassivationStrategy(maybeOptions: Option[EntityOptions]): Option[PassivationStrategy] = {
    import com.akkaserverless.protocol.discovery.{ PassivationStrategy => EPStrategy }
    maybeOptions.flatMap { options =>
      options.passivationStrategy() match {
        case Timeout(maybeTimeout) =>
          maybeTimeout match {
            case Some(timeout) => Some(EPStrategy(Strategy.Timeout(TimeoutPassivationStrategy(timeout.toMillis))))
            case _ =>
              configuredPassivationTimeout("akkaserverless.passivation-timeout").map(timeout =>
                EPStrategy(Strategy.Timeout(TimeoutPassivationStrategy(timeout.toMillis))))
          }
      }
    }
  }

  private def configuredPassivationTimeout(key: String): Option[Duration] =
    if (system.settings.config.hasPath(key)) Some(system.settings.config.getDuration(key)) else None

  def specificSettings(options: Option[ReplicatedEntityOptions]): EntitySettings.SpecificSettings = {
    val writeConsistency = options.map(_.writeConsistency) match {
      case Some(WriteConsistency.ALL)      => ReplicatedWriteConsistency.REPLICATED_WRITE_CONSISTENCY_ALL
      case Some(WriteConsistency.MAJORITY) => ReplicatedWriteConsistency.REPLICATED_WRITE_CONSISTENCY_MAJORITY
      case _                               => ReplicatedWriteConsistency.REPLICATED_WRITE_CONSISTENCY_LOCAL_UNSPECIFIED
    }
    EntitySettings.SpecificSettings.ReplicatedEntity(ReplicatedEntitySettings(writeConsistency))
  }

  private def loadDescriptorsWithSource(path: String): Map[String, DescriptorProtos.FileDescriptorProto] =
    // Special case for disabled, this allows the user to disable attempting to load the descriptor, which means
    // they won't get the great big warning below if it doesn't exist.
    if (path == "disabled") {
      Map.empty
    } else {
      val stream = getClass.getClassLoader.getResourceAsStream(path)
      if (stream == null) {
        log.warn(
          s"Source info descriptor [$path] not found on classpath. Reporting descriptor errors against " +
          "source locations will be disabled. To fix this, ensure that the following configuration applied to the " +
          "protobuf maven plugin: \n" +
          s"""
             |true
             |true
             |${path.split("/").last}
             |
             |and also that the generated resources directory is included in the classpath:
             |
             |  
             |    
             |      
             |        $${project.build.directory}/generated-resources
             |      
             |    
             |    ...
             |""".stripMargin)
        Map.empty
      } else {
        try {
          DescriptorProtos.FileDescriptorSet
            .parseFrom(stream)
            .getFileList
            .asScala
            .collect {
              case file if file.hasSourceCodeInfo => file.getName -> file
            }
            .toMap
        } catch {
          case NonFatal(e) =>
            log.error("Error parsing descriptor file [{}] from classpath, source mapping will be disabled", path, e)
            Map.empty
        }
      }
    }

  override def proxyTerminated(in: Empty): Future[Empty] = {
    log.debug("Proxy terminated")
    proxyTerminatedRef.get().trySuccess(Done)
    Future.successful(Empty.defaultInstance)
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy