Поток Akka Streams для обработки результатов с разбивкой на страницы не завершается

Я хотел бы реализовать поток для обработки результатов с разбивкой на страницы (например, базовая служба возвращает некоторые результаты, но также указывает, что доступны дополнительные результаты, делая другой запрос, передавая, например, курсор).

Что я сделал до сих пор:

  1. Я реализовал следующий поток и тест, но поток не завершается.

    object AdditionalRequestsFlow {
    
      private def keepRequest[Request, Response](flow: Flow[Request, Response, NotUsed]): Flow[Request, (Request, Response), NotUsed] = {
        Flow.fromGraph(GraphDSL.create() { implicit builder: GraphDSL.Builder[NotUsed] =>
          import GraphDSL.Implicits._
          val in = builder.add(Flow[Request])
    
          val bcast = builder.add(Broadcast[Request](2))
          val merge = builder.add(Zip[Request, Response]())
    
          in ~> bcast         ~> merge.in0
                bcast ~> flow ~> merge.in1
    
          FlowShape(in.in, merge.out)
        })
      }
    
      def flow[Request, Response, Output](
        inputFlow: Flow[Request, Response, NotUsed],
        anotherRequest: (Request, Response) => Option[Request],
        extractOutput: Response => Output,
        mergeOutput: (Output, Output) => Output
      ): Flow[Request, Output, NotUsed] = {
        Flow.fromGraph(GraphDSL.create() { implicit b =>
          import GraphDSL.Implicits._
    
          val start = b.add(Flow[Request])
          val merge = b.add(Merge[Request](2))
          val underlying = b.add(keepRequest(inputFlow))
          val unOption = b.add(Flow[Option[Request]].mapConcat(_.toList))
          val unzip = b.add(UnzipWith[(Request, Response), Response, Option[Request]] { case (req, res) =>
            (res, anotherRequest(req, res))
          })
          val finish = b.add(Flow[Response].map(extractOutput)) // this is wrong as we don't keep to 1 Request -> 1 Output, but first let's get the flow to work
    
          start ~> merge ~> underlying ~> unzip.in
                                          unzip.out0            ~>  finish
                   merge <~ unOption   <~ unzip.out1
    
          FlowShape(start.in, finish.out)
        })
      }       
    }
    

    Тест:

        import akka.NotUsed
        import akka.actor.ActorSystem
        import akka.stream.ActorMaterializer
        import akka.stream.scaladsl.{Flow, Sink, Source}
        import org.scalatest.FlatSpec
        import org.scalatest.Matchers._
        import cats.syntax.option._
        import org.scalatest.concurrent.ScalaFutures.whenReady
    
        class AdditionalRequestsFlowSpec extends FlatSpec {
          implicit val system = ActorSystem()
          implicit val materializer = ActorMaterializer()
    
          case class Request(max: Int, batchSize: Int, offset: Option[Int] = None)
          case class Response(values: List[Int], nextOffset: Option[Int])
    
          private val flow: Flow[Request, Response, NotUsed] = {
            Flow[Request]
              .map { request =>
                val start = request.offset.getOrElse(0)
                val end = Math.min(request.max, start + request.batchSize)
                val nextOffset = if (end == request.max) None else Some(end)
                val result = Response((start until end).toList, nextOffset)
                result
              }
          }
    
          "AdditionalRequestsFlow" should "collect additional responses" in {
            def anotherRequest(request: Request, response: Response): Option[Request] = {
              response.nextOffset.map { nextOffset => request.copy(offset = nextOffset.some) }
            }
    
            def extract(x: Response): List[Int] = x.values
            def merge(a: List[Int], b: List[Int]): List[Int] = a ::: b
    
            val requests =
              Request(max = 35, batchSize = 10) ::
              Request(max = 5, batchSize = 10) ::
              Request(max = 100, batchSize = 1) ::
              Nil
    
            val expected = requests.map { x =>
              (0 until x.max).toList
            }
    
            val future = Source(requests)
              .via(AdditionalRequestsFlow.flow(flow, anotherRequest, extract, merge))
              .runWith(Sink.seq)
    
            whenReady(future) { x =>
              x shouldEqual expected
            }
          }
        }
    
  2. Реализовал тот же поток ужасным, блокирующим образом, чтобы проиллюстрировать, чего я пытаюсь достичь:

       def uglyHackFlow[Request, Response, Output](
        inputFlow: Flow[Request, Response, NotUsed],
        anotherRequest: (Request, Response) => Option[Request],
        extractOutput: Response => Output,
        mergeOutput: (Output, Output) => Output
      ): Flow[Request, Output, NotUsed] = {
        implicit val system = ActorSystem()
        implicit val materializer = ActorMaterializer()
    
        Flow[Request]
          .map { x =>
            def grab(request: Request): Output = {
              val response = Await.result(Source.single(request).via(inputFlow).runWith(Sink.head), 10.seconds) // :(
              val another = anotherRequest(request, response)
              val output = extractOutput(response)
              another.map { another =>
                mergeOutput(output, grab(another))
              } getOrElse output
            }
    
            grab(x)
          }
      }
    

    Это работает (но мы не должны материализовать что-либо / Await на данный момент).

  3. Проверено http://doc.akka.io/docs/akka/2.4/scala/stream/stream-graphs.html#Graph_cycles__liveness_and_deadlocks, который, как мне кажется, содержит ответ, однако я не могу найти его там. В моем случае я бы ожидал, что цикл должен содержать один элемент в большинстве случаев, поэтому не должно происходить ни переполнения буфера, ни полного голодания, но, очевидно, это происходит.

  4. Пытался отладить поток с помощью .withAttributes(Attributes(LogLevels(...))), однако это не приводит к каким-либо результатам, несмотря на, казалось бы, правильно настроенные регистраторы.

Я ищу подсказки, как исправить метод flow, сохраняя ту же подпись и семантику (тест пройдет).

Или, может быть, я делаю что-то совершенно нестандартное (например, в akka-stream-contrib уже есть функция, которая решает эту проблему)?


person John M    schedule 18.12.2016    source источник
comment
Не могли бы вы добавить информацию о том, как выглядит внешний API, куда он возвращает курсор и как вы передаете его для перехода на следующую страницу? Я подозреваю, что решение может быть намного проще (потому что я получаю доступ к разбивке на страницы API в своем проекте).   -  person expert    schedule 18.12.2016
comment
Намерение состояло в том, чтобы создать поток, который будет работать для любого разбитого на страницы API, где из ответа ясно, следует ли делать другой запрос или нет. Конкретный вариант использования, с которым я сейчас сталкиваюсь, — это фид Atom XML, который возвращает несколько тегов <entry/›, ​​а затем <link href="..." rel="next"/> для следующего URL-адреса, который нужно вызвать, чтобы получить следующий ответ с большим количеством записей. В настоящее время я подключил свой uglyHackFlow, и он отлично работает для этого варианта использования.   -  person John M    schedule 18.12.2016


Ответы (2)


Я думаю, что гораздо безопаснее использовать Source.unfold, чем создавать собственные графики. Вот что я обычно делаю (с небольшими вариациями в зависимости от API).

  override def getArticles(lastTokenOpt: Option[String], filterIds: (Seq[Id]) => Seq[Id]): Source[Either[String, ImpArticle], NotUsed] = {

    val maxRows = 1000

    def getUri(cursor: String, count: Int) = s"/works?rows=$count&filter=type:journal-article&order=asc&sort=deposited&cursor=${URLEncoder.encode(cursor, "UTF-8")}"

    Source.unfoldAsync(lastTokenOpt.getOrElse("*")) { cursor =>

      println(s"Getting ${getUri(cursor, maxRows)}")
      if (cursor.nonEmpty) {
        sendGetRequest[CrossRefResponse[CrossRefList[JsValue]]](getUri(cursor, maxRows)).map {
          case Some(response) =>
            response.message match {
              case Left(list) if response.status == "ok" =>

                println(s"Got ${list.items.length} items")
                val items = list.items.flatMap { js =>
                  try {
                    parseArticle(js)
                  } catch {
                    case ex: Throwable =>
                      logger.error(s"Error on parsing: ${js.compactPrint}")
                      throw ex
                  }
                }

                list.`next-cursor` match {
                  case Some(nextCursor) =>
                    Some(nextCursor -> (items.map(Right.apply).toList ::: List(Left(nextCursor))))
                  case None =>
                    logger.error(s"`next-cursor` is missing when fetching from CrossRef [status ${response.status}][${getUri(cursor, maxRows)}]")
                    Some("" -> items.map(Right.apply).toList)
                }
              case Left(jsvalue) if response.status != "ok" =>
                logger.error(s"API error on fetching data from CrossRef [status ${response.status}][${getUri(cursor, maxRows)}]")
                None
              case Right(someError) =>
                val cause = someError.fold(errors => errors.map(_.message).mkString(", "), ex => ex.message)
                logger.error(s"API error on fetching data from CrossRef [status $cause}][${getUri(cursor, maxRows)}]")
                None
            }

          case None =>
            logger.error(s"Got error on fetching ${getUri(cursor, maxRows)} from CrossRef")
            None
        }
      } else
        Future.successful(None)
    }.mapConcat(identity)
  }

В вашем случае вам, вероятно, даже не нужно нажимать курсор на поток. Я делаю это, потому что я сохраняю последний успешный курсор в базе данных, чтобы иметь возможность возобновить работу позже в случае сбоя.

person expert    schedule 18.12.2016
comment
Спасибо, Source.unfold выглядит интересно, хотя я еще не успел его реализовать. Я также нашел gist.github.com/rrodseth/ed3e5edf90ce36e3cf6b, который может быть кому-то полезен. - person John M; 13.01.2017
comment
Я устанавливаю ваш ответ как принятый (по крайней мере, на данный момент), так как я думаю, что у него больше шансов сработать. - person John M; 13.01.2017

кажется, что это видео раскрывает суть того, что вы пытаетесь сделать. Они создают пользовательскую Graphstage, которая поддерживает состояние и отправляет его обратно на сервер, а поток ответов зависит от отправленного состояния, у них также есть событие, сигнализирующее о завершении (в вашем случае это будет там, где у вас есть эта проверка

if (end == request.max) None

person Michael Gikaru    schedule 21.12.2016