From b0fa0ad9758bbfecaeeaf3d0bed7dfbebd3e35ab Mon Sep 17 00:00:00 2001 From: Moses Nakamura Date: Thu, 10 Jan 2019 22:23:53 +0000 Subject: [PATCH 01/45] twitter-oss: update OSS libraries post-release to 19.2.0 Problem We want to update to the next SNAPSHOT version of our Twitter OSS libraries - util - scrooge - finagle - twitter-server - finatra Solution Prepare libraries for their next SNAPSHOT version. JIRA Issues: CSL-7429 Differential Revision: https://phabricator.twitter.biz/D259499 --- README.md | 4 ++-- build.sbt | 2 +- project/plugins.sbt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3925f3af9c..567bab45f3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Finatra -[![Build Status](https://secure.travis-ci.org/twitter/finatra.png?branch=master)](https://travis-ci.org/twitter/finatra?branch=master) -[![Test Coverage](https://codecov.io/github/twitter/finatra/coverage.svg?branch=master)](https://codecov.io/github/twitter/finatra?branch=master) +[![Build Status](https://secure.travis-ci.org/twitter/finatra.png?branch=develop)](https://travis-ci.org/twitter/finatra?branch=develop) +[![Test Coverage](https://codecov.io/github/twitter/finatra/coverage.svg?branch=develop)](https://codecov.io/github/twitter/finatra?branch=develop) [![Project status](https://img.shields.io/badge/status-active-brightgreen.svg)](#status) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.twitter/finatra-http_2.12/badge.svg)][maven-central] [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/twitter/finatra) diff --git a/build.sbt b/build.sbt index 92490aba9c..ef60482c64 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ import scoverage.ScoverageKeys concurrentRestrictions in Global += Tags.limit(Tags.Test, 1) // All Twitter library releases are date versioned as YY.MM.patch -val releaseVersion = "19.1.0" +val releaseVersion = "19.2.0-SNAPSHOT" lazy val buildSettings = Seq( version := releaseVersion, diff --git a/project/plugins.sbt b/project/plugins.sbt index 5c8c1abfae..677a099bb0 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,7 +3,7 @@ resolvers ++= Seq( Resolver.sonatypeRepo("snapshots") ) -val releaseVersion = "19.1.0" +val releaseVersion = "19.2.0-SNAPSHOT" addSbtPlugin("com.twitter" % "scrooge-sbt-plugin" % releaseVersion) From 851009524e0a9f9d40fbc1ef97a0d16ccd93dac5 Mon Sep 17 00:00:00 2001 From: Christopher Coco Date: Fri, 11 Jan 2019 00:09:30 +0000 Subject: [PATCH 02/45] finatra: Remove `javax.servlet` dependency Problem The `javax.servlet` dependency is incorrectly specified. Solution Remove it. Result The fixes #478. No longer a specified dependency on a very old version on an unused library. JIRA Issues: CSL-7457 Differential Revision: https://phabricator.twitter.biz/D259671 --- CHANGELOG.rst | 15 +++++++++++++++ build.sbt | 2 -- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 40267a84af..939b139a62 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,21 @@ Note that ``RB_ID=#`` and ``PHAB_ID=#`` correspond to associated message in comm Unreleased ---------- +Added +~~~~~ + +Changed +~~~~~~~ + +Fixed +~~~~~ + +* finatra: Remove extraneous dependency on old `javax.servlet` ServletAPI dependency. + The fixes #478. ``PHAB_ID=D259671`` + +Closed +~~~~~~ + 19.1.0 ------- diff --git a/build.sbt b/build.sbt index ef60482c64..388187d13b 100644 --- a/build.sbt +++ b/build.sbt @@ -74,7 +74,6 @@ lazy val versions = new { val scalaCheck = "1.13.4" val scalaGuice = "4.1.0" val scalaTest = "3.0.0" - val servletApi = "2.5" val slf4j = "1.7.21" val snakeyaml = "1.12" val specs2 = "2.4.17" @@ -645,7 +644,6 @@ lazy val http = project "com.twitter" %% "finagle-exp" % versions.twLibVersion, "com.twitter" %% "finagle-http" % versions.twLibVersion, "commons-fileupload" % "commons-fileupload" % versions.commonsFileupload, - "javax.servlet" % "servlet-api" % versions.servletApi, "com.novocode" % "junit-interface" % "0.11" % Test ), unmanagedResourceDirectories in Test += baseDirectory( From 862f0ab15660145fb8c3f432da544950a57747e9 Mon Sep 17 00:00:00 2001 From: Mesut OZEN Date: Fri, 11 Jan 2019 19:05:55 +0000 Subject: [PATCH 03/45] finatra-jackson: Added @Pattern annotation for regex based string validation Problem We should able to validate http request parameters whether match the given regex pattern. For instance, Finatra should able to validate phone parameter whether matches to phone pattern. `case class MyRequest(phone: String)` Solution Added `@Pattern` annotation to finatra-jackson for regex based validation. If the regex does not match, it will return PatternNotMatches errorCode which contains the regex and value Result With `@Pattern` annotation, we provide regex that checks the parameter match or not. ``` case class MyRequest( @Pattern(regexp = "^[+][(]{0,1}[0-9]{1,4}[)]{0,1}[-\s./0-9]$") phone: String) ``` Signed-off-by: Yufan Gong Differential Revision: https://phabricator.twitter.biz/D259719 --- CHANGELOG.rst | 5 +- .../validators/PatternInternal.java | 20 +++++ .../finatra/json/validation.properties | 1 + .../com/twitter/json/validation.properties | 1 + .../validators/PatternValidator.scala | 79 +++++++++++++++++++ .../finatra/validation/ErrorCode.scala | 1 + .../twitter/finatra/validation/package.scala | 1 + .../validators/PatternValidatorTest.scala | 75 ++++++++++++++++++ 8 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 jackson/src/main/java/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternInternal.java create mode 100644 jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala create mode 100644 jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 939b139a62..d605af83c7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,9 @@ Unreleased Added ~~~~~ +* finatra-jackson: Added @Pattern annotation to support finatra/jackson for regex pattern + validation on string values. ``PHAB_ID=D259719`` + Changed ~~~~~~~ @@ -23,7 +26,7 @@ Closed ~~~~~~ 19.1.0 -------- +------ Added ~~~~~ diff --git a/jackson/src/main/java/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternInternal.java b/jackson/src/main/java/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternInternal.java new file mode 100644 index 0000000000..3451e88bda --- /dev/null +++ b/jackson/src/main/java/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternInternal.java @@ -0,0 +1,20 @@ +package com.twitter.finatra.json.internal.caseclass.validation.validators; + +import com.twitter.finatra.validation.Validation; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({PARAMETER}) +@Retention(RUNTIME) +@Validation(validatedBy = PatternValidator.class) +public @interface PatternInternal { + + /** + * @return the regular expression to match + */ + String regexp(); +} diff --git a/jackson/src/main/resources/com/twitter/finatra/json/validation.properties b/jackson/src/main/resources/com/twitter/finatra/json/validation.properties index 1b58cfc278..e935dc88f4 100644 --- a/jackson/src/main/resources/com/twitter/finatra/json/validation.properties +++ b/jackson/src/main/resources/com/twitter/finatra/json/validation.properties @@ -9,3 +9,4 @@ com.twitter.finatra.json.internal.caseclass.validation.validators.RangeInternal com.twitter.finatra.json.internal.caseclass.validation.validators.SizeInternal = size [%s] is not between %s and %s com.twitter.finatra.json.internal.caseclass.validation.validators.TimeGranularityInternal = [%s] is not %s granularity com.twitter.finatra.json.internal.caseclass.validation.validators.UUIDInternal = [%s] is not a valid UUID +com.twitter.finatra.json.internal.caseclass.validation.validators.PatternInternal = [%s] does not match regex %s diff --git a/jackson/src/main/resources/com/twitter/json/validation.properties b/jackson/src/main/resources/com/twitter/json/validation.properties index 1b58cfc278..e935dc88f4 100644 --- a/jackson/src/main/resources/com/twitter/json/validation.properties +++ b/jackson/src/main/resources/com/twitter/json/validation.properties @@ -9,3 +9,4 @@ com.twitter.finatra.json.internal.caseclass.validation.validators.RangeInternal com.twitter.finatra.json.internal.caseclass.validation.validators.SizeInternal = size [%s] is not between %s and %s com.twitter.finatra.json.internal.caseclass.validation.validators.TimeGranularityInternal = [%s] is not %s granularity com.twitter.finatra.json.internal.caseclass.validation.validators.UUIDInternal = [%s] is not a valid UUID +com.twitter.finatra.json.internal.caseclass.validation.validators.PatternInternal = [%s] does not match regex %s diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala new file mode 100644 index 0000000000..e989d8e26c --- /dev/null +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala @@ -0,0 +1,79 @@ +package com.twitter.finatra.json.internal.caseclass.validation.validators + +import com.twitter.finatra.json.internal.caseclass.validation.validators.PatternValidator._ +import com.twitter.finatra.validation._ + +private[finatra] object PatternValidator { + def errorMessage(resolver: ValidationMessageResolver, value: Any, regex: String): String = { + resolver.resolve(classOf[Pattern], value, regex) + } + + def errorMessage(resolver: ValidationMessageResolver): String = { + resolver.resolve(classOf[Pattern]) + } +} + +/** + * Validates whether given [[CharSequence]] value matches with the specified regular expression + * + * @example {{{ + * case class ExampleRequest(@Pattern(regexp= "exampleRegex") exampleValue : String) + * }}} + */ +private[finatra] class PatternValidator( + validationMessageResolver: ValidationMessageResolver, + annotation: Pattern) + extends Validator[Pattern, Any](validationMessageResolver, annotation) { + + private val regexp: String = annotation.regexp() + private val regex = regexp.r + + /* Public */ + + override def isValid(value: Any): ValidationResult = { + value match { + case arrayValue: Array[_] => + validationResult(arrayValue) + case traversableValue: Traversable[_] => + validationResult(traversableValue) + case stringValue: String => + validationResult(stringValue) + case _ => + throw new IllegalArgumentException( + s"Class [${value.getClass}}] is not supported by ${this.getClass}") + } + } + + /* Private */ + + private def validationResult(value: Traversable[_]): ValidationResult = { + ValidationResult.validate( + value.forall(x => validate(x.toString)), + errorMessage(validationMessageResolver, value, regexp), + errorCode(value, regexp) + ) + } + + private def validationResult(value: String): ValidationResult = { + ValidationResult.validate( + validate(value), + errorMessage(validationMessageResolver, value, regexp), + errorCode(value, regexp) + ) + } + + private def validate(value: String): Boolean = { + regex.findFirstIn(value) match { + case None => false + case _ => true + } + } + + private def errorCode(value: String, regex: String) = { + ErrorCode.PatternNotMatched(value, regex) + } + + private def errorCode(value: Traversable[_], regex: String) = { + ErrorCode.PatternNotMatched(value mkString ",", regex) + } +} diff --git a/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala b/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala index c57a366409..939b413d49 100644 --- a/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala +++ b/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala @@ -25,4 +25,5 @@ object ErrorCode { case class ValueOutOfRange(value: Number, min: Long, max: Long) extends ErrorCode case class ValueTooLarge(maxValue: Long, value: Number) extends ErrorCode case class ValueTooSmall(minValue: Long, value: Number) extends ErrorCode + case class PatternNotMatched(value: String, regex: String) extends ErrorCode } diff --git a/jackson/src/main/scala/com/twitter/finatra/validation/package.scala b/jackson/src/main/scala/com/twitter/finatra/validation/package.scala index 713bac2037..0346fd9f04 100644 --- a/jackson/src/main/scala/com/twitter/finatra/validation/package.scala +++ b/jackson/src/main/scala/com/twitter/finatra/validation/package.scala @@ -21,4 +21,5 @@ package object validation { type TimeGranularity = com.twitter.finatra.json.internal.caseclass.validation.validators.TimeGranularityInternal @param type UUID = com.twitter.finatra.json.internal.caseclass.validation.validators.UUIDInternal @param + type Pattern = com.twitter.finatra.json.internal.caseclass.validation.validators.PatternInternal @param } diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala new file mode 100644 index 0000000000..d1b8f3e896 --- /dev/null +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala @@ -0,0 +1,75 @@ +package com.twitter.finatra.json.tests.internal.caseclass.validation.validators + +import com.twitter.finatra.json.internal.caseclass.validation.validators.PatternValidator +import com.twitter.finatra.validation.ValidationResult.{Invalid, Valid} +import com.twitter.finatra.validation.{ErrorCode, Pattern, ValidationResult, ValidatorTest} +import org.scalacheck.Gen +import org.scalatest.prop.GeneratorDrivenPropertyChecks + +case class NumberPatternExample(@Pattern(regexp = "[0-9]+") stringValue: String) +case class NumberPatternArrayExample(@Pattern(regexp = "[0-9]+") stringValue: Array[String]) +case class EmptyPatternExample(@Pattern(regexp = "") stringValue: String) +case class InvalidPatternExample(@Pattern(regexp = "([)") stringValue: String) + +class PatternValidatorTest extends ValidatorTest with GeneratorDrivenPropertyChecks { + + test("pass validation when regex matches for array type") { + val passValue = for { + size <- Gen.choose(10, 50) + } yield + Array.fill(size) { + Gen.choose(10, 100) + } + forAll(passValue) { value => + validate[NumberPatternArrayExample](value) should equal(Valid) + } + } + + test("pass validation when regex matches") { + validate[NumberPatternExample]("12345") should equal(Valid) + + } + + test("fail validation when regex not matches") { + validate[NumberPatternExample]("meros") should equal( + Invalid(errorMessage("meros", "[0-9]+"), ErrorCode.PatternNotMatched("meros", "[0-9]+")) + ) + } + + test("fail validation when regex not matches for a invalid value in array type") { + forAll(Traversable("invalid", "6666")) { value => + validate[NumberPatternArrayExample](value) should equal( + Invalid( + errorMessage(value.toString, "[0-9]+"), + ErrorCode.PatternNotMatched(value mkString ",", "[0-9]+")) + ) + } + } + + test("it should throw exception for invalid class type") { + the[IllegalArgumentException] thrownBy validate[NumberPatternArrayExample](new Object()) should have message + "Class [class java.lang.Object}] is not supported by class com.twitter.finatra.json.internal.caseclass.validation.validators.PatternValidator" + } + + test("pass validation when regex matches for traversable type") { + forAll(Traversable("1234", "6666")) { value => + validate[NumberPatternArrayExample](value) should equal(Valid) + } + } + + test("fail validation when regex is invalid") { + intercept[Exception] { + validate[InvalidPatternExample](value = "123") should equal( + Invalid(errorMessage("123", "([)"), ErrorCode.PatternNotMatched("123", "([)")) + ) + } + } + + private def validate[C: Manifest](value: Any): ValidationResult = { + super.validate(manifest[C].runtimeClass, "stringValue", classOf[Pattern], value) + } + + private def errorMessage(value: String, regex: String): String = { + PatternValidator.errorMessage(messageResolver, value, regex) + } +} From d1d6d1e079faa05b3ac1a8a42b792f56cd12f777 Mon Sep 17 00:00:00 2001 From: Jordan Parker Date: Mon, 14 Jan 2019 20:40:51 +0000 Subject: [PATCH 04/45] finatra-examples: Move calc example to new Controller Problem The Calculator example project was using the old version of Controller, an error message was not properly rendered, and Finatra uses `assert()` everywhere to validate configuration except in one place. Solution Move the example to the new Controller style, fix the error message, make all configuration validation use `assert()` for consistency. Differential Revision: https://phabricator.twitter.biz/D260230 --- CHANGELOG.rst | 17 +++++++++++------ .../calculator/CalculatorController.scala | 8 ++++---- .../com/twitter/finatra/thrift/Controller.scala | 6 +++--- .../finatra/thrift/routing/routers.scala | 16 +++++++--------- .../finatra/thrift/tests/ControllerTest.scala | 6 +++--- 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d605af83c7..e679fa6771 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Added Changed ~~~~~~~ +* finatra-thrift: If a Controller is not configured with exactly one endpoint + per method, it will throw an AssertionError instead of logging an error message. + An attempt to use non-legacy functionality with a legacy Controller will throw + an AssertionError. ``PHAB_ID=D260230`` + Fixed ~~~~~ @@ -32,7 +37,7 @@ Added ~~~~~ * finatra-kafka-streams: SumAggregator and CompositeSumAggregator only support enhanced window - aggregations for the sum operation. Deprecate SumAggregator and CompositeSumAggregator and create + aggregations for the sum operation. Deprecate SumAggregator and CompositeSumAggregator and create an AggregatorTransformer class that can perform arbitrary aggregations. ``PHAB_ID=D257138`` * finatra-streams: Open-source Finatra Streams. Finatra Streams is an integration @@ -118,11 +123,11 @@ Changed now-removed `c.t.f.b.Server` have been modified or removed. ``PHAB_ID=D254339`` -* finatra-kafka-streams: Finatra Queryable State methods currently require the window size - to be passed into query methods for windowed key value stores. This is unnecessary, as - the queryable state class can be passed the window size at construction time. We also now - save off all FinatraKeyValueStores in a global manager class to allow query services - (e.g. thrift) to access the same KeyValueStore implementation that the FinatraTransformer +* finatra-kafka-streams: Finatra Queryable State methods currently require the window size + to be passed into query methods for windowed key value stores. This is unnecessary, as + the queryable state class can be passed the window size at construction time. We also now + save off all FinatraKeyValueStores in a global manager class to allow query services + (e.g. thrift) to access the same KeyValueStore implementation that the FinatraTransformer is using. ``PHAB_ID=D256920`` Fixed diff --git a/examples/thrift-server/thrift-example-server/src/main/scala/com/twitter/calculator/CalculatorController.scala b/examples/thrift-server/thrift-example-server/src/main/scala/com/twitter/calculator/CalculatorController.scala index 7bfd7ec88d..ce8dca3689 100644 --- a/examples/thrift-server/thrift-example-server/src/main/scala/com/twitter/calculator/CalculatorController.scala +++ b/examples/thrift-server/thrift-example-server/src/main/scala/com/twitter/calculator/CalculatorController.scala @@ -7,18 +7,18 @@ import com.twitter.util.Future import javax.inject.Singleton @Singleton -class CalculatorController extends Controller with Calculator.BaseServiceIface { +class CalculatorController extends Controller(Calculator) { - override val addNumbers = handle(AddNumbers) { args: AddNumbers.Args => + handle(AddNumbers) { args: AddNumbers.Args => info(s"Adding numbers $args.a + $args.b") Future.value(args.a + args.b) } - override val addStrings = handle(AddStrings) { args: AddStrings.Args => + handle(AddStrings) { args: AddStrings.Args => Future.value((args.a.toInt + args.b.toInt).toString) } - override val increment = handle(Increment) { args: Increment.Args => + handle(Increment) { args: Increment.Args => Future.value(args.a + 1) } } diff --git a/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala b/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala index a13a07b59d..bc8031a7d0 100644 --- a/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala +++ b/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala @@ -52,9 +52,9 @@ abstract class Controller private (val config: Controller.Config) extends Loggin */ class MethodDSL[M <: ThriftMethod] (val m: M, chain: Filter.TypeAgnostic) { - private[this] def nonLegacy[T](f: ControllerConfig => T): T = config match { - case cc: ControllerConfig => f(cc) - case _: LegacyConfig => throw new IllegalStateException("Legacy controllers cannot use method DSLs") + private[this] def nonLegacy[T](f: ControllerConfig => T): T = { + assert(config.isInstanceOf[ControllerConfig], "Legacy controllers cannot use method DSLs") + f(config.asInstanceOf[ControllerConfig]) } /** diff --git a/thrift/src/main/scala/com/twitter/finatra/thrift/routing/routers.scala b/thrift/src/main/scala/com/twitter/finatra/thrift/routing/routers.scala index d56d7c9780..ca23d06b63 100644 --- a/thrift/src/main/scala/com/twitter/finatra/thrift/routing/routers.scala +++ b/thrift/src/main/scala/com/twitter/finatra/thrift/routing/routers.scala @@ -239,15 +239,13 @@ class ThriftRouter @Inject()(injector: Injector, exceptionManager: ExceptionMana controller: Controller, conf: Controller.ControllerConfig ): ThriftService = { - if (!conf.isValid) { - val expectStr = conf.methods.map(_.method.name).mkString("{,", ", ", "}") - val message = - s"${controller.getClass.getSimpleName} for service " + - s"${conf.gen.getClass.getSimpleName} is misconfigured. " + - s"Expected exactly one implementation for each of $expectStr but found:\n" + - conf.methods.map(m => s" - ${m.method.name}").mkString("\n") - error(message) - } + assert(conf.isValid, { + val expectStr = conf.gen.methods.map(_.name).mkString("{", ", ", "}") + val actualStr = conf.methods.map(_.method.name).mkString("{", ", ", "}") + s"${controller.getClass.getSimpleName} for service " + + s"${conf.gen.getClass.getSimpleName} is misconfigured. " + + s"Expected exactly one implementation for each of $expectStr but found $actualStr" + }) routes = conf.methods.map { cm => val method: ThriftMethod = cm.method diff --git a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/ControllerTest.scala b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/ControllerTest.scala index 7dee184429..54124aabae 100644 --- a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/ControllerTest.scala +++ b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/ControllerTest.scala @@ -89,15 +89,15 @@ class ControllerTest extends Test { Future.value(Response(req.args.msg)) } - intercept[IllegalStateException] { + intercept[AssertionError] { dsl.filtered(Filter.TypeAgnostic.Identity) { args: Echo.Args => Future.value(args.msg) } } - intercept[IllegalStateException] { + intercept[AssertionError] { dsl.withFn(fn) } - intercept[IllegalStateException] { + intercept[AssertionError] { dsl.withService(Service.mk(fn)) } true From 6237ff86718e6e91fd0a5174fa17718fa740005f Mon Sep 17 00:00:00 2001 From: Christopher Coco Date: Tue, 15 Jan 2019 18:13:42 +0000 Subject: [PATCH 05/45] finatra-jackson: Support inherited annotations in caseclass deserialization Problem The case class deserialization support does not properly find inherited Jackson annotations. This means that code like this: ``` trait MyTrait { @JsonProperty("differentName") def name: String } case class MyCaseClass(name: String) extends MyTrait ``` would not properly expect an incoming field with name `differentName` to parse into the `name` field. Solution Implement support for capturing annotations defined in the class ancestry. Result This fixes #437. JIRA Issues: CSL-5573 Differential Revision: https://phabricator.twitter.biz/D260376 --- CHANGELOG.rst | 19 ++ .../caseclass/jackson/CaseClassField.scala | 124 ++++++++--- .../caseclass/utils/AnnotationUtils.scala | 10 +- .../json/tests/FinatraObjectMapperTest.scala | 32 ++- .../jackson/CaseClassFieldTest.scala | 195 ++++++++++++++++++ .../caseclass/jackson/caseclasses.scala | 68 ++++++ ...pleCaseClasses.scala => caseclasses.scala} | 13 ++ .../finatra/validation/ValidatorTest.scala | 13 +- 8 files changed, 430 insertions(+), 44 deletions(-) create mode 100644 jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala rename jackson/src/test/scala/com/twitter/finatra/json/tests/internal/{ExampleCaseClasses.scala => caseclasses.scala} (97%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e679fa6771..72084ad216 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,6 +24,25 @@ Changed Fixed ~~~~~ +* finatra-jackson: Support inherited annotations in case class deserialization. Case class + deserialization support does not properly find inherited Jackson annotations. This means + that code like this: + + ``` + trait MyTrait { + @JsonProperty("differentName") + def name: String + } + case class MyCaseClass(name: String) extends MyTrait + ``` + + would not properly expect an incoming field with name `differentName` to parse into the + case class `name` field. This commit provides support for capturing inherited annotations + on case class fields. Annotations processed in order, thus if the same annotation appears + in the class hierarchy multiple times, the value configured on the class will win otherwise + will be in the order of trait linearization with the "last" declaration prevailing. + ``PHAB_ID=D260376`` + * finatra: Remove extraneous dependency on old `javax.servlet` ServletAPI dependency. The fixes #478. ``PHAB_ID=D259671`` diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala index d9c13bbbad..0e0fad9cfb 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala @@ -7,13 +7,14 @@ import com.fasterxml.jackson.databind.`type`.TypeFactory import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.node.TreeTraversingParser import com.fasterxml.jackson.databind.util.ClassUtil -import com.twitter.finatra.json.internal.caseclass.exceptions.CaseClassValidationException.PropertyPath import com.twitter.finatra.json.internal.caseclass.exceptions.{ CaseClassValidationException, FinatraJsonMappingException } -import com.twitter.finatra.json.internal.caseclass.reflection.CaseClassSigParser -import com.twitter.finatra.json.internal.caseclass.reflection.DefaultMethodUtils.defaultFunction +import com.twitter.finatra.json.internal.caseclass.reflection.{ + CaseClassSigParser, + DefaultMethodUtils +} import com.twitter.finatra.json.internal.caseclass.utils.AnnotationUtils._ import com.twitter.finatra.json.internal.caseclass.utils.FieldInjection import com.twitter.finatra.request.{FormParam, Header, QueryParam} @@ -21,6 +22,7 @@ import com.twitter.finatra.validation.ValidationResult._ import com.twitter.finatra.validation.{ErrorCode, Validation} import com.twitter.inject.Logging import com.twitter.inject.conversions.string._ +import com.twitter.util.{Return, Try} import java.lang.annotation.Annotation import scala.annotation.tailrec import scala.language.existentials @@ -33,56 +35,121 @@ private[finatra] object CaseClassField { namingStrategy: PropertyNamingStrategy, typeFactory: TypeFactory ): Seq[CaseClassField] = { - val allAnnotations = constructorAnnotations(clazz) val constructorParams = CaseClassSigParser.parseConstructorParams(clazz) assert( - allAnnotations.size == constructorParams.size, + clazz.getConstructors.head.getParameterCount == constructorParams.size, "Non-static inner 'case classes' not supported" ) + val annotationsMap: Map[String, Seq[Annotation]] = findAnnotations(clazz) + val companionObject = Class.forName(clazz.getName + "$").getField("MODULE$").get(null) val companionObjectClass = companionObject.getClass - for { - (constructorParam, idx) <- constructorParams.zipWithIndex - annotations = allAnnotations(idx) - name = jsonNameForField(annotations, namingStrategy, constructorParam.name) - deserializer = deserializerOrNone(annotations) - } yield { + for ((constructorParam, idx) <- constructorParams.zipWithIndex) yield { + val fieldAnnotations: Seq[Annotation] = annotationsMap.getOrElse(constructorParam.name, Nil) + val name = jsonNameForField(fieldAnnotations, namingStrategy, constructorParam.name) + val deserializer = deserializerOrNone(fieldAnnotations) + CaseClassField( name = name, javaType = JacksonTypes.javaType(typeFactory, constructorParam.scalaType), parentClass = clazz, - defaultFuncOpt = defaultFunction(companionObjectClass, companionObject, idx), - annotations = annotations, + defaultFuncOpt = + DefaultMethodUtils.defaultFunction(companionObjectClass, companionObject, idx), + annotations = fieldAnnotations, deserializer = deserializer ) } } - private[finatra] def constructorAnnotations(clazz: Class[_]): Seq[Array[Annotation]] = { - clazz.getConstructors.head.getParameterAnnotations.toSeq + /** Finds the sequence of Annotations per field in the clazz, keyed by field name */ + private[finatra] def findAnnotations(clazz: Class[_]): Map[String, Seq[Annotation]] = { + // for case classes, the annotations are only visible on the constructor. + val clazzAnnotationsArray: Array[Array[Annotation]] = + clazz.getConstructors.head.getParameterAnnotations + val clazzFields = clazz.getDeclaredFields + + val clazzAnnotations: Map[String, Seq[Annotation]] = (for { + (field, index) <- clazzFields.zipWithIndex + } yield { + Try(clazzAnnotationsArray.apply(index)) match { + case Return(annotations) if annotations.nonEmpty => + field.getName -> annotations.toSeq + case _ => + field.getName -> Nil + } + }).toMap + + val inheritedAnnotations: Map[String, Seq[Annotation]] = + findDeclaredMethodAnnotations(clazz, Map.empty[String, Seq[Annotation]]) + + // Merge the two maps: if the same annotation for a given field occurs in both lists, we keep + // the clazz annotation to in effect "override" what was specified by inheritance. That is, it + // is not expected that annotations are ever additive (in the sense that you can configure a + // single field through multiple declarations of the same annotation) but rather either-or. + clazzAnnotations.map { + case (field: String, annotations: Seq[Annotation]) => + val inherited: Seq[Annotation] = + inheritedAnnotations.getOrElse(field, Nil) + // want to prefer what is coming in from clazz annotations over inherited + field -> mergeAnnotationLists(annotations, inherited) + } } - private def jsonNameForField( + private[this] def findDeclaredMethodAnnotations( + clazz: Class[_], + found: Map[String, Seq[Annotation]] + ): Map[String, Seq[Annotation]] = { + // clazz declared method annotations + val interfaceDeclaredAnnotations: Map[String, Seq[Annotation]] = + clazz.getDeclaredMethods + .map { method => + method.getName -> method.getDeclaredAnnotations.toSeq + }.toMap.map { + case (key, values) => + key -> mergeAnnotationLists(values, found.getOrElse(key, Seq.empty[Annotation])) + } + + // interface declared method annotations + clazz.getInterfaces.foldLeft(interfaceDeclaredAnnotations) { + (acc: Map[String, Seq[Annotation]], interface: Class[_]) => + acc.map { + case (key, values) => + key -> mergeAnnotationLists( + values, + findDeclaredMethodAnnotations(interface, acc).getOrElse(key, Seq.empty[Annotation])) + } + } + } + + /** Prefer values in A over B */ + private[this] def mergeAnnotationLists( + a: Seq[Annotation], + b: Seq[Annotation] + ): Seq[Annotation] = { + a ++ b.filterNot(bAnnotation => a.exists(_.annotationType() == bAnnotation.annotationType())) + } + + private[this] def jsonNameForField( annotations: Seq[Annotation], namingStrategy: PropertyNamingStrategy, name: String ): String = { findAnnotation[JsonProperty](annotations) match { - case Some(jsonProperty) if jsonProperty.value.nonEmpty => jsonProperty.value + case Some(jsonProperty) if jsonProperty.value.nonEmpty => + jsonProperty.value case _ => - val decodedName = NameTransformer.decode(name) //decode unicode escaped field names - namingStrategy.nameForField( //apply json naming strategy (e.g. snake_case) + val decodedName = NameTransformer.decode(name) // decode unicode escaped field names + namingStrategy.nameForField( // apply json naming strategy (e.g. snake_case) /* config = */ null, /* field = */ null, - /* defaultName = */ decodedName - ) + /* defaultName = */ decodedName) } } - private def deserializerOrNone( - annotations: Array[Annotation] + private[this] def deserializerOrNone( + annotations: Seq[Annotation] ): Option[JsonDeserializer[Object]] = { for { jsonDeserializer <- findAnnotation[JsonDeserialize](annotations) @@ -98,8 +165,8 @@ private[finatra] case class CaseClassField( parentClass: Class[_], defaultFuncOpt: Option[() => Object], annotations: Seq[Annotation], - deserializer: Option[JsonDeserializer[Object]] -) extends Logging { + deserializer: Option[JsonDeserializer[Object]]) + extends Logging { private val isOption = javaType.getRawClass == classOf[Option[_]] private val isString = javaType.getRawClass == classOf[String] @@ -107,7 +174,7 @@ private[finatra] case class CaseClassField( private val fieldInjection = new FieldInjection(name, javaType, parentClass, annotations) private lazy val firstTypeParam = javaType.containedType(0) private lazy val requiredFieldException = CaseClassValidationException( - PropertyPath.leaf(attributeName), + CaseClassValidationException.PropertyPath.leaf(attributeName), Invalid(s"$attributeType is required", ErrorCode.RequiredFieldMissing) ) @@ -213,10 +280,7 @@ private[finatra] case class CaseClassField( private case class AttributeInfo(`type`: String, fieldName: String) @tailrec - private def findAttributeInfo( - fieldName: String, - annotations: Seq[Annotation] - ): AttributeInfo = { + private def findAttributeInfo(fieldName: String, annotations: Seq[Annotation]): AttributeInfo = { if (annotations.isEmpty) { AttributeInfo("field", fieldName) } else { diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/utils/AnnotationUtils.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/utils/AnnotationUtils.scala index a0a2e0baca..01509dbf8e 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/utils/AnnotationUtils.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/utils/AnnotationUtils.scala @@ -7,8 +7,8 @@ private[finatra] object AnnotationUtils { def filterIfAnnotationPresent[A <: Annotation: Manifest]( annotations: Seq[Annotation] ): Seq[Annotation] = { - annotations filter { annot => - isAnnotationPresent[A](annot) + annotations.filter { annotation => + isAnnotationPresent[A](annotation) } } @@ -16,7 +16,7 @@ private[finatra] object AnnotationUtils { filterSet: Set[Class[_ <: Annotation]], annotations: Seq[Annotation] ): Seq[Annotation] = { - annotations filter { annotation => + annotations.filter { annotation => filterSet.contains(annotation.annotationType) } } @@ -25,13 +25,13 @@ private[finatra] object AnnotationUtils { target: Class[_ <: Annotation], annotations: Seq[Annotation] ): Option[Annotation] = { - annotations find { annotation => + annotations.find { annotation => annotation.annotationType() == target } } def findAnnotation[A <: Annotation: Manifest](annotations: Seq[Annotation]): Option[A] = { - annotations collectFirst { + annotations.collectFirst { case annotation if annotationEquals[A](annotation) => annotation.asInstanceOf[A] } diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/FinatraObjectMapperTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/FinatraObjectMapperTest.scala index 087fc9bed1..c235795f8e 100644 --- a/jackson/src/test/scala/com/twitter/finatra/json/tests/FinatraObjectMapperTest.scala +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/FinatraObjectMapperTest.scala @@ -25,6 +25,7 @@ import com.twitter.finatra.json.tests.internal.Obj.{ } import com.twitter.finatra.json.tests.internal.TypeAndCompanion.NestedCaseClassInCompanion import com.twitter.finatra.json.tests.internal._ +import com.twitter.finatra.json.tests.internal.caseclass.jackson.Aum import com.twitter.finatra.json.tests.internal.internal.{ SimplePersonInPackageObject, SimplePersonInPackageObjectWithoutConstructorParams @@ -50,6 +51,19 @@ class FinatraObjectMapperTest extends Test with Logging { private[this] val injector = TestInjector(FinatraJacksonModule).create + test("JsonProperty#annotation inheritance") { + val aumJson = """{"i":1,"j":"J"}""" + val aum = parse[Aum](aumJson) + aum should equal(Aum(1, "J")) + mapper.writeValueAsString(Aum(1, "J")) should equal(aumJson) + + val testCaseClassJson = """{"fedoras":["felt","straw"],"oldness":27}""" + val testCaseClass = parse[CaseClassTraitImpl](testCaseClassJson) + testCaseClass should equal(CaseClassTraitImpl(Seq("felt", "straw"), 27)) + mapper.writeValueAsString(CaseClassTraitImpl(Seq("felt", "straw"), 27)) should equal( + testCaseClassJson) + } + test("simple tests#parse super simple") { val foo = parse[SimplePerson]("""{"name": "Steve"}""") foo should equal(SimplePerson("Steve")) @@ -356,9 +370,11 @@ class FinatraObjectMapperTest extends Test with Logging { } test("Jodatime#invalid DateTime") { - assertJsonParse[CaseClassWithDateTime]("""{ + assertJsonParse[CaseClassWithDateTime]( + """{ "date_time" : "" - }""", withErrors = Seq("""date_time: field cannot be empty""")) + }""", + withErrors = Seq("""date_time: field cannot be empty""")) } test("Jodatime#invalid DateTime's") { @@ -1089,15 +1105,19 @@ class FinatraObjectMapperTest extends Test with Logging { } test("case class with boolean as string") { - assertJsonParse[CaseClassWithBoolean](""" { + assertJsonParse[CaseClassWithBoolean]( + """ { "foo": "bar" - }""", withErrors = Seq("foo: 'bar' is not a valid Boolean")) + }""", + withErrors = Seq("foo: 'bar' is not a valid Boolean")) } test("case class with boolean number as string") { - assertJsonParse[CaseClassWithBoolean](""" { + assertJsonParse[CaseClassWithBoolean]( + """ { "foo": "1" - }""", withErrors = Seq("foo: '1' is not a valid Boolean")) + }""", + withErrors = Seq("foo: '1' is not a valid Boolean")) } val msgHiJsonStr = """{"msg":"hi"}""" diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/CaseClassFieldTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/CaseClassFieldTest.scala index 4a932e4c94..26d631d5b0 100644 --- a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/CaseClassFieldTest.scala +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/CaseClassFieldTest.scala @@ -1,13 +1,18 @@ package com.twitter.finatra.json.tests.internal.caseclass.jackson +import com.fasterxml.jackson.annotation.{JsonIgnore, JsonProperty} import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.`type`.TypeFactory +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.deser.std.NumberDeserializers.BigDecimalDeserializer import com.twitter.finatra.json.internal.caseclass.jackson.CaseClassField import com.twitter.finatra.json.tests.internal.{ WithEmptyJsonProperty, WithNonemptyJsonProperty, WithoutJsonPropertyAnnotation } +import com.twitter.finatra.request.{Header, QueryParam} +import com.twitter.finatra.validation.NotEmpty import com.twitter.inject.Test class CaseClassFieldTest extends Test { @@ -44,4 +49,194 @@ class CaseClassFieldTest extends Test { fields.length should equal(1) fields.head.name should equal("bar") } + + test("CaseClassField.createFields sees inherited JsonProperty annotation") { + val fields = CaseClassField.createFields( + classOf[Aum], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(2) + + val iField = fields.head + iField.name should equal("i") + iField.annotations.size should equal(1) + iField.annotations.head.annotationType() should be(classOf[JsonProperty]) + + val jField = fields.last + jField.name should equal("j") + jField.annotations.size should equal(1) + jField.annotations.head.annotationType() should be(classOf[JsonProperty]) + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 2") { + val fields = CaseClassField.createFields( + classOf[FooBar], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(1) + + val helloField = fields.head + helloField.name should equal("helloWorld") + helloField.annotations.size should equal(2) + helloField.annotations.head.annotationType() should be(classOf[JsonProperty]) + helloField.annotations.exists(_.annotationType() == classOf[Header]) should be(true) + helloField.annotations.last.asInstanceOf[Header].value() should be("accept") // from Bar + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 3") { + val fields = CaseClassField.createFields( + classOf[TestTraitImpl], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + /* + in trait: + --------- + @JsonProperty("oldness") + def age: Int + @NotEmpty + def name: String + + case class constructor: + ----------------------- + @JsonProperty("ageness") age: Int, // should override inherited annotation from trait + @Header name: String, // should have two annotations, one from trait and one here + @QueryParam dateTime: DateTime, + @JsonProperty foo: String, // empty JsonProperty should default to field name + @JsonDeserialize(contentAs = classOf[BigDecimal], using = classOf[BigDecimalDeserializer]) + double: BigDecimal, + @JsonIgnore ignoreMe: String + */ + fields.length should equal(6) + + val fieldMap: Map[String, CaseClassField] = + fields.map(field => field.name -> field).toMap + + val ageField = fieldMap("ageness") + ageField.annotations.size should equal(1) + ageField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + ageField.annotations.head.asInstanceOf[JsonProperty].value() should equal("ageness") + + val nameField = fieldMap("name") + nameField.annotations.size should equal(2) + nameField.annotations.exists(_.annotationType() == classOf[NotEmpty]) should be(true) + nameField.annotations.exists(_.annotationType() == classOf[Header]) should be(true) + + val dateTimeField = fieldMap("dateTime") + dateTimeField.annotations.size should equal(1) + dateTimeField.annotations.exists(_.annotationType() == classOf[QueryParam]) should be(true) + + val fooField = fieldMap("foo") + fooField.annotations.size should equal(1) + fooField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + fooField.annotations.head.asInstanceOf[JsonProperty].value() should equal("") + + val doubleField = fieldMap("double") + doubleField.annotations.size should equal(1) + doubleField.annotations.exists(_.annotationType() == classOf[JsonDeserialize]) should be(true) + doubleField.annotations.head.asInstanceOf[JsonDeserialize].contentAs() should be( + classOf[BigDecimal]) + doubleField.annotations.head.asInstanceOf[JsonDeserialize].using() should be( + classOf[BigDecimalDeserializer]) + doubleField.deserializer should not be None + + val ignoreMeField = fieldMap("ignoreMe") + ignoreMeField.annotations.size should equal(1) + ignoreMeField.annotations.exists(_.annotationType() == classOf[JsonIgnore]) should be(true) + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 4") { + val fields = CaseClassField.createFields( + classOf[FooBaz], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(1) + val helloField: CaseClassField = fields.head + helloField.annotations.size should equal(2) + helloField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + helloField.annotations.head.asInstanceOf[JsonProperty].value() should equal("goodbyeWorld") // from Baz + + helloField.annotations.exists(_.annotationType() == classOf[Header]) should be(true) + helloField.annotations.last.asInstanceOf[Header].value() should be("accept") // from Bar + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 5") { + val fields = CaseClassField.createFields( + classOf[FooBarBaz], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(1) + val helloField: CaseClassField = fields.head + helloField.annotations.size should equal(2) + helloField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + helloField.annotations.head.asInstanceOf[JsonProperty].value() should equal("goodbye") // from BarBaz + + helloField.annotations.exists(_.annotationType() == classOf[Header]) should be(true) + helloField.annotations.last.asInstanceOf[Header].value() should be("accept") // from Bar + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 6") { + val fields = CaseClassField.createFields( + classOf[File], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(1) + val uriField: CaseClassField = fields.head + uriField.annotations.size should equal(1) + uriField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + uriField.annotations.head.asInstanceOf[JsonProperty].value() should equal("file") + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 7") { + val fields = CaseClassField.createFields( + classOf[Folder], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(1) + val uriField: CaseClassField = fields.head + uriField.annotations.size should equal(1) + uriField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + uriField.annotations.head.asInstanceOf[JsonProperty].value() should equal("folder") + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 8") { + val fields = CaseClassField.createFields( + classOf[LoadableFile], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(1) + val uriField: CaseClassField = fields.head + uriField.annotations.size should equal(1) + uriField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + uriField.annotations.head.asInstanceOf[JsonProperty].value() should equal("file") + } + + test("CaseClassField.createFields sees inherited JsonProperty annotation 9") { + val fields = CaseClassField.createFields( + classOf[LoadableFolder], + PropertyNamingStrategy.LOWER_CAMEL_CASE, + TypeFactory.defaultInstance + ) + + fields.length should equal(1) + val uriField: CaseClassField = fields.head + uriField.annotations.size should equal(1) + uriField.annotations.exists(_.annotationType() == classOf[JsonProperty]) should be(true) + uriField.annotations.head.asInstanceOf[JsonProperty].value() should equal("folder") + } } diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala new file mode 100644 index 0000000000..4e2080b98f --- /dev/null +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala @@ -0,0 +1,68 @@ +package com.twitter.finatra.json.tests.internal.caseclass.jackson + +import com.fasterxml.jackson.annotation.{JsonIgnore, JsonProperty} +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.deser.std.NumberDeserializers.BigDecimalDeserializer +import com.twitter.finatra.request.{Header, QueryParam} +import com.twitter.finatra.response.JsonCamelCase +import com.twitter.finatra.validation.NotEmpty +import org.joda.time.DateTime + +/* Note: the decoder automatically changes "_i" to "i" for de/serialization: + * See CaseClassField#jsonNameForField */ +trait Aumly { @JsonProperty("i") def _i: Int; @JsonProperty("j") def _j: String } +case class Aum(_i: Int, _j: String) extends Aumly + +trait Bar { + @JsonProperty("helloWorld") @Header("accept") + def hello: String +} +case class FooBar(hello: String) extends Bar + +trait Baz extends Bar { + @JsonProperty("goodbyeWorld") + def hello: String +} +case class FooBaz(hello: String) extends Baz + +trait BarBaz { + @JsonProperty("goodbye") + def hello: String +} +case class FooBarBaz(hello: String) extends BarBaz with Bar // will end up with BarBaz @JsonProperty value as trait linearization is "right-to-left" + +trait Loadable { + @JsonProperty("url") + def uri: String +} +abstract class Resource { + @JsonProperty("resource") + def uri: String +} +case class File(@JsonProperty("file") uri : String) extends Resource +case class Folder(@JsonProperty("folder") uri : String) extends Resource + +abstract class LoadableResource extends Loadable { + @JsonProperty("resource") + override def uri: String +} +case class LoadableFile(@JsonProperty("file") uri : String) extends LoadableResource +case class LoadableFolder(@JsonProperty("folder") uri : String) extends LoadableResource + +trait TestTrait { + @JsonProperty("oldness") + def age: Int + @NotEmpty + def name: String +} +@JsonCamelCase +case class TestTraitImpl( + @JsonProperty("ageness") age: Int,// should override inherited annotation from trait + @Header name: String, // should have two annotations, one from trait and one here + @QueryParam dateTime: DateTime, + @JsonProperty foo: String, + @JsonDeserialize(contentAs = classOf[BigDecimal], using = classOf[BigDecimalDeserializer]) + double: BigDecimal, + @JsonIgnore ignoreMe: String +) extends TestTrait + diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/ExampleCaseClasses.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclasses.scala similarity index 97% rename from jackson/src/test/scala/com/twitter/finatra/json/tests/internal/ExampleCaseClasses.scala rename to jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclasses.scala index 80e999e055..1eab52fbe1 100644 --- a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/ExampleCaseClasses.scala +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclasses.scala @@ -362,6 +362,19 @@ case class WithoutJsonPropertyAnnotation(foo: String) case class NamingStrategyJsonProperty(@JsonProperty longFieldName: String) +trait CaseClassTrait { + @JsonProperty("fedoras") + @Size(min = 1, max = 2) + def names: Seq[String] + + @Min(1L) + def age: Int +} +case class CaseClassTraitImpl( + names: Seq[String], + @JsonProperty("oldness") age: Int +) extends CaseClassTrait + package object internal { case class SimplePersonInPackageObject( // not recommended but used here for testing use case diff --git a/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala b/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala index a21db2541d..24de57a325 100644 --- a/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala +++ b/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala @@ -23,10 +23,14 @@ class ValidatorTest extends Test { } def getValidationAnnotations(clazz: Class[_], paramName: String): Seq[Annotation] = { + val constructorParams = parseConstructorParams(clazz) + val annotations = findAnnotations(clazz) + for { - (param, annotations) <- parseConstructorParams(clazz).zip(constructorAnnotations(clazz)) + param <- constructorParams + paramAnnotations = annotations(param.name) if param.name.equals(paramName) - annotation <- annotations + annotation <- paramAnnotations if validationManager.isValidationAnnotation(annotation) } yield annotation } @@ -40,7 +44,10 @@ class ValidatorTest extends Test { findAnnotation(annotationClass, annotations) } - def findAnnotation[A <: Annotation](annotationClass: Class[A], annotations: Seq[Annotation]): A = { + def findAnnotation[A <: Annotation]( + annotationClass: Class[A], + annotations: Seq[Annotation] + ): A = { AnnotationUtils.findAnnotation(annotationClass, annotations) match { case Some(annotation) => annotation.asInstanceOf[A] From 8b448065f5f74c1eedd744bd15618cbf932ea1bc Mon Sep 17 00:00:00 2001 From: Yufan Gong Date: Tue, 15 Jan 2019 19:02:13 +0000 Subject: [PATCH 06/45] finatra-jackson: Pattern validation wrap up Problem We had a GitHub sync failure before, so we added a duplicated file `validation.properties` back when getting PR from GitHub; For invalid Regex (i.e. `([)`), `PattenValidator` throws the raw java exception. Solution Delete the duplicated file; before validating the value, validate the Regex itself and wrap the PatternSyntaxException to an `ErrorCode`; add documentation. JIRA Issues: CSL-7471 Differential Revision: https://phabricator.twitter.biz/D260302 --- .../sphinx/user-guide/json/validations.rst | 1 + .../com/twitter/json/validation.properties | 12 ----- .../validators/PatternValidator.scala | 53 ++++++++++++------- .../finatra/validation/ErrorCode.scala | 1 + .../validators/PatternValidatorTest.scala | 8 +-- 5 files changed, 39 insertions(+), 36 deletions(-) delete mode 100644 jackson/src/main/resources/com/twitter/json/validation.properties diff --git a/doc/src/sphinx/user-guide/json/validations.rst b/doc/src/sphinx/user-guide/json/validations.rst index 4761e42599..3bb51891ad 100644 --- a/doc/src/sphinx/user-guide/json/validations.rst +++ b/doc/src/sphinx/user-guide/json/validations.rst @@ -10,6 +10,7 @@ The validations framework integrates Finatra's custom `case class` deserializer - ``@CountryCode`` - ``@FutureTime`` - ``@PastTime`` +- ``@Pattern`` - ``@Max`` - ``@Min`` - ``@NotEmpty`` diff --git a/jackson/src/main/resources/com/twitter/json/validation.properties b/jackson/src/main/resources/com/twitter/json/validation.properties deleted file mode 100644 index e935dc88f4..0000000000 --- a/jackson/src/main/resources/com/twitter/json/validation.properties +++ /dev/null @@ -1,12 +0,0 @@ -com.twitter.finatra.json.internal.caseclass.validation.validators.CountryCodeInternal = [%s] is not a valid country code -com.twitter.finatra.json.internal.caseclass.validation.validators.FutureTimeInternal = [%s] is not in the future -com.twitter.finatra.json.internal.caseclass.validation.validators.MinInternal = [%s] is not greater than or equal to %s -com.twitter.finatra.json.internal.caseclass.validation.validators.MaxInternal = [%s] is not less than or equal to %s -com.twitter.finatra.json.internal.caseclass.validation.validators.NotEmptyInternal = cannot be empty -com.twitter.finatra.json.internal.caseclass.validation.validators.OneOfInternal = [%s] is not one of [%s] -com.twitter.finatra.json.internal.caseclass.validation.validators.PastTimeInternal = [%s] is not in the past -com.twitter.finatra.json.internal.caseclass.validation.validators.RangeInternal = [%s] is not between %s and %s -com.twitter.finatra.json.internal.caseclass.validation.validators.SizeInternal = size [%s] is not between %s and %s -com.twitter.finatra.json.internal.caseclass.validation.validators.TimeGranularityInternal = [%s] is not %s granularity -com.twitter.finatra.json.internal.caseclass.validation.validators.UUIDInternal = [%s] is not a valid UUID -com.twitter.finatra.json.internal.caseclass.validation.validators.PatternInternal = [%s] does not match regex %s diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala index e989d8e26c..2d245cba1b 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/validation/validators/PatternValidator.scala @@ -1,16 +1,15 @@ package com.twitter.finatra.json.internal.caseclass.validation.validators import com.twitter.finatra.json.internal.caseclass.validation.validators.PatternValidator._ +import com.twitter.finatra.validation.ValidationResult.{Invalid, Valid} import com.twitter.finatra.validation._ +import com.twitter.util.{Return, Throw, Try} +import scala.util.matching.Regex private[finatra] object PatternValidator { def errorMessage(resolver: ValidationMessageResolver, value: Any, regex: String): String = { resolver.resolve(classOf[Pattern], value, regex) } - - def errorMessage(resolver: ValidationMessageResolver): String = { - resolver.resolve(classOf[Pattern]) - } } /** @@ -26,29 +25,32 @@ private[finatra] class PatternValidator( extends Validator[Pattern, Any](validationMessageResolver, annotation) { private val regexp: String = annotation.regexp() - private val regex = regexp.r + private val regex: Try[Regex] = Try(regexp.r) /* Public */ override def isValid(value: Any): ValidationResult = { - value match { - case arrayValue: Array[_] => - validationResult(arrayValue) - case traversableValue: Traversable[_] => - validationResult(traversableValue) - case stringValue: String => - validationResult(stringValue) - case _ => - throw new IllegalArgumentException( - s"Class [${value.getClass}}] is not supported by ${this.getClass}") - } + val validateRegexResult = validateRegex + if (validateRegexResult.isValid) { + value match { + case arrayValue: Array[_] => + validationResult(arrayValue) + case traversableValue: Traversable[_] => + validationResult(traversableValue) + case stringValue: String => + validationResult(stringValue) + case _ => + throw new IllegalArgumentException( + s"Class [${value.getClass}}] is not supported by ${this.getClass}") + } + } else validateRegexResult } /* Private */ private def validationResult(value: Traversable[_]): ValidationResult = { ValidationResult.validate( - value.forall(x => validate(x.toString)), + value.forall(x => validateValue(x.toString)), errorMessage(validationMessageResolver, value, regexp), errorCode(value, regexp) ) @@ -56,19 +58,30 @@ private[finatra] class PatternValidator( private def validationResult(value: String): ValidationResult = { ValidationResult.validate( - validate(value), + validateValue(value), errorMessage(validationMessageResolver, value, regexp), errorCode(value, regexp) ) } - private def validate(value: String): Boolean = { - regex.findFirstIn(value) match { + // validate the value after validate the regex + private def validateValue(value: String): Boolean = { + regex.get().findFirstIn(value) match { case None => false case _ => true } } + private def validateRegex: ValidationResult = + regex match { + case Return(_) => Valid + case Throw(ex) => Invalid(ex.getClass.getName, errorCode(ex, regexp)) + } + + private def errorCode(t: Throwable, regex: String) = { + ErrorCode.PatternSyntaxError(t.getMessage, regex) + } + private def errorCode(value: String, regex: String) = { ErrorCode.PatternNotMatched(value, regex) } diff --git a/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala b/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala index 939b413d49..e6d1f3e428 100644 --- a/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala +++ b/jackson/src/main/scala/com/twitter/finatra/validation/ErrorCode.scala @@ -26,4 +26,5 @@ object ErrorCode { case class ValueTooLarge(maxValue: Long, value: Number) extends ErrorCode case class ValueTooSmall(minValue: Long, value: Number) extends ErrorCode case class PatternNotMatched(value: String, regex: String) extends ErrorCode + case class PatternSyntaxError(message: String, regex: String) extends ErrorCode } diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala index d1b8f3e896..19b3d02078 100644 --- a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/validation/validators/PatternValidatorTest.scala @@ -58,11 +58,11 @@ class PatternValidatorTest extends ValidatorTest with GeneratorDrivenPropertyChe } test("fail validation when regex is invalid") { - intercept[Exception] { - validate[InvalidPatternExample](value = "123") should equal( - Invalid(errorMessage("123", "([)"), ErrorCode.PatternNotMatched("123", "([)")) + validate[InvalidPatternExample](value = "123") should equal( + Invalid("java.util.regex.PatternSyntaxException", + ErrorCode.PatternSyntaxError("Unclosed character class near index 2\n([)\n ^", "([)") ) - } + ) } private def validate[C: Manifest](value: Any): ValidationResult = { From e3426fb98a320b2bf9bd48ef62843337e9a928e4 Mon Sep 17 00:00:00 2001 From: Julio Ng Date: Wed, 16 Jan 2019 00:50:54 +0000 Subject: [PATCH 07/45] finatra-kafka: Add lookupBootstrapServers function that takes timeout as a parameter Problem The DNS resolution inside lookupBootstrapServers could take a long time to return Solution Allow the caller to specified a maximum timeout duration for the lookupBootstrapServers function Result Callers can specify a timeout for lookupBootstrapServers Differential Revision: https://phabricator.twitter.biz/D256997 --- CHANGELOG.rst | 3 ++ .../kafka/utils/BootstrapServerUtils.scala | 26 +++++++++--- .../kafka/test/BootstrapServerUtilsTest.scala | 41 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 72084ad216..7f16949cfe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,9 @@ Added Changed ~~~~~~~ +* finatra-kafka: Add lookupBootstrapServers function that takes timeout as a parameter. + ``PHAB_ID=D256997`` + * finatra-thrift: If a Controller is not configured with exactly one endpoint per method, it will throw an AssertionError instead of logging an error message. An attempt to use non-legacy functionality with a legacy Controller will throw diff --git a/kafka/src/main/scala/com/twitter/finatra/kafka/utils/BootstrapServerUtils.scala b/kafka/src/main/scala/com/twitter/finatra/kafka/utils/BootstrapServerUtils.scala index a6de38a8a4..b641bc3463 100644 --- a/kafka/src/main/scala/com/twitter/finatra/kafka/utils/BootstrapServerUtils.scala +++ b/kafka/src/main/scala/com/twitter/finatra/kafka/utils/BootstrapServerUtils.scala @@ -4,20 +4,36 @@ import com.twitter.finagle.Addr.{Bound, Failed, Neg, Pending} import com.twitter.finagle.Address.Inet import com.twitter.finagle.{Addr, Address, Namer} import com.twitter.inject.Logging -import com.twitter.util.{Await, Promise, Witness} +import com.twitter.util.{Await, Duration, Promise, Witness} import java.net.InetSocketAddress + object BootstrapServerUtils extends Logging { - def lookupBootstrapServers(dest: String): String = { + /** + * Translates the dest path into a list of servers that can be used to initialize a Kafka + * producer or consumer. It uses [[com.twitter.util.Duration.Top]] as the timeout, effectively + * waiting infinitely for the name resolution. + * @param dest The path to translate. + * @return A comma separated list of server addresses. + */ + def lookupBootstrapServers(dest: String): String = lookupBootstrapServers(dest, Duration.Top) + + /** + * Translates the dest path into a list of servers that can be used to initialize a Kafka + * producer or consumer using the specified timeout. + * @param dest The path to translate. + * @param timeout The maximum timeout for the name resolution. + * @return A comma separated list of server addresses. + */ + def lookupBootstrapServers(dest: String, timeout: Duration): String = { if (!dest.startsWith("/")) { info(s"Resolved Kafka Dest = $dest") dest } else { info(s"Resolving Kafka Bootstrap Servers: $dest") val promise = new Promise[Seq[InetSocketAddress]]() - val resolveResult = Namer - .resolve(dest).changes + val resolveResult = Namer.resolve(dest).changes .register(new Witness[Addr] { override def notify(note: Addr): Unit = note match { case Pending => @@ -32,7 +48,7 @@ object BootstrapServerUtils extends Logging { } }) - val socketAddress = Await.result(promise) + val socketAddress = Await.result(promise, timeout) resolveResult.close() val servers = socketAddress.take(5).map(a => s"${a.getAddress.getHostAddress}:${a.getPort}").mkString(",") diff --git a/kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala b/kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala new file mode 100644 index 0000000000..0a2da3d2d5 --- /dev/null +++ b/kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala @@ -0,0 +1,41 @@ +package com.twitter.finatra.kafka.test + +import com.twitter.conversions.time._ +import com.twitter.finagle.{Addr, Dtab, Name, NameTree, Path} +import com.twitter.finatra.kafka.{utils => KafkaUtils} +import com.twitter.inject.Test +import com.twitter.util.{Activity, Duration, TimeoutException, Var} +import com.twitter.finagle.naming.{DefaultInterpreter, NameInterpreter} + + +class BootstrapServerUtilsTest extends Test { + + override protected def afterEach(): Unit = { + NameInterpreter.global = DefaultInterpreter + } + + test("lookup success") { + KafkaUtils.BootstrapServerUtils.lookupBootstrapServers( + "/$/inet/localhost/88", Duration.Top) should equal("127.0.0.1:88") + } + + test("lookup with timeout") { + val testingPath: String = "/s/kafka/cluster" + + // Bind the testing path to a pending address, so the name resolution will time out + NameInterpreter.global = new NameInterpreter { + override def bind(dtab: Dtab, path: Path): Activity[NameTree[Name.Bound]] = { + if (path.equals(Path.read(testingPath))) { + Activity.value(NameTree.Leaf(Name.Bound(Var.value(Addr.Pending), new Object()))) + } else { + DefaultInterpreter.bind(dtab, path) + } + } + } + + val ex = the[TimeoutException] thrownBy { + KafkaUtils.BootstrapServerUtils.lookupBootstrapServers(testingPath, 10.milliseconds) + } + ex.getMessage should equal("10.milliseconds") + } +} From 50184f1ba1876dc4465ec0df5d8847692e87a0e5 Mon Sep 17 00:00:00 2001 From: Jordan Parker Date: Thu, 17 Jan 2019 21:04:59 +0000 Subject: [PATCH 08/45] finatra-thrift: DarkTrafficFilter docs + return type fix Problem/Solution DarkTrafficFilter could use a bit more documentation, and a return type was not correct. This addresses that. Differential Revision: https://phabricator.twitter.biz/D261868 --- CHANGELOG.rst | 3 +++ .../twitter/finatra/thrift/Controller.scala | 11 ++++++++- .../thrift/modules/darktrafficmodules.scala | 23 +++++++++++++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7f16949cfe..e4366fbae3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,9 @@ Added Changed ~~~~~~~ +* finatra-thrift: The return type of `ReqRepDarkTrafficFilterModule#newFilter` has been changed from + `DarkTrafficFilter[MethodIface]` to `Filter.TypeAgnostic`. ``PHAB_ID=D261868`` + * finatra-kafka: Add lookupBootstrapServers function that takes timeout as a parameter. ``PHAB_ID=D256997`` diff --git a/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala b/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala index bc8031a7d0..d88d319e18 100644 --- a/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala +++ b/thrift/src/main/scala/com/twitter/finatra/thrift/Controller.scala @@ -67,6 +67,8 @@ abstract class Controller private (val config: Controller.Config) extends Loggin /** * Provide an implementation for this method in the form of a [[com.twitter.finagle.Service]] * + * @note The service will be called for each request. + * * @param svc the service to use as an implementation */ def withService(svc: Service[Request[M#Args], Response[M#SuccessType]]): Unit = nonLegacy { cc => @@ -75,7 +77,9 @@ abstract class Controller private (val config: Controller.Config) extends Loggin /** * Provide an implementation for this method in the form of a function of - * Request => Future[Response] + * Request => Future[Response]. + * + * @note The given function will be invoked for each request. * * @param fn the function to use */ @@ -88,6 +92,8 @@ abstract class Controller private (val config: Controller.Config) extends Loggin * This exists for legacy compatibility reasons. Users should instead use Request/Response * based functionality. * + * @note The implementation given will be invoked for each request. + * * @param f the implementation * @return a ThriftMethodService, which is used in legacy controller configurations */ @@ -119,7 +125,10 @@ abstract class Controller private (val config: Controller.Config) extends Loggin * implementation. All thrift methods that a ThriftSerivce handles must be registered using * this method to properly construct a Controller. * + * @note The provided implementation will be invoked for each request. + * * @param m The thrift method to handle. + * */ protected def handle[M <: ThriftMethod](m: M) = new MethodDSL[M](m, Filter.TypeAgnostic.Identity) } diff --git a/thrift/src/main/scala/com/twitter/finatra/thrift/modules/darktrafficmodules.scala b/thrift/src/main/scala/com/twitter/finatra/thrift/modules/darktrafficmodules.scala index d1a5ecbf90..58bf694006 100644 --- a/thrift/src/main/scala/com/twitter/finatra/thrift/modules/darktrafficmodules.scala +++ b/thrift/src/main/scala/com/twitter/finatra/thrift/modules/darktrafficmodules.scala @@ -86,7 +86,13 @@ private[modules] abstract class AbstractDarkTrafficFilterModule } /** - * A [[TwitterModule]] which configures and binds a [[DarkTrafficFilter]] to the object graph. + * A [[TwitterModule]] which configures and binds a [[DarkTrafficFilter]] to the object graph, for + * use with [[Controllers]] constructed using the legacy method. + * + * @note This [[DarkTrafficFilter]] module is to be used with [[Controllers]] which are constructed using + * the deprecated method of extending the `BaseServiceIface` of the generated Thrift service. + * For services that construct their Controllers by extending + * `Controller(GeneratedThriftService)`, use the [[ReqRepDarkTrafficFilter]] instead * * @note This is only applicable in Scala as it uses generated Scala classes and expects to configure * the [[DarkTrafficFilter]] over a [[com.twitter.finagle.Service]] that is generated from @@ -122,6 +128,19 @@ abstract class DarkTrafficFilterModule[ServiceIface <: Filterable[ServiceIface]: } } +/** + * A [[TwitterModule]] which configures and binds a [[DarkTrafficFilter]] to the object graph. + * + * @note This [[DarkTrafficFilter]] module is to be used with [[Controllers]] which are constructed by + * extending `Controller(GeneratedThriftService)`. For Controllers that are constructed using + * the deprecated method of extending `Controller with GeneratedThriftService.BaseServiceIface`, + * Use the [[DarkTrafficFilterModule]] above. + * + * @note This is only applicable in Scala as it uses generated Scala classes and expects to configure + * the [[DarkTrafficFilter]] over a [[com.twitter.finagle.Service]] that is generated from + * Finagle via generated Scala code. Users of generated Java code should use the + * [[JavaDarkTrafficFilterModule]]. + */ abstract class ReqRepDarkTrafficFilterModule[MethodIface <: Filterable[MethodIface]: ClassTag]( implicit serviceBuilder: ReqRepServicePerEndpointBuilder[MethodIface] ) extends AbstractDarkTrafficFilterModule { @@ -140,7 +159,7 @@ abstract class ReqRepDarkTrafficFilterModule[MethodIface <: Filterable[MethodIfa client: ThriftMux.Client, injector: Injector, stats: StatsReceiver - ): DarkTrafficFilter[MethodIface] = { + ): Filter.TypeAgnostic = { new DarkTrafficFilter[MethodIface]( client.servicePerEndpoint[MethodIface](dest, label), enableSampling(injector), From e752f0430eb4d2cf4a952dd5719934aa4eb5114a Mon Sep 17 00:00:00 2001 From: Yufan Gong Date: Thu, 17 Jan 2019 22:34:30 +0000 Subject: [PATCH 09/45] finagle/finatra: More documentation for ResponseClassification Problem/Solution # Added doc about the default ResponseClassifier for Finatra servers. # Explained that ResponseClassificationSyntheticException is applied for bookkeeping when use ThriftExceptionAsFailure; # Added doc that thrift Per-endpoint StatsFilter can record the specific thriftExceptions when enabled in RichClientParam/RichServerParam. JIRA Issues: CSL-7432 Differential Revision: https://phabricator.twitter.biz/D261148 --- doc/src/sphinx/user-guide/http/server.rst | 3 ++- doc/src/sphinx/user-guide/thrift/server.rst | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/src/sphinx/user-guide/http/server.rst b/doc/src/sphinx/user-guide/http/server.rst index fb351fb4e3..edfb4d6674 100644 --- a/doc/src/sphinx/user-guide/http/server.rst +++ b/doc/src/sphinx/user-guide/http/server.rst @@ -264,7 +264,8 @@ specifically the server documentation `here `__ you could choose to +The default Response Classifier for HTTP servers is `HttpResponseClassifier.ServerErrorsAsFailures `__, +which classifies any HTTP 5xx response code as a failure. To configure server-side `Response Classification `__ you could choose to set the classifier directly on the underlying Finagle server by overriding the `configureHttpServer` (or `configureHttpsServer`) in your server, e.g., .. code:: scala diff --git a/doc/src/sphinx/user-guide/thrift/server.rst b/doc/src/sphinx/user-guide/thrift/server.rst index aa9eac2442..efff4c199f 100644 --- a/doc/src/sphinx/user-guide/thrift/server.rst +++ b/doc/src/sphinx/user-guide/thrift/server.rst @@ -237,7 +237,8 @@ the server documentation `here `__ +The default Response Classifier for Thrift servers is `ThriftResponseClassifier.ThriftExceptionsAsFailures `__, +which classifies any deserialized Thrift Exception as a failure. To configure server-side `Response Classification `__ you could choose to set the classifier directly on the underlying Finagle server by overriding the `configureThriftServer` in your server, e.g., From 9aa12b8db05360853443cb60d975423e8af8048b Mon Sep 17 00:00:00 2001 From: Steve Cosenza Date: Tue, 22 Jan 2019 08:18:31 +0000 Subject: [PATCH 10/45] finatra-kafka-streams: Improve watermark propagation Problem We identified two areas where watermark assignment/propagation could be improved: 1) The FinatraTransformerV2 watermark is not immediately populated upon receiving the first message which could result in a watermark value of 0 being returned from FinatraTransformerV2#watermark if the watermark method is called before the first auto.watermark.interval passes. 2) FinatraTransformerV2 was not "emitting" the latest watermark on commit boundaries. This could be problematic if you mixed the CachingKeyValueStores trait into your FinatraTransformer and expected the watermark value exposed to the flush listener to be the latest available assigned watermark. Solution Populate FinatraTransformerV2#watermark on receiving the first message and ensure FinatraTransformerV2#onFlush is used to check for the most recent watermark before flushing entries from a CachingKeyValueStore. Result FinatraTransformerV2#watermark returns a more accurate watermark in more cases. JIRA Issues: DINS-2574 Differential Revision: https://phabricator.twitter.biz/D262054 --- CHANGELOG.rst | 3 + .../processors/internal/Flushing.scala | 6 +- .../transformer/CachingKeyValueStores.scala | 1 + .../transformer/FinatraTransformerV2.scala | 17 +- .../transformer/domain/Watermark.scala | 4 + .../transformer/internal/OnFlush.scala | 10 ++ .../internal/WatermarkManager.scala | 32 +++- .../twitter/finatra/streams/transformer/BUILD | 12 ++ .../FinatraTransformerV2Test.scala | 156 ++++++++++++++++++ .../test/scala/com/twitter/unittests/BUILD | 1 - 10 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnFlush.scala create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2Test.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4366fbae3..bca6fa2e2a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -30,6 +30,9 @@ Changed Fixed ~~~~~ +* finatra-kafka-streams: Improve watermark assignment/propagation upon reading the first + message and when caching key value stores are used. ``PHAB_ID=D262054`` + * finatra-jackson: Support inherited annotations in case class deserialization. Case class deserialization support does not properly find inherited Jackson annotations. This means that code like this: diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/Flushing.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/Flushing.scala index 2063b122cf..a3e7492d16 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/Flushing.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/Flushing.scala @@ -1,19 +1,17 @@ package com.twitter.finatra.kafkastreams.processors.internal import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging -import com.twitter.finatra.streams.transformer.internal.{OnClose, OnInit} +import com.twitter.finatra.streams.transformer.internal.{OnClose, OnFlush, OnInit} import com.twitter.util.Duration import org.apache.kafka.streams.StreamsConfig import org.apache.kafka.streams.processor.{Cancellable, PunctuationType, Punctuator} -trait Flushing extends OnInit with OnClose with ProcessorContextLogging { +trait Flushing extends OnInit with OnClose with OnFlush with ProcessorContextLogging { @volatile private var commitPunctuatorCancellable: Cancellable = _ protected def commitInterval: Duration - protected def onFlush(): Unit = {} - //TODO: Create and use frameworkOnInit for framework use override def onInit(): Unit = { super.onInit() diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CachingKeyValueStores.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CachingKeyValueStores.scala index 615bcb8a73..51ddddb53f 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CachingKeyValueStores.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CachingKeyValueStores.scala @@ -18,6 +18,7 @@ trait CachingKeyValueStores[K, V, K1, V1] extends FlushingTransformer[K, V, K1, protected def finatraKeyValueStoresMap: mutable.Map[String, FinatraKeyValueStore[_, _]] override def onFlush(): Unit = { + super.onFlush() finatraKeyValueStoresMap.values.foreach(_.flush()) } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala index 4adf00758c..41324c56c5 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala @@ -12,7 +12,7 @@ import com.twitter.finatra.streams.stores.internal.{ } import com.twitter.finatra.streams.transformer.FinatraTransformer.TimerTime import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} -import com.twitter.finatra.streams.transformer.internal.{OnClose, OnInit} +import com.twitter.finatra.streams.transformer.internal.{OnClose, OnFlush, OnInit} import com.twitter.finatra.streams.transformer.watermarks.internal.WatermarkManager import com.twitter.finatra.streams.transformer.watermarks.{ DefaultWatermarkAssignor, @@ -56,17 +56,17 @@ abstract class FinatraTransformerV2[InputKey, InputValue, OutputKey, OutputValue with OnInit with OnWatermark with OnClose + with OnFlush with ProcessorContextLogging { protected[streams] val finatraKeyValueStoresMap: mutable.Map[String, FinatraKeyValueStore[_, _]] = scala.collection.mutable.Map[String, FinatraKeyValueStore[_, _]]() - private var watermarkManager: WatermarkManager[InputKey, InputValue] = _ - /* Private Mutable */ @volatile private var _context: ProcessorContext = _ @volatile private var watermarkTimerCancellable: Cancellable = _ + @volatile private var watermarkManager: WatermarkManager[InputKey, InputValue] = _ /* Abstract */ @@ -88,6 +88,8 @@ abstract class FinatraTransformerV2[InputKey, InputValue, OutputKey, OutputValue _context = processorContext watermarkManager = new WatermarkManager[InputKey, InputValue]( + taskId = processorContext.taskId(), + transformerName = this.getClass.getSimpleName, onWatermark = this, watermarkAssignor = watermarkAssignor, emitWatermarkPerMessage = shouldEmitWatermarkPerMessage(_context)) @@ -112,6 +114,11 @@ abstract class FinatraTransformerV2[InputKey, InputValue, OutputKey, OutputValue onInit() } + override def onFlush(): Unit = { + super.onFlush() + watermarkManager.callOnWatermarkIfChanged() + } + override def onWatermark(watermark: Watermark): Unit = { trace(s"onWatermark $watermark") } @@ -122,8 +129,8 @@ abstract class FinatraTransformerV2[InputKey, InputValue, OutputKey, OutputValue can cause context.timestamp to be mutated to the forwarded message timestamp :-( */ val messageTime = Time(_context.timestamp()) - debug(s"onMessage $watermark MessageTime(${messageTime.millis.iso8601Millis}) $k -> $v") watermarkManager.onMessage(messageTime, _context.topic(), k, v) + debug(s"onMessage LastEmitted $watermark MessageTime $messageTime $k -> $v") onMessage(messageTime, k, v) null } @@ -175,7 +182,7 @@ abstract class FinatraTransformerV2[InputKey, InputValue, OutputKey, OutputValue _context.forward(key, value, To.all().withTimestamp(timestamp)) } - final protected def watermark: Watermark = { + final protected[finatra] def watermark: Watermark = { watermarkManager.watermark } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Watermark.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Watermark.scala index 3357d136ea..2f8993a19f 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Watermark.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Watermark.scala @@ -2,6 +2,10 @@ package com.twitter.finatra.streams.transformer.domain import com.twitter.finatra.streams.converters.time._ +object Watermark { + val unknown: Watermark = Watermark(0L) +} + case class Watermark(timeMillis: Long) extends AnyVal { override def toString: String = { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnFlush.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnFlush.scala new file mode 100644 index 0000000000..95904b90ae --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnFlush.scala @@ -0,0 +1,10 @@ +package com.twitter.finatra.streams.transformer.internal + +trait OnFlush { + + /** + * Callback method for when you should flush any cached data. + * This method is typically called prior to a Kafka commit + */ + protected def onFlush(): Unit = {} +} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/internal/WatermarkManager.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/internal/WatermarkManager.scala index 669080f36c..ab71f4e4e4 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/internal/WatermarkManager.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/internal/WatermarkManager.scala @@ -4,14 +4,31 @@ import com.twitter.finatra.streams.transformer.OnWatermark import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} import com.twitter.finatra.streams.transformer.watermarks.WatermarkAssignor import com.twitter.inject.Logging - +import org.apache.kafka.streams.processor.TaskId + +/** + * WatermarkManager coordinates with a Transformers WatermarkAssignor to keep track of the latest assigned + * watermark and the last emitted watermark. + * + * @param taskId TaskId of the FinatraTransformer being managed used for internal logging + * @param transformerName Transformer name of the FinatraTransformer being managed used for internal logging + * @param onWatermark OnWatermark callback which is called when a new watermark is emitted + * @param watermarkAssignor The WatermarkAssignor used in the FinatraTransformer being managed. + * @param emitWatermarkPerMessage Whether to check if a new watermark needs to be emitted after each + * message is read in onMessage. If false, callOnWatermarkIfChanged must + * be called to check if a new watermark is to be emitted. + * @tparam K Message key for the FinatraTransformer being managed + * @tparam V Message value for the FinatraTransformer being managed + */ class WatermarkManager[K, V]( + taskId: TaskId, + transformerName: String, onWatermark: OnWatermark, watermarkAssignor: WatermarkAssignor[K, V], emitWatermarkPerMessage: Boolean) extends Logging { - @volatile private var lastEmittedWatermark = Watermark(0L) + @volatile private var lastEmittedWatermark = Watermark.unknown /* Public */ @@ -26,16 +43,17 @@ class WatermarkManager[K, V]( def onMessage(messageTime: Time, topic: String, key: K, value: V): Unit = { watermarkAssignor.onMessage(topic = topic, timestamp = messageTime, key = key, value = value) - if (emitWatermarkPerMessage) { + if (lastEmittedWatermark == Watermark.unknown || emitWatermarkPerMessage) { callOnWatermarkIfChanged() } } def callOnWatermarkIfChanged(): Unit = { - val currentWatermark = watermarkAssignor.getWatermark - if (currentWatermark.timeMillis > lastEmittedWatermark.timeMillis) { - onWatermark.onWatermark(currentWatermark) - setLastEmittedWatermark(currentWatermark) + val latestAssignedWatermark = watermarkAssignor.getWatermark + trace(s"callOnWatermarkIfChanged $transformerName $taskId $latestAssignedWatermark") + if (latestAssignedWatermark.timeMillis > lastEmittedWatermark.timeMillis) { + onWatermark.onWatermark(latestAssignedWatermark) + setLastEmittedWatermark(latestAssignedWatermark) } } diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD new file mode 100644 index 0000000000..d00f8ba06f --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD @@ -0,0 +1,12 @@ +junit_tests( + sources = rglobs("*.scala"), + compiler_option_sets = {"fatal_warnings"}, + strict_deps = False, + dependencies = [ + "3rdparty/jvm/ch/qos/logback:logback-classic", + "finatra/kafka-streams/kafka-streams/src/main/scala", + "finatra/kafka-streams/kafka-streams/src/test/resources", + "finatra/kafka-streams/kafka-streams/src/test/scala/com/twitter:test-deps", + "finatra/kafka/src/test/scala:test-deps", + ], +) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2Test.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2Test.scala new file mode 100644 index 0000000000..2143423e29 --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2Test.scala @@ -0,0 +1,156 @@ +package com.twitter.finatra.streams.transformer + +import com.twitter.conversions.DurationOps._ +import com.twitter.finagle.stats.{NullStatsReceiver, StatsReceiver} +import com.twitter.finatra.kafkastreams.config.KafkaStreamsConfig +import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} +import com.twitter.inject.Test +import com.twitter.util.Duration +import org.apache.kafka.common.serialization.Serdes +import org.apache.kafka.streams.StreamsConfig +import org.apache.kafka.streams.processor._ +import org.apache.kafka.streams.processor.internals.{RecordCollector, ToInternal} +import org.apache.kafka.streams.state.Stores +import org.apache.kafka.test.{InternalMockProcessorContext, NoOpRecordCollector, TestUtils} +import org.hamcrest.{BaseMatcher, Description} +import org.mockito.{Matchers, Mockito} + +class FinatraTransformerV2Test extends Test with com.twitter.inject.Mockito { + val firstMessageTimestamp = 100000 + val firstKey = "key1" + val firstValue = "value1" + + val secondMessageTimestamp = 200000 + val secondKey = "key2" + val secondValue = "value2" + + test("watermark processing when forwarding from onMessage") { + val transformer = + new FinatraTransformerV2[String, String, String, String](NullStatsReceiver) { + override def onMessage(messageTime: Time, key: String, value: String): Unit = { + forward(key, value, watermark.timeMillis) + } + } + + val context = smartMock[ProcessorContext] + context.taskId() returns new TaskId(0, 0) + context.timestamp returns firstMessageTimestamp + + transformer.init(context) + transformer.transform(firstKey, firstValue) + transformer.watermark should be(Watermark(firstMessageTimestamp - 1)) + assertForwardedMessage(context, firstKey, firstValue, firstMessageTimestamp) + + context.timestamp returns secondMessageTimestamp + transformer.transform(secondKey, secondValue) + transformer.watermark should be(Watermark(firstMessageTimestamp - 1)) + assertForwardedMessage(context, secondKey, secondValue, firstMessageTimestamp) + + transformer.onFlush() + transformer.watermark should be(Watermark(secondMessageTimestamp - 1)) + } + + test("watermark processing when forwarding from caching flush listener") { + val transformer = + new FinatraTransformerV2[String, String, String, String](NullStatsReceiver) + with CachingKeyValueStores[String, String, String, String] { + private val cache = getCachingKeyValueStore[String, String]("mystore") + + override def statsReceiver: StatsReceiver = NullStatsReceiver + override def commitInterval: Duration = 1.second + + override def onInit(): Unit = { + super.onInit() + cache.registerFlushListener(onFlushed) + } + + override def onMessage(messageTime: Time, key: String, value: String): Unit = { + cache.put(key, value) + } + + private def onFlushed(key: String, value: String): Unit = { + forward(key = key, value = value, timestamp = watermark.timeMillis) + } + } + + val context = Mockito.spy(new FinatraMockProcessorContext) + transformer.init(context) + + context.setTime(firstMessageTimestamp) + transformer.transform(firstKey, firstValue) + + context.setTime(secondMessageTimestamp) + transformer.transform(secondKey, secondValue) + + transformer.onFlush() + assertForwardedMessage(context, firstKey, firstValue, secondMessageTimestamp) + assertForwardedMessage(context, secondKey, secondValue, secondMessageTimestamp) + } + + private def assertForwardedMessage( + context: ProcessorContext, + firstKey: String, + firstValue: String, + firstMessageTimestamp: Int + ): Unit = { + org.mockito.Mockito + .verify(context) + .forward(meq(firstKey), meq(firstValue), matchTo(firstMessageTimestamp - 1)) + } + + private def matchTo(expectedTimestamp: Int): To = { + Matchers.argThat(new BaseMatcher[To] { + override def matches(to: scala.Any): Boolean = { + val toInternal = new ToInternal + toInternal.update(to.asInstanceOf[To]) + toInternal.timestamp() == expectedTimestamp + } + + override def describeTo(description: Description): Unit = { + description.appendText(s"To(timestamp = $expectedTimestamp)") + } + }) + } + + val config = new KafkaStreamsConfig() + .commitInterval(Duration.Top) + .applicationId("test-app") + .bootstrapServers("127.0.0.1:1000") + + class FinatraMockProcessorContext + extends InternalMockProcessorContext( + TestUtils.tempDirectory, + new StreamsConfig(config.properties)) { + + override def schedule( + interval: Long, + `type`: PunctuationType, + callback: Punctuator + ): Cancellable = { + new Cancellable { + override def cancel(): Unit = { + //no-op + } + } + } + override def getStateStore(name: String): StateStore = { + val storeBuilder = Stores + .keyValueStoreBuilder( + Stores.persistentKeyValueStore(name), + Serdes.String(), + Serdes.String() + ) + + val store = storeBuilder.build + store.init(this, store) + store + } + + override def recordCollector(): RecordCollector = { + new NoOpRecordCollector + } + + override def forward[K, V](key: K, value: V, to: To): Unit = {} + } + +} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD index 150a15777c..80f391f7ef 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD @@ -11,7 +11,6 @@ junit_tests( "3rdparty/jvm/org/apache/kafka:kafka-test", "3rdparty/jvm/org/apache/zookeeper:zookeeper-client", "3rdparty/jvm/org/apache/zookeeper:zookeeper-server", - "finatra-internal/streams/examples/tweet-word-count/src/main/scala", "finatra/inject/inject-app/src/main/scala", "finatra/inject/inject-core/src/main/scala", "finatra/inject/inject-core/src/test/scala:test-deps", From c03a497c0b96edae20671c4ab5ad754056a9d329 Mon Sep 17 00:00:00 2001 From: Adam Singer Date: Tue, 22 Jan 2019 21:44:59 +0000 Subject: [PATCH 11/45] finatra-kafka-streams: Add flags for controlling rocksdb internal LOG file growth Problem finatra-kafka-streams has depended on the default rocksdb options for info `LOG` file settings. These settings have an unbounded LOG file growth by default. The default level for rocksdb info `LOG` is `DEBUG_LEVEL` in `FinatraRocksDBConfig`, that could cause a growth of large `LOG` files. Solution Introduce new flags and defaults for managing `LOG` max file size and keep count. Flags * `rocksdb.log.info.level` - allows the setting of rocks log level `DEBUG_LEVEL`, `INFO_LEVEL`, `WARN_LEVEL`, `ERROR_LEVEL`, `FATAL_LEVEL`, `HEADER_LEVEL`. * `rocksdb.log.max.file.size` - The maximal size of the info log file. * `rocksdb.log.keep.file.num` - Maximal info log files to be kept. Result Now all finatra-kafka-streams application will default to the following settings unless specified by the application. * `rocksdb.log.info.level` - `DEBUG_LEVEL` * `rocksdb.log.max.file.size` - `50.megabytes` * `rocksdb.log.keep.file.num` - `10` JIRA Issues: DINS-2549 Differential Revision: https://phabricator.twitter.biz/D259579 --- CHANGELOG.rst | 8 ++++++ .../config/FinatraRocksDBConfig.scala | 25 ++++++++++++++++++- .../finatra/streams/flags/RocksDbFlags.scala | 25 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bca6fa2e2a..d3cbc18e82 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -27,6 +27,14 @@ Changed An attempt to use non-legacy functionality with a legacy Controller will throw an AssertionError. ``PHAB_ID=D260230`` +* finatra-kafka: Add flags for controlling rocksdb internal LOG file growth. + - `rocksdb.log.info.level` Allows the setting of rocks log levels + `DEBUG_LEVEL`, `INFO_LEVEL`, `WARN_LEVEL`, `ERROR_LEVEL`, `FATAL_LEVEL`, + `HEADER_LEVEL`. + - `rocksdb.log.max.file.size` The maximal size of the info log file. + - `rocksdb.log.keep.file.num` Maximal info log files to be kept. + ``PHAB_ID=D259579`` + Fixed ~~~~~ diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala index 52b1adbc4b..93fd29f238 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala @@ -28,6 +28,9 @@ object FinatraRocksDBConfig { val RocksDbLZ4Config = "rocksdb.lz4" val RocksDbEnableStatistics = "rocksdb.statistics" val RocksDbStatCollectionPeriodMs = "rocksdb.statistics.collection.period.ms" + val RocksDbInfoLogLevel = "rocksdb.log.info.level" + val RocksDbMaxLogFileSize = "rocksdb.log.max.file.size" + val RocksDbKeepLogFileNum = "rocksdb.log.keep.file.num" // BlockCache to be shared by all RocksDB instances created on this instance. Note: That a single Kafka Streams instance may get multiple tasks assigned to it // and each stateful task will have a separate RocksDB instance created. This cache will be shared across all the tasks. @@ -93,8 +96,19 @@ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { options.setCompressionType(CompressionType.LZ4_COMPRESSION) } + val infoLogLevel = InfoLogLevel.valueOf(getStringOrDefault( + configs, + FinatraRocksDBConfig.RocksDbInfoLogLevel, "DEBUG_LEVEL").toUpperCase) options - .setInfoLogLevel(InfoLogLevel.DEBUG_LEVEL) + .setInfoLogLevel(infoLogLevel) + + val maxLogFileSize = + getBytesOrDefault(configs, FinatraRocksDBConfig.RocksDbMaxLogFileSize, 50.megabytes) + options.setMaxLogFileSize(maxLogFileSize) + + val keepLogFileNum = + getIntOrDefault(configs, FinatraRocksDBConfig.RocksDbKeepLogFileNum, 10) + options.setKeepLogFileNum(keepLogFileNum) if (configs.get(FinatraRocksDBConfig.RocksDbEnableStatistics) == "true") { val statistics = new Statistics @@ -135,4 +149,13 @@ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { default } } + + private def getStringOrDefault(configs: util.Map[String, AnyRef], key: String, default: String): String = { + val valueString = configs.get(key) + if (valueString != null) { + valueString.toString + } else { + default + } + } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/RocksDbFlags.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/RocksDbFlags.scala index 43e75e75f3..8cbe495303 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/RocksDbFlags.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/RocksDbFlags.scala @@ -36,4 +36,29 @@ trait RocksDbFlags extends TwitterServer { help = "Enable RocksDB LZ4 compression. (See https://github.com/facebook/rocksdb/wiki/Compression)" ) + + protected val rocksDbInfoLogLevel = + flag( + name = FinatraRocksDBConfig.RocksDbInfoLogLevel, + default = "INFO_LEVEL", + help = + """Level of logging for rocksdb LOG file. + |DEBUG_LEVEL, INFO_LEVEL, WARN_LEVEL, ERROR_LEVEL, FATAL_LEVEL, HEADER_LEVEL""".stripMargin + ) + + protected val rocksDbMaxLogFileSize = + flag( + name = FinatraRocksDBConfig.RocksDbMaxLogFileSize, + default = 50.megabytes, + help = + s"""Specify the maximal size of the info log file. If the log file is larger then + |${FinatraRocksDBConfig.RocksDbKeepLogFileNum} a new log file will be created.""".stripMargin + ) + + protected val rocksDbKeepLogFileNum = + flag( + name = FinatraRocksDBConfig.RocksDbKeepLogFileNum, + default = 10, + help = "Maximal info log files to be kept." + ) } From ecf2e54f685a3f017175fb1711d2666439c9bfed Mon Sep 17 00:00:00 2001 From: Adam Singer Date: Tue, 22 Jan 2019 22:28:10 +0000 Subject: [PATCH 12/45] finatra-kafka-streams: Add admin routes for properties and topology information Problem When inspecting finatra-kafka-streams applications it isn't obvious what the state of the kafka configuration is without looking at the source code and/or deployment configuration. Solution Add new routes that dump the kafka streams properties and topology from `KafkaStreamsTwitterServer`. * `/admin/kafka/streams/properties` - Dumps `KafkaStreamsTwitterServer#properties` as plain text in the TwitterServer admin page. * `/admin/kafka/streams/topology` - Dumps `KafkaStreamsTwitterServer#topology` as plain text in the TwitterServer admin page. Result Introspection into configuration of the finatra-kafka-streams application can be done from the outside without referencing source code. JIRA Issues: DINS-2564 Differential Revision: https://phabricator.twitter.biz/D259597 --- CHANGELOG.rst | 9 +++ .../KafkaStreamsTwitterServer.scala | 2 + .../internal/admin/AdminRoutes.scala | 25 ++++++++ .../admin/KafkaStreamsPropertiesHandler.scala | 28 +++++++++ .../admin/KafkaStreamsTopologyHandler.scala | 29 +++++++++ .../internal/admin/ResponseWriter.scala | 17 ++++++ .../KafkaStreamsAdminServerFeatureTest.scala | 60 +++++++++++++++++++ 7 files changed, 170 insertions(+) create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/AdminRoutes.scala create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsPropertiesHandler.scala create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsTopologyHandler.scala create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/ResponseWriter.scala create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/admin/KafkaStreamsAdminServerFeatureTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d3cbc18e82..925091a7b3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,6 +35,15 @@ Changed - `rocksdb.log.keep.file.num` Maximal info log files to be kept. ``PHAB_ID=D259579`` +* finatra-kafka: Add admin routes for properties and topology information + - `/admin/kafka/streams/properties` Dumps the + `KafkaStreamsTwitterServer#properties` as plain text in the TwitterServer + admin page. + - `/admin/kafka/streams/topology` Dumps the + `KafkaStreamsTwitterServer#topology` as plain text in the TwitterServer + admin page. + ``PHAB_ID=D259597`` + Fixed ~~~~~ diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala index ce0916036e..ba749a9730 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala @@ -13,6 +13,7 @@ import com.twitter.finatra.kafka.stats.KafkaFinagleMetricsReporter import com.twitter.finatra.kafkastreams.config.{FinatraRocksDBConfig, KafkaStreamsConfig} import com.twitter.finatra.kafkastreams.domain.ProcessingGuarantee import com.twitter.finatra.kafkastreams.internal.ScalaStreamsImplicits +import com.twitter.finatra.kafkastreams.internal.admin.AdminRoutes import com.twitter.finatra.kafkastreams.internal.listeners.FinatraStateRestoreListener import com.twitter.finatra.kafkastreams.internal.serde.AvoidDefaultSerde import com.twitter.finatra.kafkastreams.internal.stats.KafkaStreamsFinagleMetricsReporter @@ -146,6 +147,7 @@ abstract class KafkaStreamsTwitterServer super.postInjectorStartup() properties = createKafkaStreamsProperties() topology = createKafkaStreamsTopology() + addAdminRoutes(AdminRoutes(properties, topology)) } override protected def postWarmup(): Unit = { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/AdminRoutes.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/AdminRoutes.scala new file mode 100644 index 0000000000..0602693ce3 --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/AdminRoutes.scala @@ -0,0 +1,25 @@ +package com.twitter.finatra.kafkastreams.internal.admin + +import com.twitter.server.AdminHttpServer +import com.twitter.server.AdminHttpServer.Route +import java.util.Properties +import org.apache.kafka.streams.Topology + +private[kafkastreams] object AdminRoutes { + def apply(properties: Properties, topology: Topology): Seq[Route] = { + // Kafka Properties + Seq(AdminHttpServer.mkRoute( + path = "/admin/kafka/streams/properties", + handler = KafkaStreamsPropertiesHandler(properties), + alias = "kafkaStreamsProperties", + group = Some("Kafka"), + includeInIndex = true), + // Kafka Topology + AdminHttpServer.mkRoute( + path = "/admin/kafka/streams/topology", + handler = KafkaStreamsTopologyHandler(topology), + alias = "kafkaStreamsTopology", + group = Some("Kafka"), + includeInIndex = true)) + } +} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsPropertiesHandler.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsPropertiesHandler.scala new file mode 100644 index 0000000000..19ae0ac4ab --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsPropertiesHandler.scala @@ -0,0 +1,28 @@ +package com.twitter.finatra.kafkastreams.internal.admin + +import com.twitter.finagle.Service +import com.twitter.finagle.http._ +import com.twitter.util.Future +import java.util.Properties +import scala.collection.JavaConversions._ + +private[kafkastreams] object KafkaStreamsPropertiesHandler { + /** + * Create a service function that extracts the key/value of kafka properties and formats it in + * HTML. + * @param properties Kafka Properties + * @return HTML formatted properties + */ + def apply(properties: Properties): Service[Request, Response] = { + new Service[Request, Response] { + override def apply(request: Request): Future[Response] = { + val response = Response(Version.Http11, Status.Ok) + response.setContentType(MediaType.Html) + val sortedProperties = properties.propertyNames().map { property => + s"$property=${properties.get(property)}" + }.toSeq.sorted.mkString("\n") + ResponseWriter(response)(_.print(s"
$sortedProperties
")) + } + } + } +} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsTopologyHandler.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsTopologyHandler.scala new file mode 100644 index 0000000000..aa6a25f45b --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/KafkaStreamsTopologyHandler.scala @@ -0,0 +1,29 @@ +package com.twitter.finatra.kafkastreams.internal.admin + +import com.twitter.finagle.Service +import com.twitter.finagle.http._ +import com.twitter.util.Future +import org.apache.kafka.streams.Topology + +private[kafkastreams] object KafkaStreamsTopologyHandler { + /** + * Create a service function that prints the kafka topology and formats it in HTML. + * @param topology Kafka Topology + * @return HTML formatted properties + */ + def apply(topology: Topology): Service[Request, Response] = { + new Service[Request, Response] { + override def apply(request: Request): Future[Response] = { + val response = Response(Version.Http11, Status.Ok) + response.setContentType(MediaType.Html) + val describeHtml = + s""" + |
+            |${topology.describe().toString.trim()}
+            |
+ """.stripMargin + ResponseWriter(response)(_.print(describeHtml)) + } + } + } +} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/ResponseWriter.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/ResponseWriter.scala new file mode 100644 index 0000000000..6195f050c7 --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/admin/ResponseWriter.scala @@ -0,0 +1,17 @@ +package com.twitter.finatra.kafkastreams.internal.admin + +import com.twitter.finagle.http._ +import com.twitter.util.Future +import java.io.{PrintWriter, StringWriter} + +private[kafkastreams] object ResponseWriter { + def apply(response: Response)(printer: PrintWriter => Unit): Future[Response] = { + val writer = new StringWriter() + val printWriter = new PrintWriter(writer) + printer(printWriter) + response.write(writer.getBuffer.toString) + printWriter.close() + writer.close() + Future.value(response) + } +} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/admin/KafkaStreamsAdminServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/admin/KafkaStreamsAdminServerFeatureTest.scala new file mode 100644 index 0000000000..a02401e6e0 --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/admin/KafkaStreamsAdminServerFeatureTest.scala @@ -0,0 +1,60 @@ +package com.twitter.unittests.integration.admin + +import com.twitter.finatra.kafka.serde.UnKeyedSerde +import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer +import com.twitter.finatra.kafkastreams.test.KafkaStreamsFeatureTest +import com.twitter.inject.server.EmbeddedTwitterServer +import com.twitter.util.Await +import java.nio.charset.{Charset, StandardCharsets} +import org.apache.kafka.common.serialization.Serdes +import org.apache.kafka.streams.StreamsBuilder +import org.apache.kafka.streams.kstream.{Consumed, Produced} + +class KafkaStreamsAdminServerFeatureTest extends KafkaStreamsFeatureTest { + + override val server = new EmbeddedTwitterServer( + new KafkaStreamsTwitterServer { + override val name = "no-op" + override protected def configureKafkaStreams(builder: StreamsBuilder): Unit = { + builder.asScala + .stream("TextLinesTopic")(Consumed.`with`(UnKeyedSerde, Serdes.String)) + .to("sink")(Produced.`with`(UnKeyedSerde, Serdes.String)) + } + }, + flags = kafkaStreamsFlags ++ Map("kafka.application.id" -> "no-op") + ) + + override def beforeEach(): Unit = { + server.start() + } + + test("admin kafka streams properties") { + val bufBytes = getAdminResponseBytes("/admin/kafka/streams/properties") + val result = new String(bufBytes, StandardCharsets.UTF_8) + result.contains("application.id=no-op") should equal(true) + } + + test("admin kafka streams topology") { + val bufBytes = getAdminResponseBytes("/admin/kafka/streams/topology") + val result = new String(bufBytes, Charset.forName("UTF-8")) + result.trim() should equal( + """
+        |Topologies:
+        |   Sub-topology: 0
+        |    Source: KSTREAM-SOURCE-0000000000 (topics: [TextLinesTopic])
+        |      --> KSTREAM-SINK-0000000001
+        |    Sink: KSTREAM-SINK-0000000001 (topic: sink)
+        |      <-- KSTREAM-SOURCE-0000000000
+        |
""".stripMargin) + } + + private def getAdminResponseBytes(path: String): Array[Byte] = { + val response = server.httpGetAdmin(path) + val read = Await.result(response.reader.read()) + read.isDefined should equal(true) + val buf = read.get + val bufBytes: Array[Byte] = new Array[Byte](buf.length) + buf.write(bufBytes, off = 0) + bufBytes + } +} From cd455c43d2b25415248228862f8b96991a494a54 Mon Sep 17 00:00:00 2001 From: Kristy Liao Date: Fri, 25 Jan 2019 01:59:29 +0000 Subject: [PATCH 13/45] finatra-kafka-streams: Combine FinatraTransformer with FinatraTransformerV2 Problem FinatraTransformer calls timersStore.all() on each timer fire and consequently may have to iterate over many tombstoned timers. Solution Combine the original FinatraTransformer with FinatraTransformerV2, which handles timers in a more performant way. Result FinatraTransformer avoids potentially having to traverse many tombstoned timers when calling timersStores.all(). JIRA Issues: DINS-2490 Differential Revision: https://phabricator.twitter.biz/D254411 --- CHANGELOG.rst | 2 + .../sphinx/user-guide/kafka-streams/index.rst | 2 +- .../ReservoirSamplingTransformer.scala | 4 +- .../processors/FlushingAwareServer.scala | 2 +- .../transformer/AggregatorTransformer.scala | 2 +- .../transformer/CompositeSumAggregator.scala | 40 +- .../transformer/FinatraTransformer.scala | 379 +++++------------- .../transformer/FinatraTransformerV2.scala | 206 ---------- .../streams/transformer/SumAggregator.scala | 43 +- ...est.scala => FinatraTransformerTest.scala} | 6 +- ...ala => WordLengthFinatraTransformer.scala} | 10 +- .../finatratransformer/WordLengthServer.scala | 2 +- 12 files changed, 163 insertions(+), 535 deletions(-) delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala rename kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/{FinatraTransformerV2Test.scala => FinatraTransformerTest.scala} (95%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/{WordLengthFinatraTransformerV2.scala => WordLengthFinatraTransformer.scala} (79%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 925091a7b3..e8318f1e61 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,8 @@ Added Changed ~~~~~~~ +* finatra-kafka-streams: Combine FinatraTransformer with FinatraTransformerV2. ``PHAB_ID=D254411`` + * finatra-thrift: The return type of `ReqRepDarkTrafficFilterModule#newFilter` has been changed from `DarkTrafficFilter[MethodIface]` to `Filter.TypeAgnostic`. ``PHAB_ID=D261868`` diff --git a/doc/src/sphinx/user-guide/kafka-streams/index.rst b/doc/src/sphinx/user-guide/kafka-streams/index.rst index cd113f9652..718ccb9e07 100644 --- a/doc/src/sphinx/user-guide/kafka-streams/index.rst +++ b/doc/src/sphinx/user-guide/kafka-streams/index.rst @@ -23,7 +23,7 @@ a fully functional service can be written by simply configuring the Kafka Stream Transformers ~~~~~~~~~~~~ -Implement custom `transformers `__ using `FinatraTransformerV2 `__. +Implement custom `transformers `__ using `FinatraTransformer `__. Aggregations ^^^^^^^^^^^^ diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala index 1b309590fa..bbd257d9c4 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala @@ -7,7 +7,7 @@ import com.twitter.finatra.streams.transformer.domain.{ Time, TimerMetadata } -import com.twitter.finatra.streams.transformer.{FinatraTransformerV2, PersistentTimers} +import com.twitter.finatra.streams.transformer.{FinatraTransformer, PersistentTimers} import com.twitter.util.Duration import org.apache.kafka.streams.processor.PunctuationType import scala.reflect.ClassTag @@ -31,7 +31,7 @@ class ReservoirSamplingTransformer[ countStoreName: String, sampleStoreName: String, timerStoreName: String) - extends FinatraTransformerV2[Key, Value, SampleKey, SampleValue](statsReceiver = statsReceiver) + extends FinatraTransformer[Key, Value, SampleKey, SampleValue](statsReceiver = statsReceiver) with PersistentTimers { private val numExpiredCounter = statsReceiver.counter("numExpired") diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingAwareServer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingAwareServer.scala index cae93745fc..319ae7b871 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingAwareServer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingAwareServer.scala @@ -7,7 +7,7 @@ import com.twitter.util.Duration /** * FlushingAwareServer must be mixed in to servers that rely on manually controlling when a flush/commit occurs. * As such, this trait will be needed when using the following classes, FlushingProcessor, FlushingTransformer, - * AsyncProcessor, AsyncTransformer, FinatraTransformer, and FinatraTransformerV2 + * AsyncProcessor, AsyncTransformer, and FinatraTransformer * * This trait sets 'kafka.commit.interval' to 'Duration.Top' to disable the normal Kafka Streams commit process. * As such the only commits that will occur are triggered manually, thus allowing us to control when flush/commit diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala index da63f1dc87..203e5a8fcf 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala @@ -55,7 +55,7 @@ class AggregatorTransformer[K, V, Aggregate]( queryableAfterClose: Duration, emitUpdatedEntriesOnCommit: Boolean, val commitInterval: Duration) - extends FinatraTransformerV2[K, V, TimeWindowed[K], WindowedValue[Aggregate]](statsReceiver) + extends FinatraTransformer[K, V, TimeWindowed[K], WindowedValue[Aggregate]](statsReceiver) with CachingKeyValueStores[K, V, TimeWindowed[K], WindowedValue[Aggregate]] with PersistentTimers { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala index 95375ce2af..20aa521c29 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala @@ -3,7 +3,9 @@ package com.twitter.finatra.streams.transformer import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.streams.transformer.FinatraTransformer.WindowStartTime import com.twitter.finatra.streams.transformer.domain._ +import com.twitter.finatra.streams.transformer.internal.StateStoreImplicits import com.twitter.util.Duration +import org.apache.kafka.streams.processor.PunctuationType import org.apache.kafka.streams.state.KeyValueIterator @deprecated("Use AggregatorTransformer", "1/7/2019") @@ -21,12 +23,13 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( extends FinatraTransformer[ CK, Int, - TimeWindowed[CK], - WindowStartTime, TimeWindowed[K], WindowedValue[ scala.collection.Map[A, Int] - ]](timerStoreName = timerStoreName, statsReceiver = statsReceiver, cacheTimers = true) { + ]](statsReceiver = statsReceiver) + with PersistentTimers + with StateStoreImplicits + with IteratorImplicits { private val windowSizeMillis = windowSize.inMillis private val allowedLatenessMillis = allowedLateness.inMillis @@ -41,10 +44,15 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( private val putLatencyStat = statsReceiver.stat("putLatency") private val stateStore = getKeyValueStore[TimeWindowed[CK], Int](stateStoreName) + private val timerStore = getPersistentTimerStore[WindowStartTime]( + timerStoreName, + onEventTimer, + PunctuationType.STREAM_TIME + ) override def onMessage(time: Time, compositeKey: CK, count: Int): Unit = { val windowedCompositeKey = TimeWindowed.forSize(time.hourMillis, windowSizeMillis, compositeKey) - if (windowedCompositeKey.isLate(allowedLatenessMillis, Watermark(watermark))) { + if (windowedCompositeKey.isLate(allowedLatenessMillis, Watermark(watermark.timeMillis))) { restatementsCounter.incr() forward(windowedCompositeKey.map { _ => compositeKey.primary @@ -59,9 +67,9 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( if (newCount == count) { val closeTime = windowedCompositeKey.startMs + windowSizeMillis + allowedLatenessMillis if (emitOnClose) { - addEventTimeTimer(Time(closeTime), Close, windowedCompositeKey.startMs) + timerStore.addTimer(Time(closeTime), Close, windowedCompositeKey.startMs) } - addEventTimeTimer( + timerStore.addTimer( Time(closeTime + queryableAfterCloseMillis), Expire, windowedCompositeKey.startMs @@ -77,16 +85,14 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( * TimeWindowedKey(2018-08-04T10:00:00.000Z-40-retweet) -> 4 */ //Note: We use the cursor even for deletes to skip tombstones that may otherwise slow down the range scan - override def onEventTimer( + private def onEventTimer( time: Time, timerMetadata: TimerMetadata, - windowStartMs: WindowStartTime, - cursor: Option[TimeWindowed[CK]] - ): TimerResult[TimeWindowed[CK]] = { + windowStartMs: WindowStartTime + ): Unit = { debug(s"onEventTimer $time $timerMetadata") val windowIterator = stateStore.range( - cursor getOrElse TimeWindowed - .forSize(windowStartMs, windowSizeMillis, compositeKeyRangeStart), + TimeWindowed.forSize(windowStartMs, windowSizeMillis, compositeKeyRangeStart), TimeWindowed.forSize(windowStartMs + 1, windowSizeMillis, compositeKeyRangeStart) ) @@ -104,7 +110,7 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( private def onClosed( windowStartMs: Long, windowIterator: KeyValueIterator[TimeWindowed[CK], Int] - ): TimerResult[TimeWindowed[CK]] = { + ): Unit = { windowIterator .groupBy( primaryKey = timeWindowed => timeWindowed.value.primary, @@ -121,14 +127,12 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( ) } - deleteOrRetainTimer(windowIterator, onDeleteTimer = closedCounter.incr()) + closedCounter.incr() } //Note: We call "put" w/ a null value instead of calling "delete" since "delete" also gets the previous value :-/ //TODO: Consider performing deletes in a transaction so that queryable state sees all or no keys per "primary key" - private def onExpired( - windowIterator: KeyValueIterator[TimeWindowed[CK], Int] - ): TimerResult[TimeWindowed[CK]] = { + private def onExpired(windowIterator: KeyValueIterator[TimeWindowed[CK], Int]): Unit = { windowIterator .take(maxActionsPerTimer) .foreach { @@ -137,6 +141,6 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( stateStore.put(timeWindowedCompositeKey, null.asInstanceOf[Int]) } - deleteOrRetainTimer(windowIterator, onDeleteTimer = expiredCounter.incr()) + expiredCounter.incr() } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformer.scala index f40f4ac784..770aaa5b5a 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformer.scala @@ -1,45 +1,25 @@ package com.twitter.finatra.streams.transformer import com.google.common.annotations.Beta -import com.twitter.conversions.DurationOps._ -import com.twitter.finagle.stats.{LoadedStatsReceiver, StatsReceiver} +import com.twitter.finagle.stats.StatsReceiver +import com.twitter.finatra.kafka.utils.ConfigUtils import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging import com.twitter.finatra.streams.config.DefaultTopicConfig -import com.twitter.finatra.streams.stores.internal.{ - FinatraKeyValueStoreImpl, - FinatraStoresGlobalManager -} +import com.twitter.finatra.streams.flags.FinatraTransformerFlags import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.stores.internal.FinatraStoresGlobalManager +import com.twitter.finatra.streams.stores.internal.{FinatraKeyValueStoreImpl, FinatraStoresGlobalManager} import com.twitter.finatra.streams.transformer.FinatraTransformer.TimerTime -import com.twitter.finatra.streams.transformer.domain.{ - DeleteTimer, - RetainTimer, - Time, - TimerMetadata, - TimerResult -} +import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} import com.twitter.finatra.streams.transformer.internal.domain.{Timer, TimerSerde} -import com.twitter.finatra.streams.transformer.internal.{ - OnClose, - OnInit, - ProcessorContextUtils, - StateStoreImplicits, - WatermarkTracker -} +import com.twitter.finatra.streams.transformer.internal.{OnClose, OnFlush, OnInit} +import com.twitter.finatra.streams.transformer.watermarks.internal.WatermarkManager +import com.twitter.finatra.streams.transformer.watermarks.{DefaultWatermarkAssignor, WatermarkAssignor} import com.twitter.util.Duration -import org.agrona.collections.ObjectHashSet import org.apache.kafka.common.serialization.{Serde, Serdes} import org.apache.kafka.streams.kstream.Transformer -import org.apache.kafka.streams.processor.{ - Cancellable, - ProcessorContext, - PunctuationType, - Punctuator -} -import org.apache.kafka.streams.state.{KeyValueIterator, KeyValueStore, StoreBuilder, Stores} -import org.joda.time.DateTime -import scala.collection.JavaConverters._ +import org.apache.kafka.streams.processor.{Cancellable, ProcessorContext, PunctuationType, Punctuator, To} +import org.apache.kafka.streams.state.{KeyValueStore, StoreBuilder, Stores} +import scala.collection.mutable import scala.reflect.ClassTag object FinatraTransformer { @@ -62,161 +42,116 @@ object FinatraTransformer { } /** - * A KafkaStreams Transformer supporting Per-Key Persistent Timers - * Inspired by Flink's ProcessFunction: https://ci.apache.org/projects/flink/flink-docs-stable/dev/stream/operators/process_function.html - * - * Note: Timers are based on a sorted RocksDB KeyValueStore - * Note: Timers that fire at the same time MAY NOT fire in the order which they were added + * A KafkaStreams Transformer offering an upgraded API over the built in Transformer interface. * - * Example Timer Key Structures (w/ corresponding CountsStore Key Structures) - * {{{ - * ImpressionsCounter (w/ TimerKey storing TweetId) - * TimeWindowedKey(2018-08-04T10:00:00.000Z-20) - * Timer( 2018-08-04T12:00:00.000Z-Expire-2018-08-04T10:00:00.000Z-20 - * TimeWindowedKey(2018-08-04T10:00:00.000Z-30) - * Timer( 2018-08-04T12:00:00.000Z-Expire-2018-08-04T10:00:00.000Z-30 + * This Transformer differs from the built in Transformer interface by exposing an [onMessage] + * interface that is used to process incoming messages. Within [onMessage] you may use the + * [forward] method to emit 0 or more records. * - * ImpressionsCounter (w/ TimerKey storing windowStartMs) - * TimeWindowedKey(2018-08-04T10:00:00.000Z-20) - * TimeWindowedKey(2018-08-04T10:00:00.000Z-30) - * TimeWindowedKey(2018-08-04T10:00:00.000Z-40) - * TimeWindowedKey(2018-08-04T11:00:00.000Z-20) - * TimeWindowedKey(2018-08-04T11:00:00.000Z-30) - * Timer( 2018-08-04T12:00:00.000Z-Expire-2018-08-04T10:00:00.000Z - * Timer( 2018-08-04T13:00:00.000Z-Expire-2018-08-04T11:00:00.000Z - * - * EngagementCounter (w/ TimerKey storing windowStartMs) - * TimeWindowedKey(2018-08-04T10:00:00.000Z-20-displayed) -> 5 - * TimeWindowedKey(2018-08-04T10:00:00.000Z-20-fav) -> 10 - * Timer( 2018-08-04T12:00:00.000Z-Expire-2018-08-04T10:00:00.000Z + * This transformer also manages watermarks(see [WatermarkManager]), and extends [OnWatermark] which + * allows you to track the passage of event time. * * @tparam InputKey Type of the input keys * @tparam InputValue Type of the input values - * @tparam StoreKey Type of the key being stored in the state store (needed to support onEventTimer cursoring) - * @tparam TimerKey Type of the timer key * @tparam OutputKey Type of the output keys * @tparam OutputValue Type of the output values - * }}} */ -//TODO: Create variant for when there are no timers (e.g. avoid the extra time params and need to specify a timer store @Beta -abstract class FinatraTransformer[InputKey, InputValue, StoreKey, TimerKey, OutputKey, OutputValue]( - commitInterval: Duration = null, //TODO: This field is currently only used by one external customer (but unable to @deprecate a constructor param). Will remove from caller and here in followup Phab. - cacheTimers: Boolean = true, - throttlingResetDuration: Duration = 3.seconds, - disableTimers: Boolean = false, - timerStoreName: String, - statsReceiver: StatsReceiver = LoadedStatsReceiver) //TODO - extends Transformer[InputKey, InputValue, (OutputKey, OutputValue)] +abstract class FinatraTransformer[InputKey, InputValue, OutputKey, OutputValue]( + statsReceiver: StatsReceiver, + watermarkAssignor: WatermarkAssignor[InputKey, InputValue] = + new DefaultWatermarkAssignor[InputKey, InputValue]) + extends Transformer[InputKey, InputValue, (OutputKey, OutputValue)] with OnInit + with OnWatermark with OnClose - with StateStoreImplicits - with IteratorImplicits + with OnFlush with ProcessorContextLogging { + protected[streams] val finatraKeyValueStoresMap: mutable.Map[String, FinatraKeyValueStore[_, _]] = + scala.collection.mutable.Map[String, FinatraKeyValueStore[_, _]]() + /* Private Mutable */ @volatile private var _context: ProcessorContext = _ - @volatile private var cancellableThrottlingResetTimer: Cancellable = _ - @volatile private var processingTimerCancellable: Cancellable = _ - @volatile private var nextTimer: Long = Long.MaxValue //Maintain to avoid iterating timerStore every time fireTimers is called - - //TODO: Persist cursor in stateStore to avoid duplicate cursored work after a restart - @volatile private var throttled: Boolean = false - @volatile private var lastThrottledCursor: Option[StoreKey] = None - - /* Private */ - - private val watermarkTracker = new WatermarkTracker - private val cachedTimers = new ObjectHashSet[Timer[TimerKey]](16) - private val finatraKeyValueStores = - scala.collection.mutable.Map[String, FinatraKeyValueStore[_, _]]() - - protected[finatra] final val timersStore = if (disableTimers) { - null - } else { - getKeyValueStore[Timer[TimerKey], Array[Byte]](timerStoreName) - } + @volatile private var watermarkTimerCancellable: Cancellable = _ + @volatile private var watermarkManager: WatermarkManager[InputKey, InputValue] = _ /* Abstract */ - protected[finatra] def onMessage(messageTime: Time, key: InputKey, value: InputValue): Unit - - protected def onProcessingTimer(time: TimerTime): Unit = {} - /** - * Callback for when an Event timer is ready for processing + * Callback method which is called for every message in the stream this Transformer is attached to. + * Implementers of this method may emit 0 or more records by using the processorContext. * - * @return TimerResult indicating if this timer should be retained or deleted + * @param messageTime the time of the message + * @param key the key of the message + * @param value the value of the message */ - protected def onEventTimer( - time: Time, - metadata: TimerMetadata, - key: TimerKey, - cursor: Option[StoreKey] - ): TimerResult[StoreKey] = { - warn(s"Unhandled timer $time $metadata $key") - DeleteTimer() - } + protected[finatra] def onMessage(messageTime: Time, key: InputKey, value: InputValue): Unit /* Protected */ + override protected def processorContext: ProcessorContext = _context + final override def init(processorContext: ProcessorContext): Unit = { _context = processorContext - for ((name, store) <- finatraKeyValueStores) { + watermarkManager = new WatermarkManager[InputKey, InputValue]( + taskId = processorContext.taskId(), + transformerName = this.getClass.getSimpleName, + onWatermark = this, + watermarkAssignor = watermarkAssignor, + emitWatermarkPerMessage = shouldEmitWatermarkPerMessage(_context)) + + for ((name, store) <- finatraKeyValueStoresMap) { store.init(processorContext, null) } - if (!disableTimers) { - cancellableThrottlingResetTimer = _context - .schedule( - throttlingResetDuration.inMillis, - PunctuationType.WALL_CLOCK_TIME, - new Punctuator { - override def punctuate(timestamp: TimerTime): Unit = { - resetThrottled() - fireEventTimeTimers() - } + val autoWatermarkInterval = parseAutoWatermarkInterval(_context).inMillis + if (autoWatermarkInterval > 0) { + watermarkTimerCancellable = _context.schedule( + autoWatermarkInterval, + PunctuationType.WALL_CLOCK_TIME, + new Punctuator { + override def punctuate(timestamp: TimerTime): Unit = { + watermarkManager.callOnWatermarkIfChanged() } - ) - - findAndSetNextTimer() - cacheTimersIfEnabled() + } + ) } onInit() } - override protected def processorContext: ProcessorContext = _context - - final override def transform(k: InputKey, v: InputValue): (OutputKey, OutputValue) = { - if (watermarkTracker.track(_context.topic(), _context.timestamp)) { - fireEventTimeTimers() - } + override def onFlush(): Unit = { + super.onFlush() + watermarkManager.callOnWatermarkIfChanged() + } - debug(s"onMessage ${_context.timestamp.iso8601Millis} $k $v") - onMessage(Time(_context.timestamp()), k, v) + override def onWatermark(watermark: Watermark): Unit = { + trace(s"onWatermark $watermark") + } + final override def transform(k: InputKey, v: InputValue): (OutputKey, OutputValue) = { + /* Note: It's important to save off the message time before watermarkManager.onMessage is called + which can trigger persistent timers to fire, which can cause messages to be forwarded, which + can cause context.timestamp to be mutated to the forwarded message timestamp :-( */ + val messageTime = Time(_context.timestamp()) + + watermarkManager.onMessage(messageTime, _context.topic(), k, v) + debug(s"onMessage LastEmitted $watermark MessageTime $messageTime $k -> $v") + onMessage(messageTime, k, v) null } final override def close(): Unit = { - setNextTimerTime(0) - cachedTimers.clear() - watermarkTracker.reset() - - if (cancellableThrottlingResetTimer != null) { - cancellableThrottlingResetTimer.cancel() - cancellableThrottlingResetTimer = null - } - - if (processingTimerCancellable != null) { - processingTimerCancellable.cancel() - processingTimerCancellable = null + if (watermarkTimerCancellable != null) { + watermarkTimerCancellable.cancel() + watermarkTimerCancellable = null } + watermarkManager.close() - for ((name, store) <- finatraKeyValueStores) { + for ((name, store) <- finatraKeyValueStoresMap) { store.close() FinatraStoresGlobalManager.removeStore(store) } @@ -228,9 +163,10 @@ abstract class FinatraTransformer[InputKey, InputValue, StoreKey, TimerKey, Outp name: String ): FinatraKeyValueStore[KK, VV] = { val store = new FinatraKeyValueStoreImpl[KK, VV](name, statsReceiver) - val previousStore = finatraKeyValueStores.put(name, store) - FinatraStoresGlobalManager.addStore(store) + + val previousStore = finatraKeyValueStoresMap.put(name, store) assert(previousStore.isEmpty, s"getKeyValueStore was called for store $name more than once") + FinatraStoresGlobalManager.addStore(store) // Initialize stores that are still using the "lazy val store" pattern if (processorContext != null) { @@ -240,157 +176,40 @@ abstract class FinatraTransformer[InputKey, InputValue, StoreKey, TimerKey, Outp store } - //TODO: Add a forwardOnCommit which just takes a key final protected def forward(key: OutputKey, value: OutputValue): Unit = { - trace(f"${"Forward:"}%-20s $key $value") + debug(s"Forward ${_context.timestamp().iso8601Millis} $key $value") _context.forward(key, value) } final protected def forward(key: OutputKey, value: OutputValue, timestamp: Long): Unit = { - trace(f"${"Forward:"}%-20s $key $value @${new DateTime(timestamp)}") - ProcessorContextUtils.setTimestamp(_context, timestamp) - _context.forward(key, value) - } - - final protected def watermark: Long = { - watermarkTracker.watermark - } - - final protected def addEventTimeTimer( - time: Time, - metadata: TimerMetadata, - key: TimerKey - ): Unit = { - trace( - f"${"AddEventTimer:"}%-20s ${metadata.getClass.getSimpleName}%-12s Key $key Timer ${time.millis.iso8601Millis}" - ) - val timer = Timer(time = time.millis, metadata = metadata, key = key) - if (cacheTimers && cachedTimers.contains(timer)) { - trace(s"Deduped unkeyed timer: $timer") + if (timestamp <= 10000) { + warn(s"Forward SMALL TIMESTAMP: $timestamp $key $value") } else { - timersStore.put(timer, Array.emptyByteArray) - if (time.millis < nextTimer) { - setNextTimerTime(time.millis) - } - if (cacheTimers) { - cachedTimers.add(timer) - } + debug(s"Forward ${timestamp.iso8601Millis} $key $value") } - } - final protected def addProcessingTimeTimer(duration: Duration): Unit = { - assert( - processingTimerCancellable == null, - "NonPersistentProcessingTimer already set. We currently only support a single processing timer being set through addProcessingTimeTimer." - ) - processingTimerCancellable = - processorContext.schedule(duration.inMillis, PunctuationType.WALL_CLOCK_TIME, new Punctuator { - override def punctuate(time: Long): Unit = { - onProcessingTimer(time) - } - }) + _context.forward(key, value, To.all().withTimestamp(timestamp)) } - final protected def deleteOrRetainTimer( - iterator: KeyValueIterator[StoreKey, _], - onDeleteTimer: => Unit = () => () - ): TimerResult[StoreKey] = { - if (iterator.hasNext) { - RetainTimer(stateStoreCursor = iterator.peekNextKeyOpt, throttled = true) - } else { - onDeleteTimer - DeleteTimer() - } - } - - /* Private */ - - private def fireEventTimeTimers(): Unit = { - trace( - s"FireTimers watermark ${watermark.iso8601Millis} nextTimer ${nextTimer.iso8601Millis} throttled $throttled" - ) - if (!disableTimers && !isThrottled && watermark >= nextTimer) { - val timerIterator = timersStore.all() - try { - timerIterator.asScala - .takeWhile { timerAndEmptyValue => - !isThrottled && watermark >= timerAndEmptyValue.key.time - } - .foreach { timerAndEmptyValue => - fireEventTimeTimer(timerAndEmptyValue.key) - } - } finally { - timerIterator.close() - findAndSetNextTimer() //TODO: Optimize by avoiding the need to re-read from the timersStore iterator - } - } + final protected[finatra] def watermark: Watermark = { + watermarkManager.watermark } - //Note: LastThrottledCursor is shared per Task. However, since the timers are sorted, we should only be cursoring the head timer at a time. - private def fireEventTimeTimer(timer: Timer[TimerKey]): Unit = { - trace( - s"fireEventTimeTimer ${timer.metadata.getClass.getName} key: ${timer.key} timerTime: ${timer.time.iso8601Millis}" + private def parseAutoWatermarkInterval(processorContext: ProcessorContext): Duration = { + Duration.parse( + ConfigUtils.getConfigOrElse( + processorContext.appConfigs, + FinatraTransformerFlags.AutoWatermarkInterval, + "100.milliseconds" + ) ) - - onEventTimer( - time = Time(timer.time), - metadata = timer.metadata, - key = timer.key, - lastThrottledCursor - ) match { - case DeleteTimer(throttledResult) => - lastThrottledCursor = None - throttled = throttledResult - - timersStore.deleteWithoutGettingPriorValue(timer) - if (cacheTimers) { - cachedTimers.remove(timer) - } - case RetainTimer(stateStoreCursor, throttledResult) => - lastThrottledCursor = stateStoreCursor - throttled = throttledResult - } - } - - private def findAndSetNextTimer(): Unit = { - val iterator = timersStore.all() - try { - if (iterator.hasNext) { - setNextTimerTime(iterator.peekNextKey.time) - } else { - setNextTimerTime(Long.MaxValue) - } - } finally { - iterator.close() - } - } - - private def setNextTimerTime(time: TimerTime): Unit = { - nextTimer = time - if (time != Long.MaxValue) { - trace(s"NextTimer: ${nextTimer.iso8601Millis}") - } - } - - private def cacheTimersIfEnabled(): Unit = { - if (cacheTimers) { - val iterator = timersStore.all() - try { - for (timerKeyValue <- iterator.asScala) { - val timer = timerKeyValue.key - cachedTimers.add(timer) - } - } finally { - iterator.close() - } - } - } - - private def resetThrottled(): Unit = { - throttled = false } - private def isThrottled: Boolean = { - throttled + private def shouldEmitWatermarkPerMessage(processorContext: ProcessorContext): Boolean = { + ConfigUtils + .getConfigOrElse( + configs = processorContext.appConfigs, + key = FinatraTransformerFlags.EmitWatermarkPerMessage, + default = "false").toBoolean } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala deleted file mode 100644 index 41324c56c5..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2.scala +++ /dev/null @@ -1,206 +0,0 @@ -package com.twitter.finatra.streams.transformer - -import com.google.common.annotations.Beta -import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.kafka.utils.ConfigUtils -import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging -import com.twitter.finatra.streams.flags.FinatraTransformerFlags -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.stores.internal.{ - FinatraKeyValueStoreImpl, - FinatraStoresGlobalManager -} -import com.twitter.finatra.streams.transformer.FinatraTransformer.TimerTime -import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} -import com.twitter.finatra.streams.transformer.internal.{OnClose, OnFlush, OnInit} -import com.twitter.finatra.streams.transformer.watermarks.internal.WatermarkManager -import com.twitter.finatra.streams.transformer.watermarks.{ - DefaultWatermarkAssignor, - WatermarkAssignor -} -import com.twitter.util.Duration -import org.apache.kafka.streams.kstream.Transformer -import org.apache.kafka.streams.processor.{ - Cancellable, - ProcessorContext, - PunctuationType, - Punctuator, - To -} -import scala.collection.mutable -import scala.reflect.ClassTag - -/** - * A KafkaStreams Transformer offering an upgraded API over the built in Transformer interface. - * - * This Transformer differs from the built in Transformer interface by exposing an [onMesssage] - * interface that is used to process incoming messages. Within [onMessage] you may use the - * [forward] method to emit 0 or more records. - * - * This transformer also manages watermarks(see [WatermarkManager]), and extends [OnWatermark] which - * allows you to track the passage of event time. - * - * Note: In time, this class will replace the deprecated FinatraTransformer class - * - * @tparam InputKey Type of the input keys - * @tparam InputValue Type of the input values - * @tparam OutputKey Type of the output keys - * @tparam OutputValue Type of the output values - */ -@Beta -abstract class FinatraTransformerV2[InputKey, InputValue, OutputKey, OutputValue]( - statsReceiver: StatsReceiver, - watermarkAssignor: WatermarkAssignor[InputKey, InputValue] = - new DefaultWatermarkAssignor[InputKey, InputValue]) - extends Transformer[InputKey, InputValue, (OutputKey, OutputValue)] - with OnInit - with OnWatermark - with OnClose - with OnFlush - with ProcessorContextLogging { - - protected[streams] val finatraKeyValueStoresMap: mutable.Map[String, FinatraKeyValueStore[_, _]] = - scala.collection.mutable.Map[String, FinatraKeyValueStore[_, _]]() - - /* Private Mutable */ - - @volatile private var _context: ProcessorContext = _ - @volatile private var watermarkTimerCancellable: Cancellable = _ - @volatile private var watermarkManager: WatermarkManager[InputKey, InputValue] = _ - - /* Abstract */ - - /** - * Callback method which is called for every message in the stream this Transformer is attached to. - * Implementers of this method may emit 0 or more records by using the processorContext. - * - * @param messageTime the time of the message - * @param key the key of the message - * @param value the value of the message - */ - protected[finatra] def onMessage(messageTime: Time, key: InputKey, value: InputValue): Unit - - /* Protected */ - - override protected def processorContext: ProcessorContext = _context - - final override def init(processorContext: ProcessorContext): Unit = { - _context = processorContext - - watermarkManager = new WatermarkManager[InputKey, InputValue]( - taskId = processorContext.taskId(), - transformerName = this.getClass.getSimpleName, - onWatermark = this, - watermarkAssignor = watermarkAssignor, - emitWatermarkPerMessage = shouldEmitWatermarkPerMessage(_context)) - - for ((name, store) <- finatraKeyValueStoresMap) { - store.init(processorContext, null) - } - - val autoWatermarkInterval = parseAutoWatermarkInterval(_context).inMillis - if (autoWatermarkInterval > 0) { - watermarkTimerCancellable = _context.schedule( - autoWatermarkInterval, - PunctuationType.WALL_CLOCK_TIME, - new Punctuator { - override def punctuate(timestamp: TimerTime): Unit = { - watermarkManager.callOnWatermarkIfChanged() - } - } - ) - } - - onInit() - } - - override def onFlush(): Unit = { - super.onFlush() - watermarkManager.callOnWatermarkIfChanged() - } - - override def onWatermark(watermark: Watermark): Unit = { - trace(s"onWatermark $watermark") - } - - final override def transform(k: InputKey, v: InputValue): (OutputKey, OutputValue) = { - /* Note: It's important to save off the message time before watermarkManager.onMessage is called - which can trigger persistent timers to fire, which can cause messages to be forwarded, which - can cause context.timestamp to be mutated to the forwarded message timestamp :-( */ - val messageTime = Time(_context.timestamp()) - - watermarkManager.onMessage(messageTime, _context.topic(), k, v) - debug(s"onMessage LastEmitted $watermark MessageTime $messageTime $k -> $v") - onMessage(messageTime, k, v) - null - } - - final override def close(): Unit = { - if (watermarkTimerCancellable != null) { - watermarkTimerCancellable.cancel() - watermarkTimerCancellable = null - } - watermarkManager.close() - - for ((name, store) <- finatraKeyValueStoresMap) { - store.close() - FinatraStoresGlobalManager.removeStore(store) - } - - onClose() - } - - final protected def getKeyValueStore[KK: ClassTag, VV]( - name: String - ): FinatraKeyValueStore[KK, VV] = { - val store = new FinatraKeyValueStoreImpl[KK, VV](name, statsReceiver) - - val previousStore = finatraKeyValueStoresMap.put(name, store) - assert(previousStore.isEmpty, s"getKeyValueStore was called for store $name more than once") - FinatraStoresGlobalManager.addStore(store) - - // Initialize stores that are still using the "lazy val store" pattern - if (processorContext != null) { - store.init(processorContext, null) - } - - store - } - - final protected def forward(key: OutputKey, value: OutputValue): Unit = { - debug(s"Forward ${_context.timestamp().iso8601Millis} $key $value") - _context.forward(key, value) - } - - final protected def forward(key: OutputKey, value: OutputValue, timestamp: Long): Unit = { - if (timestamp <= 10000) { - warn(s"Forward SMALL TIMESTAMP: $timestamp $key $value") - } else { - debug(s"Forward ${timestamp.iso8601Millis} $key $value") - } - - _context.forward(key, value, To.all().withTimestamp(timestamp)) - } - - final protected[finatra] def watermark: Watermark = { - watermarkManager.watermark - } - - private def parseAutoWatermarkInterval(processorContext: ProcessorContext): Duration = { - Duration.parse( - ConfigUtils.getConfigOrElse( - processorContext.appConfigs, - FinatraTransformerFlags.AutoWatermarkInterval, - "100.milliseconds" - ) - ) - } - - private def shouldEmitWatermarkPerMessage(processorContext: ProcessorContext): Boolean = { - ConfigUtils - .getConfigOrElse( - configs = processorContext.appConfigs, - key = FinatraTransformerFlags.EmitWatermarkPerMessage, - default = "false").toBoolean - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala index 3e7975232f..d328f74e09 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala @@ -3,8 +3,10 @@ package com.twitter.finatra.streams.transformer import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.streams.transformer.FinatraTransformer.WindowStartTime import com.twitter.finatra.streams.transformer.domain._ +import com.twitter.finatra.streams.transformer.internal.StateStoreImplicits import com.twitter.util.Duration import org.apache.kafka.streams.state.KeyValueIterator +import org.apache.kafka.streams.processor.PunctuationType @deprecated("Use AggregatorTransformer") class SumAggregator[K, V]( @@ -24,11 +26,12 @@ class SumAggregator[K, V]( K, V, TimeWindowed[K], - WindowStartTime, - TimeWindowed[K], WindowedValue[ Int - ]](timerStoreName = timerStoreName, statsReceiver = statsReceiver, cacheTimers = true) { + ]](statsReceiver = statsReceiver) + with PersistentTimers + with StateStoreImplicits + with IteratorImplicits { private val windowSizeMillis = windowSize.inMillis private val allowedLatenessMillis = allowedLateness.inMillis @@ -39,6 +42,11 @@ class SumAggregator[K, V]( private val expiredCounter = statsReceiver.counter("expiredWindows") private val stateStore = getKeyValueStore[TimeWindowed[K], Int](stateStoreName) + private val timerStore = getPersistentTimerStore[WindowStartTime]( + timerStoreName, + onEventTimer, + PunctuationType.STREAM_TIME + ) override def onMessage(time: Time, key: K, value: V): Unit = { val windowedKey = TimeWindowed.forSize( @@ -48,7 +56,7 @@ class SumAggregator[K, V]( ) val count = countToAggregate(key, value) - if (windowedKey.isLate(allowedLatenessMillis, Watermark(watermark))) { + if (windowedKey.isLate(allowedLatenessMillis, Watermark(watermark.timeMillis))) { restatementsCounter.incr() forward(windowedKey, WindowedValue(Restatement, count)) } else { @@ -56,21 +64,24 @@ class SumAggregator[K, V]( if (newCount == count) { val closeTime = windowedKey.startMs + windowSizeMillis + allowedLatenessMillis if (emitOnClose) { - addEventTimeTimer(Time(closeTime), Close, windowedKey.startMs) + timerStore.addTimer(Time(closeTime), Close, windowedKey.startMs) } - addEventTimeTimer(Time(closeTime + queryableAfterCloseMillis), Expire, windowedKey.startMs) + timerStore.addTimer( + Time(closeTime + queryableAfterCloseMillis), + Expire, + windowedKey.startMs + ) } } } - override def onEventTimer( + private def onEventTimer( time: Time, timerMetadata: TimerMetadata, - windowStartMs: WindowStartTime, - cursor: Option[TimeWindowed[K]] - ): TimerResult[TimeWindowed[K]] = { + windowStartMs: WindowStartTime + ): Unit = { val hourlyWindowIterator = stateStore.range( - cursor getOrElse TimeWindowed.forSize(windowStartMs, windowSizeMillis, keyRangeStart), + TimeWindowed.forSize(windowStartMs, windowSizeMillis, keyRangeStart), TimeWindowed.forSize(windowStartMs + 1, windowSizeMillis, keyRangeStart) ) @@ -88,7 +99,7 @@ class SumAggregator[K, V]( private def onClosed( windowStartMs: Long, windowIterator: KeyValueIterator[TimeWindowed[K], Int] - ): TimerResult[TimeWindowed[K]] = { + ): Unit = { windowIterator .take(maxActionsPerTimer) .foreach { @@ -96,12 +107,10 @@ class SumAggregator[K, V]( forward(key = key, value = WindowedValue(resultState = WindowClosed, value = value)) } - deleteOrRetainTimer(windowIterator, closedCounter.incr()) + closedCounter.incr() } - private def onExpired( - windowIterator: KeyValueIterator[TimeWindowed[K], Int] - ): TimerResult[TimeWindowed[K]] = { + private def onExpired(windowIterator: KeyValueIterator[TimeWindowed[K], Int]): Unit = { windowIterator .take(maxActionsPerTimer) .foreach { @@ -109,6 +118,6 @@ class SumAggregator[K, V]( stateStore.deleteWithoutGettingPriorValue(key) } - deleteOrRetainTimer(windowIterator, expiredCounter.incr()) + expiredCounter.incr() } } diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2Test.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerTest.scala similarity index 95% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2Test.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerTest.scala index 2143423e29..6a2e43868c 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerV2Test.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerTest.scala @@ -15,7 +15,7 @@ import org.apache.kafka.test.{InternalMockProcessorContext, NoOpRecordCollector, import org.hamcrest.{BaseMatcher, Description} import org.mockito.{Matchers, Mockito} -class FinatraTransformerV2Test extends Test with com.twitter.inject.Mockito { +class FinatraTransformerTest extends Test with com.twitter.inject.Mockito { val firstMessageTimestamp = 100000 val firstKey = "key1" val firstValue = "value1" @@ -26,7 +26,7 @@ class FinatraTransformerV2Test extends Test with com.twitter.inject.Mockito { test("watermark processing when forwarding from onMessage") { val transformer = - new FinatraTransformerV2[String, String, String, String](NullStatsReceiver) { + new FinatraTransformer[String, String, String, String](NullStatsReceiver) { override def onMessage(messageTime: Time, key: String, value: String): Unit = { forward(key, value, watermark.timeMillis) } @@ -52,7 +52,7 @@ class FinatraTransformerV2Test extends Test with com.twitter.inject.Mockito { test("watermark processing when forwarding from caching flush listener") { val transformer = - new FinatraTransformerV2[String, String, String, String](NullStatsReceiver) + new FinatraTransformer[String, String, String, String](NullStatsReceiver) with CachingKeyValueStores[String, String, String, String] { private val cache = getCachingKeyValueStore[String, String]("mystore") diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformerV2.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala similarity index 79% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformerV2.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala index 6fa26b74aa..344dad4ec6 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformerV2.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala @@ -3,17 +3,17 @@ package com.twitter.unittests.integration.finatratransformer import com.twitter.conversions.DurationOps._ import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.streams.transformer.domain.{Expire, Time, TimerMetadata} -import com.twitter.finatra.streams.transformer.{FinatraTransformerV2, PersistentTimers} -import com.twitter.unittests.integration.finatratransformer.WordLengthFinatraTransformerV2._ +import com.twitter.finatra.streams.transformer.{FinatraTransformer, PersistentTimers} +import com.twitter.unittests.integration.finatratransformer.WordLengthFinatraTransformer._ import com.twitter.util.Duration import org.apache.kafka.streams.processor.PunctuationType -object WordLengthFinatraTransformerV2 { +object WordLengthFinatraTransformer { val delayedMessageTime: Duration = 5.seconds } -class WordLengthFinatraTransformerV2(statsReceiver: StatsReceiver, timerStoreName: String) - extends FinatraTransformerV2[String, String, String, String](statsReceiver) +class WordLengthFinatraTransformer(statsReceiver: StatsReceiver, timerStoreName: String) + extends FinatraTransformer[String, String, String, String](statsReceiver) with PersistentTimers { private val timerStore = diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServer.scala index 98c8c6d26d..f1d05c7932 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServer.scala @@ -21,7 +21,7 @@ class WordLengthServer extends KafkaStreamsTwitterServer { FinatraTransformer.timerStore(timerStoreName, Serdes.String())) val transformerSupplier = () => - new WordLengthFinatraTransformerV2(statsReceiver, timerStoreName) + new WordLengthFinatraTransformer(statsReceiver, timerStoreName) streamsBuilder.asScala .stream(stringsAndInputsTopic)( From 0af2e4038223405a1c974819d603712aec1dd358 Mon Sep 17 00:00:00 2001 From: Eugene Burmako Date: Fri, 25 Jan 2019 02:03:16 +0000 Subject: [PATCH 14/45] Change Finagle and friends to be compatible with Rsc Problem Some Scala code in Finagle and friends is incompatible with Rsc. While the majority of these incompatibilities can be fixed automatically with the RscCompat rewrite (these fixes will be submitted in a separate pull request), there are a few problems that stem from missing functionality or bugs in Rsc. Recently, I fixed a significant number of tickets in Rsc, but there is some stuff left for full compatibility with Finagle and friends. Unfortunately, all that stuff is pretty challenging, which suggests that it may be more practical to work around. Solution I've manually worked around the following tickets on the Rsc issue tracker: * https://github.com/twitter/rsc/issues/91 * https://github.com/twitter/rsc/issues/100 * https://github.com/twitter/rsc/issues/224 * https://github.com/twitter/rsc/issues/263 * https://github.com/twitter/rsc/issues/268 * https://github.com/twitter/rsc/issues/273 * https://github.com/twitter/rsc/issues/294 * https://github.com/twitter/rsc/issues/331 Result All Scala code in Finagle and friends will be compatible with Rsc, at least to the degree required for our current internal Rsc test suite to pass. Differential Revision: https://phabricator.twitter.biz/D264602 --- .../com/twitter/inject/app/BindDSL.scala | 54 +++++++++---------- .../twitter/inject/server/TwitterServer.scala | 10 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/inject/inject-app/src/test/scala/com/twitter/inject/app/BindDSL.scala b/inject/inject-app/src/test/scala/com/twitter/inject/app/BindDSL.scala index ffdcf2fa27..98ad4daa0c 100644 --- a/inject/inject-app/src/test/scala/com/twitter/inject/app/BindDSL.scala +++ b/inject/inject-app/src/test/scala/com/twitter/inject/app/BindDSL.scala @@ -58,7 +58,7 @@ private[twitter] trait BindDSL { self => def bindClass[T](clazz: Class[T]): ClassDSL[T] = new ClassDSL[T](clazz) /** For Java Compatibility */ - def bindClass[T](clazz: Class[T], instance: T): self.type = { + def bindClass[T](clazz: Class[T], instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).toInstance(instance) @@ -68,7 +68,7 @@ private[twitter] trait BindDSL { self => } /** For Java Compatibility */ - def bindClass[T](clazz: Class[T], annotation: Annotation, instance: T): self.type = { + def bindClass[T](clazz: Class[T], annotation: Annotation, instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotation).toInstance(instance) @@ -78,7 +78,7 @@ private[twitter] trait BindDSL { self => } /** For Java Compatibility */ - def bindClass[T, Ann <: Annotation](clazz: Class[T], annotationClazz: Class[Ann], instance: T): self.type = { + def bindClass[T, Ann <: Annotation](clazz: Class[T], annotationClazz: Class[Ann], instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotationClazz).toInstance(instance) @@ -88,7 +88,7 @@ private[twitter] trait BindDSL { self => } /** For Java Compatibility */ - def bindClass[T, U <: T](clazz: Class[T], instanceClazz: Class[U]): self.type = { + def bindClass[T, U <: T](clazz: Class[T], instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).to(instanceClazz) @@ -98,7 +98,7 @@ private[twitter] trait BindDSL { self => } /** For Java Compatibility */ - def bindClass[T, U <: T](clazz: Class[T], annotation: Annotation, instanceClazz: Class[U]): self.type = { + def bindClass[T, U <: T](clazz: Class[T], annotation: Annotation, instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotation).to(instanceClazz) @@ -108,7 +108,7 @@ private[twitter] trait BindDSL { self => } /** For Java Compatibility */ - def bindClass[T, Ann <: Annotation, U <: T](clazz: Class[T], annotationClazz: Class[Ann], instanceClazz: Class[U]): self.type = { + def bindClass[T, Ann <: Annotation, U <: T](clazz: Class[T], annotationClazz: Class[Ann], instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotationClazz).to(instanceClazz) @@ -120,7 +120,7 @@ private[twitter] trait BindDSL { self => /* Private */ private[app] class TypeDSL[T: TypeTag] { - def toInstance(instance: T): self.type = { + def toInstance(instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).toInstance(instance) @@ -129,7 +129,7 @@ private[twitter] trait BindDSL { self => self } - def to[U <: T: TypeTag]: self.type = { + def to[U <: T: TypeTag]: BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).to(asManifest[U]) @@ -138,7 +138,7 @@ private[twitter] trait BindDSL { self => self } - def to[U <: T: TypeTag](instanceClazz: Class[U]): self.type = { + def to[U <: T: TypeTag](instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).to(instanceClazz) @@ -155,7 +155,7 @@ private[twitter] trait BindDSL { self => } private[app] class TypeAnnotationDSL[T: TypeTag, Ann <: Annotation: TypeTag] extends TypeDSL[T] { - override def toInstance(instance: T): self.type = { + override def toInstance(instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T], asManifest[Ann]).toInstance(instance) @@ -164,7 +164,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T: TypeTag]: self.type = { + override def to[U <: T: TypeTag]: BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(asManifest[Ann]).to(asManifest[U]) @@ -173,7 +173,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T: TypeTag](instanceClazz: Class[U]): self.type = { + override def to[U <: T: TypeTag](instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(asManifest[Ann]).to(instanceClazz) @@ -184,7 +184,7 @@ private[twitter] trait BindDSL { self => } private[app] class TypeAnnotationClassDSL[T: TypeTag, Ann <: Annotation](annotationClazz: Class[Ann]) extends TypeDSL[T] { - override def toInstance(instance: T): self.type = { + override def toInstance(instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(annotationClazz).toInstance(instance) @@ -193,7 +193,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T: TypeTag]: self.type = { + override def to[U <: T: TypeTag]: BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(annotationClazz).to(asManifest[U]) @@ -202,7 +202,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T: TypeTag](instanceClazz: Class[U]): self.type = { + override def to[U <: T: TypeTag](instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(annotationClazz).to(instanceClazz) @@ -213,7 +213,7 @@ private[twitter] trait BindDSL { self => } private[app] class TypeWithNamedAnnotationDSL[T: TypeTag](annotation: Annotation) extends TypeDSL[T] { - override def toInstance(instance: T): self.type = { + override def toInstance(instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(annotation).toInstance(instance) @@ -222,7 +222,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T: TypeTag]: self.type = { + override def to[U <: T: TypeTag]: BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(annotation).to(asManifest[U]) @@ -231,7 +231,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T: TypeTag](instanceClazz: Class[U]): self.type = { + override def to[U <: T: TypeTag](instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(asManifest[T]).annotatedWith(annotation).to(instanceClazz) @@ -242,7 +242,7 @@ private[twitter] trait BindDSL { self => } private[app] class ClassDSL[T](clazz: Class[T]) { - def toInstance(instance: T): self.type = { + def toInstance(instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).toInstance(instance) @@ -251,7 +251,7 @@ private[twitter] trait BindDSL { self => self } - def to[U <: T : TypeTag : ClassTag]: self.type = { + def to[U <: T : TypeTag : ClassTag]: BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).to(typeLiteral[U](asManifest[U])) @@ -260,7 +260,7 @@ private[twitter] trait BindDSL { self => self } - def to[U <: T](instanceClazz: Class[U]): self.type = { + def to[U <: T](instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).to(instanceClazz) @@ -275,7 +275,7 @@ private[twitter] trait BindDSL { self => } private[app] class ClassAnnotationDSL[T, Ann <: Annotation](clazz: Class[T], annotationClazz: Class[Ann]) extends ClassDSL(clazz) { - override def toInstance(instance: T): self.type = { + override def toInstance(instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotationClazz).toInstance(instance) @@ -284,7 +284,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T : TypeTag : ClassTag]: self.type = { + override def to[U <: T : TypeTag : ClassTag]: BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotationClazz).to(typeLiteral[U](asManifest[U])) @@ -293,7 +293,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T](instanceClazz: Class[U]): self.type = { + override def to[U <: T](instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotationClazz).to(instanceClazz) @@ -304,7 +304,7 @@ private[twitter] trait BindDSL { self => } private[app] class ClassWithNamedAnnotationDSL[T](clazz: Class[T], annotation: Annotation) extends ClassDSL(clazz) { - override def toInstance(instance: T): self.type = { + override def toInstance(instance: T): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotation).toInstance(instance) @@ -313,7 +313,7 @@ private[twitter] trait BindDSL { self => self } - override def to[U <: T : TypeTag : ClassTag]: self.type = { + override def to[U <: T : TypeTag : ClassTag]: BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotation).to(typeLiteral[U](asManifest[U])) @@ -323,7 +323,7 @@ private[twitter] trait BindDSL { self => } - override def to[U <: T](instanceClazz: Class[U]): self.type = { + override def to[U <: T](instanceClazz: Class[U]): BindDSL.this.type = { addInjectionServiceModule(new TwitterModule { override def configure(): Unit = { bind(clazz).annotatedWith(annotation).to(instanceClazz) diff --git a/inject/inject-server/src/main/scala/com/twitter/inject/server/TwitterServer.scala b/inject/inject-server/src/main/scala/com/twitter/inject/server/TwitterServer.scala index d5f07b2fee..414f523de7 100644 --- a/inject/inject-server/src/main/scala/com/twitter/inject/server/TwitterServer.scala +++ b/inject/inject-server/src/main/scala/com/twitter/inject/server/TwitterServer.scala @@ -172,7 +172,7 @@ trait TwitterServer /* Overrides */ override final def main(): Unit = { - super.main() // Call inject.App.main() to create Injector + super[App].main() // Call inject.App.main() to create Injector info("Startup complete, server awaiting.") Awaiter.any(awaitables.asScala, period = 1.second) @@ -190,7 +190,7 @@ trait TwitterServer */ @Lifecycle override protected def postInjectorStartup(): Unit = { - super.postInjectorStartup() + super[App].postInjectorStartup() if (resolveFinagleClientsOnStartup) { info("Resolving Finagle clients before warmup") @@ -278,7 +278,7 @@ trait TwitterServer */ @Lifecycle override protected def beforePostWarmup(): Unit = { - super.beforePostWarmup() + super[App].beforePostWarmup() // trigger gc before accepting traffic prebindWarmup() @@ -298,7 +298,7 @@ trait TwitterServer */ @Lifecycle override protected def postWarmup(): Unit = { - super.postWarmup() + super[App].postWarmup() if (disableAdminHttpServer) { info("Disabling the Admin HTTP Server since disableAdminHttpServer=true") @@ -324,7 +324,7 @@ trait TwitterServer */ @Lifecycle override protected def afterPostWarmup(): Unit = { - super.afterPostWarmup() + super[App].afterPostWarmup() if (!disableAdminHttpServer) { info("admin http server started on port " + PortUtils.getPort(adminHttpServer)) From 3747c1ab12abd53739e277834370f1e8c951b0ad Mon Sep 17 00:00:00 2001 From: Christopher Coco Date: Fri, 25 Jan 2019 20:17:00 +0000 Subject: [PATCH 15/45] finatra-jackson: Fix annotation reflection for Scala 2.12 Problem The `CaseClassField#findAnnotation` reflection code does not work as expected in Scala 2.12. Solution Update the code to work properly in both Scala 2.11 and Scala 2.12, and ensure we have test cases for regression. JIRA Issues: CSL-7527 Differential Revision: https://phabricator.twitter.biz/D264423 --- CHANGELOG.rst | 2 + .../JacksonIntegrationServerFeatureTest.scala | 32 +++++ .../json/PersonWithThingsRequest.scala | 12 -- .../tests/integration/json/caseclasses.scala | 131 ++++++++++++++++++ .../caseclass/jackson/CaseClassField.scala | 24 +++- .../caseclass/jackson/caseclasses.scala | 6 +- 6 files changed, 190 insertions(+), 17 deletions(-) delete mode 100644 http/src/test/scala/com/twitter/finatra/http/tests/integration/json/PersonWithThingsRequest.scala create mode 100644 http/src/test/scala/com/twitter/finatra/http/tests/integration/json/caseclasses.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e8318f1e61..0c364c57f3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,8 @@ Added Changed ~~~~~~~ +* finatra-jackson: Fix `CaseClassField` annotation reflection for Scala 2.12. ``PHAB_ID=D264423`` + * finatra-kafka-streams: Combine FinatraTransformer with FinatraTransformerV2. ``PHAB_ID=D254411`` * finatra-thrift: The return type of `ReqRepDarkTrafficFilterModule#newFilter` has been changed from diff --git a/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/JacksonIntegrationServerFeatureTest.scala b/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/JacksonIntegrationServerFeatureTest.scala index 0a2da498b2..5a3bba7a5d 100644 --- a/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/JacksonIntegrationServerFeatureTest.scala +++ b/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/JacksonIntegrationServerFeatureTest.scala @@ -1,5 +1,6 @@ package com.twitter.finatra.http.tests.integration.json +import com.twitter.finagle.http.{Request, Response} import com.twitter.finagle.http.Status.BadRequest import com.twitter.finatra.http.{Controller, EmbeddedHttpServer, HttpServer} import com.twitter.finatra.http.filters.CommonFilters @@ -20,6 +21,19 @@ class JacksonIntegrationServerFeatureTest extends FeatureTest { post("/personWithThings") { _: PersonWithThingsRequest => "Accepted" } + + get("/users/lookup") { request: UserLookupRequest => + Map( + "ids" -> request.ids, + "names" -> request.names, + "format" -> request.format, + "userFormat" -> request.userFormat, + "statusFormat" -> request.statusFormat, + "acceptHeader" -> request.acceptHeader, + "validationPassesForIds" -> request.validationPassesForIds, + "validationPassesForNames" -> request.validationPassesForNames + ) + } } ) } @@ -47,4 +61,22 @@ class JacksonIntegrationServerFeatureTest extends FeatureTest { andExpect = BadRequest, withJsonBody = """{"errors":["things: Unable to parse"]}""") } + + test("/GET UserLookup") { + + val response: Response = server.httpGet( + "/users/lookup?ids=21345", + headers = Map("accept" -> "application/vnd.foo+json") + ) + + response.status.code shouldBe 200 + val responseMap = server.mapper.parse[Map[String, String]](response.contentString) + responseMap("ids") should be("21345") + responseMap("format") should be(null) + responseMap("userFormat") should be(null) + responseMap("statusFormat") should be(null) + responseMap("validationPassesForIds").toBoolean should be(true) + responseMap("validationPassesForNames").toBoolean should be(true) + responseMap("acceptHeader") should be("application/vnd.foo+json") + } } diff --git a/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/PersonWithThingsRequest.scala b/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/PersonWithThingsRequest.scala deleted file mode 100644 index 27462051b5..0000000000 --- a/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/PersonWithThingsRequest.scala +++ /dev/null @@ -1,12 +0,0 @@ -package com.twitter.finatra.http.tests.integration.json - -import com.twitter.finatra.validation.Size - -case class PersonWithThingsRequest( - id: Int, - name: String, - age: Option[Int], - @Size(min = 1, max = 10) things: Map[String, Things]) - -case class Things( - @Size(min = 1, max = 2) names: Seq[String]) diff --git a/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/caseclasses.scala b/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/caseclasses.scala new file mode 100644 index 0000000000..eb3173a95c --- /dev/null +++ b/http/src/test/scala/com/twitter/finatra/http/tests/integration/json/caseclasses.scala @@ -0,0 +1,131 @@ +package com.twitter.finatra.http.tests.integration.json + +import com.twitter.finatra.request.{Header, QueryParam} +import com.twitter.finatra.validation.{MethodValidation, Size, ValidationResult} + +case class PersonWithThingsRequest( + id: Int, + name: String, + age: Option[Int], + @Size(min = 1, max = 10) things: Map[String, Things]) + +case class Things( + @Size(min = 1, max = 2) names: Seq[String]) + +trait TestRequest { + protected[this] val ValidFormats: Seq[String] = Seq("compact", "default", "detailed") + + protected[this] def validateFormat(formatValue: Option[String], formatKey: String): ValidationResult = { + if (formatValue.isEmpty) { + ValidationResult.Valid + } else { + val actualFormat = formatValue.get + val errorMsg = s"Bad parameter value: <$actualFormat>." + + s" The only format values allowed for <$formatKey> are ${ValidFormats.mkString(",")}" + ValidationResult.validate(ValidFormats.contains(actualFormat), errorMsg) + } + } + + protected[this] def validateListOfLongIds(commaSeparatedListOfIds: String): Boolean = { + val actualIdsString = commaSeparatedListOfIds.trim() + if (actualIdsString.isEmpty) { + false + } else { + actualIdsString + .split("\\,").map { anEntry => + anEntry.trim.nonEmpty && anEntry.matches("\\d+") + }.forall(_ == true) + } + } + + protected[this] def validateListOfUsers(users: Option[String]): Boolean = { + users.forall { names => + val namesTrimmed = names.trim() + if (namesTrimmed.isEmpty) { + false + } else { + namesTrimmed + .split("\\,").map { anEntry => + anEntry.trim.nonEmpty + }.forall(_ == true) + } + } + } + + protected[this] def extractListOfLongIds(idsString: String): Seq[Long] = { + val items = idsString.trim().split(",") + items.map { anItem => + anItem.toLong + }.toSeq + } + + def listOfStrings(namesValue: Option[String]): Seq[String] = { + { + namesValue.map { + _.split(",").toSeq + } + }.getOrElse(Seq[String]()) + } + + def createErrorMessage(paramName: String, badValue: String, errMsg: String): String = { + s"Bad Value: '$badValue' for parameter '$paramName'. $errMsg" + } + + def createErrorMessage(paramName: String, badValue: Option[String], errMsg: String): String = { + createErrorMessage(paramName, badValue.getOrElse("None"), errMsg) + } +} +case class UserLookupRequest( + @QueryParam ids: Option[String] = None, + @QueryParam names: Option[String] = None, + @QueryParam format: Option[String] = None, + @QueryParam("user.format") userFormat: Option[String] = None, + @QueryParam("status.format") statusFormat: Option[String] = None, + @Header("Accept") acceptHeader: Option[String] = None) + extends TestRequest { + + lazy val validationPassesForIds: Boolean = ids.forall(validateListOfLongIds) + lazy val validationPassesForNames: Boolean = validateListOfUsers(names) + + @MethodValidation + def validateIds(): ValidationResult = + ValidationResult.validate( + validationPassesForIds, + createErrorMessage("ids", ids, "Must be a comma separated list of decimal numbers.") + ) + + @MethodValidation + def validateNames(): ValidationResult = { + ValidationResult.validate( + validationPassesForNames, + createErrorMessage( + "names", + names, + "Must be a comma separated list of names." + ) + ) + } + + @MethodValidation + def validateUserFormat(): ValidationResult = + validateFormat(userFormat, "user.format") + + @MethodValidation + def validateStatusFormat(): ValidationResult = + validateFormat(statusFormat, "status.format") + + @MethodValidation + def validateMinimalRequestParams: ValidationResult = { + // in case one of the validations failed, don't add this error message + val atLeastOneValidEntry = + (!validationPassesForIds || !validationPassesForNames) || + listOfIds.nonEmpty || listOfNames.nonEmpty + ValidationResult.validate( + atLeastOneValidEntry, + "At least one valid id or one valid name must be provided" + ) + } + + private[this] val listOfIds: Seq[Long] = ids.fold(Seq.empty[Long])(extractListOfLongIds) + private[this] val listOfNames: Seq[String] = listOfStrings(names) +} diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala index 0e0fad9cfb..87179e2b41 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala @@ -68,16 +68,30 @@ private[finatra] object CaseClassField { // for case classes, the annotations are only visible on the constructor. val clazzAnnotationsArray: Array[Array[Annotation]] = clazz.getConstructors.head.getParameterAnnotations - val clazzFields = clazz.getDeclaredFields + + val clazzConstructorParamNames: Array[String] = + clazz.getConstructors.head.getParameters.map(_.getName) + val clazzDeclaredFieldNames: Array[String] = clazz.getDeclaredFields.map(_.getName) + + /* NOTE: we want to prefer using the constructor Array[Parameter] names, however in Scala 2.11 + Parameters don't have actual names (only "arg" + index), so we fall back to relying on + the declared fields found via reflection -- which conversely is incorrect in Scala 2.12 + for this purpose. */ + val clazzFields: Array[String] = + if (clazzConstructorParamNames.exists(!_.startsWith("arg"))) { + clazzConstructorParamNames + } else { + clazzDeclaredFieldNames + } val clazzAnnotations: Map[String, Seq[Annotation]] = (for { (field, index) <- clazzFields.zipWithIndex } yield { Try(clazzAnnotationsArray.apply(index)) match { case Return(annotations) if annotations.nonEmpty => - field.getName -> annotations.toSeq + field -> annotations.toSeq case _ => - field.getName -> Nil + field -> Nil } }).toMap @@ -208,7 +222,9 @@ private[finatra] case class CaseClassField( ): Object = { if (fieldInjection.isInjectable) fieldInjection - .inject(context, codec).orElse(defaultValue).getOrElse(throwRequiredFieldException()) + .inject(context, codec) + .orElse(defaultValue) + .getOrElse(throwRequiredFieldException()) else { val fieldJsonNode = objectJsonNode.get(name) if (fieldJsonNode != null && !fieldJsonNode.isNull) diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala index 4e2080b98f..c3e72bb7d2 100644 --- a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/caseclass/jackson/caseclasses.scala @@ -64,5 +64,9 @@ case class TestTraitImpl( @JsonDeserialize(contentAs = classOf[BigDecimal], using = classOf[BigDecimalDeserializer]) double: BigDecimal, @JsonIgnore ignoreMe: String -) extends TestTrait +) extends TestTrait { + + lazy val testFoo: String = "foo" + lazy val testBar: String = "bar" +} From afd9a17c9d7223065dfb37e7d6c006da21c1b2d4 Mon Sep 17 00:00:00 2001 From: Vaughn Ganem Haka Date: Mon, 28 Jan 2019 19:39:05 +0000 Subject: [PATCH 16/45] kafka-streams: State and Store improvements Problem Finatra Kafka Streams is currently lacking ScalaDocs and contains various open TODOs Solution This branch introduces ScalaDocs and resolves several open TODOs Describe the modifications you've done. Additional docstrings have been added. Adding a metric for elapsed state restore time. RockDB shard bits is now modifiable by a flag. Result RocksDB configuration now supports a `rocksdb.block.cache.shard.bits` flag for specifying the number of block cache shard bits. Time spent restoring state is now emitted as a Metric, `kafka/stream/finatra_state_restore_listener/restore_time_elapsed_ms` JIRA Issues: DINS-2533 Differential Revision: https://phabricator.twitter.biz/D255771 --- CHANGELOG.rst | 4 +++ .../config/FinatraRocksDBConfig.scala | 6 +++- .../FinatraStateRestoreListener.scala | 29 ++++++++++++++++--- .../WordCountServerFeatureTest.scala | 5 ++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0c364c57f3..28ed968efd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,10 @@ Unreleased Added ~~~~~ +* finatra-kafka-streams: Adding missing ScalaDocs. Adding metric for elapsed state + restore time. RocksDB configuration now contains a flag for adjusting the number + of cache shard bits, `rocksdb.block.cache.shard.bits`. ``PHAB_ID=D255771`` + * finatra-jackson: Added @Pattern annotation to support finatra/jackson for regex pattern validation on string values. ``PHAB_ID=D259719`` diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala index 93fd29f238..43bc8e8da4 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala @@ -25,6 +25,7 @@ import org.rocksdb.{ object FinatraRocksDBConfig { val RocksDbBlockCacheSizeConfig = "rocksdb.block.cache.size" + val RocksDbBlockCacheShardBitsConfig = "rocksdb.block.cache.shard.bits" val RocksDbLZ4Config = "rocksdb.lz4" val RocksDbEnableStatistics = "rocksdb.statistics" val RocksDbStatCollectionPeriodMs = "rocksdb.statistics.collection.period.ms" @@ -44,6 +45,9 @@ object FinatraRocksDBConfig { } } +/** + * Maintains the RocksDB configuration used by Kafka Streams. + */ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { //See https://github.com/facebook/rocksdb/wiki/Setup-Options-and-Basic-Tuning#other-general-options @@ -55,7 +59,7 @@ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { if (FinatraRocksDBConfig.SharedBlockCache == null) { val blockCacheSize = getBytesOrDefault(configs, FinatraRocksDBConfig.RocksDbBlockCacheSizeConfig, 100.megabytes) - val numShardBits = 1 //TODO: Make configurable so this can be increased for multi-threaded queryable state access + val numShardBits = getIntOrDefault(configs, FinatraRocksDBConfig.RocksDbBlockCacheShardBitsConfig, 1) FinatraRocksDBConfig.SharedBlockCache = new LRUCache(blockCacheSize, numShardBits) } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/listeners/FinatraStateRestoreListener.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/listeners/FinatraStateRestoreListener.scala index b52d2a2308..d28e973b6d 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/listeners/FinatraStateRestoreListener.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/listeners/FinatraStateRestoreListener.scala @@ -4,18 +4,31 @@ import com.twitter.finagle.stats.StatsReceiver import com.twitter.util.logging.Logging import org.apache.kafka.common.TopicPartition import org.apache.kafka.streams.processor.StateRestoreListener +import org.joda.time.DateTimeUtils -class FinatraStateRestoreListener( - statsReceiver: StatsReceiver) //TODO: Add stats for restoration (e.g. total time) +/** + * A [[StateRestoreListener]] that emits logs and metrics relating to state restoration. + * + * @param statsReceiver A StatsReceiver used for metric tracking. + */ +private[kafkastreams] class FinatraStateRestoreListener(statsReceiver: StatsReceiver) extends StateRestoreListener with Logging { + private val scopedStatReceiver = statsReceiver.scope("finatra_state_restore_listener") + private val totalRestoreTime = + scopedStatReceiver.addGauge("restore_time_elapsed_ms")(restoreTimeElapsedMs) + + private var restoreTimestampStartMs: Option[Long] = None + private var restoreTimestampEndMs: Option[Long] = None + override def onRestoreStart( topicPartition: TopicPartition, storeName: String, startingOffset: Long, endingOffset: Long ): Unit = { + restoreTimestampStartMs = Some(DateTimeUtils.currentTimeMillis) val upToRecords = endingOffset - startingOffset info( s"${storeAndPartition(storeName, topicPartition)} start restoring up to $upToRecords records from $startingOffset to $endingOffset" @@ -36,12 +49,20 @@ class FinatraStateRestoreListener( storeName: String, totalRestored: Long ): Unit = { + restoreTimestampEndMs = Some(DateTimeUtils.currentTimeMillis) info( - s"${storeAndPartition(storeName, topicPartition)} finished restoring $totalRestored records" + s"${storeAndPartition(storeName, topicPartition)} finished restoring $totalRestored records in $restoreTimeElapsedMs ms" ) } - private def storeAndPartition(storeName: String, topicPartition: TopicPartition) = { + private def storeAndPartition(storeName: String, topicPartition: TopicPartition): String = { s"$storeName topic ${topicPartition.topic}_${topicPartition.partition}" } + + private def restoreTimeElapsedMs: Long = { + val currentTimestampMs = DateTimeUtils.currentTimeMillis + restoreTimestampEndMs.getOrElse(currentTimestampMs) - restoreTimestampStartMs.getOrElse( + currentTimestampMs + ) + } } diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerFeatureTest.scala index e4bca39e69..7ea6f4b355 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerFeatureTest.scala @@ -96,6 +96,11 @@ class WordCountServerFeatureTest extends KafkaStreamsMultiServerFeatureTest { val serverAfterRestart = createServer() serverAfterRestart.start() + val serverAfterRestartStats = InMemoryStatsUtil(serverAfterRestart.injector) + serverAfterRestartStats.waitForGaugeUntil( + "kafka/stream/finatra_state_restore_listener/restore_time_elapsed_ms", + _ >= 0 + ) textLinesTopic.publish(1L -> "world world") wordsWithCountsTopic.consumeAsManyMessagesUntilMap(Map("world" -> 5L)) From e97441b2276545abc7a0204c344616214a7ea194 Mon Sep 17 00:00:00 2001 From: Ian Bennett Date: Mon, 28 Jan 2019 20:12:00 +0000 Subject: [PATCH 17/45] finatra/http|inject: add hooks for customizing tls behavior of Embedded and External test HttpClient's Problem/Solution Finatra HTTP clients available via EmbeddedHttpServer cannot be configured with customized TLS behavior. This introduces a way for the framework to customize behavior, internally. JIRA Issues: CSL-7514 Differential Revision: https://phabricator.twitter.biz/D265257 --- .../scala/com/twitter/finatra/http/ExternalHttpClient.scala | 5 ++++- .../scala/com/twitter/inject/server/EmbeddedHttpClient.scala | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala b/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala index ae633cbb27..692d33203d 100644 --- a/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala +++ b/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala @@ -26,6 +26,9 @@ private[twitter] trait ExternalHttpClient { self: EmbeddedTwitterServer => /** Provide an override to the underlying server's mapper */ def mapperOverride: Option[FinatraObjectMapper] + /** Provide an override to the external HTTPS client */ + private[twitter] def httpsClientOverride: Option[JsonAwareEmbeddedHttpClient] = None + /* Overrides */ /** Logs the external http and/or https host and port of the underlying EmbeddedHttpServer */ @@ -119,7 +122,7 @@ private[twitter] trait ExternalHttpClient { self: EmbeddedTwitterServer => client } - final lazy val httpsClient: JsonAwareEmbeddedHttpClient = { + final lazy val httpsClient: JsonAwareEmbeddedHttpClient = httpsClientOverride.getOrElse { val client = new JsonAwareEmbeddedHttpClient( "httpsClient", httpsExternalPort(), diff --git a/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedHttpClient.scala b/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedHttpClient.scala index ec1496b731..e0da16da78 100644 --- a/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedHttpClient.scala +++ b/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedHttpClient.scala @@ -73,7 +73,7 @@ private[twitter] class EmbeddedHttpClient private[twitter] ( .withStreaming(_streamResponses) .withLabel(label) if (tls) { - client = client.withTlsWithoutValidation + client = configureTls(client) } private[twitter] val service: Service[Request, Response] = @@ -196,6 +196,9 @@ private[twitter] class EmbeddedHttpClient private[twitter] ( protected def disableLogging(suppress: Boolean): Boolean = suppress || this._disableLogging + protected[twitter] def configureTls(client: Http.Client): Http.Client = + client.withTlsWithoutValidation + /* Private */ // Deletes request headers with null values in map. From 3ca4437cf9b2755692375de4e468d720a78836a4 Mon Sep 17 00:00:00 2001 From: Ian Bennett Date: Tue, 29 Jan 2019 02:39:36 +0000 Subject: [PATCH 18/45] finatra/inject-server: EmbeddedTwitterServer that fails to start will now continue to throw startup failure on calls that require a successfully started server Problem Extensions of EmbeddedTwitterServer can result in undefined behavior, if initial startup fails and other methods of the server are still accessed. Example of this are accessing the `injector` or `externalThriftHostAndPort` on a server that has failed startup, which will return values as if the server was started correctly. Solution Modify the `start()` method to ensure that a startup failure has not been seen. Otherwise, if `start()` has failed, it will throw an IllegalStateException specifying that the server had failed to start, with the original failure as the cause. Result EmbeddedTwitterServer methods that require a succcessful start() will throw an IllegalStateException with the original start failure cause, if the initial start() of the server fails. JIRA Issues: CSL-7548 Differential Revision: https://phabricator.twitter.biz/D265543 --- CHANGELOG.rst | 4 ++++ .../inject/server/EmbeddedTwitterServer.scala | 13 ++++++++--- ...EmbeddedTwitterServerIntegrationTest.scala | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 28ed968efd..f570ae60d9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -52,6 +52,10 @@ Changed admin page. ``PHAB_ID=D259597`` +* inject-server: EmbeddedTwitterServer that fails to start will now continue to + throw the startup failure on calls to methods that require a successfully started server. + ``PHAB_ID=D265543`` + Fixed ~~~~~ diff --git a/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedTwitterServer.scala b/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedTwitterServer.scala index 839f6e34ca..e2224ace51 100644 --- a/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedTwitterServer.scala +++ b/inject/inject-server/src/test/scala/com/twitter/inject/server/EmbeddedTwitterServer.scala @@ -205,8 +205,17 @@ class EmbeddedTwitterServer( started = true //mutation starting = false //mutation } + + //because start() can be called lazily multiple times, + //subsequent calls will not enter the initialization loop. + //we need to throw here if there was an error for the initial start() call. + throwIfStartupFailed() } + /** If the [[startupFailedThrowable]] is defined, [[throwStartupFailedException]] */ + private def throwIfStartupFailed(): Unit = + if (startupFailedThrowable.isDefined) throwStartupFailedException() + /** Assert the underlying TwitterServer has started */ def assertStarted(started: Boolean = true): Unit = { assert(isInjectable) @@ -448,9 +457,7 @@ class EmbeddedTwitterServer( for (_ <- 1 to maxStartupTimeSeconds) { info("Waiting for warmup phases to complete...", disableLogging) - if (startupFailedThrowable.isDefined) { - throwStartupFailedException() - } + throwIfStartupFailed() if ((isInjectable && injectableServer.started) || (!isInjectable && nonInjectableServerStarted)) { diff --git a/inject/inject-server/src/test/scala/com/twitter/inject/server/tests/EmbeddedTwitterServerIntegrationTest.scala b/inject/inject-server/src/test/scala/com/twitter/inject/server/tests/EmbeddedTwitterServerIntegrationTest.scala index af060d4883..20916814f4 100644 --- a/inject/inject-server/src/test/scala/com/twitter/inject/server/tests/EmbeddedTwitterServerIntegrationTest.scala +++ b/inject/inject-server/src/test/scala/com/twitter/inject/server/tests/EmbeddedTwitterServerIntegrationTest.scala @@ -88,6 +88,29 @@ class EmbeddedTwitterServerIntegrationTest extends Test { } } + test("server#failed startup throws startup error on future method calls") { + val server = new EmbeddedTwitterServer( + twitterServer = new TwitterServer {}, + flags = Map("foo.bar" -> "true"), + disableTestLogging = true + ) + + try { + val e = intercept[Exception] { + server.assertHealthy() + } + + val e2 = intercept[Exception] { //accessing the injector requires a started server + server.injector + } + + e.getMessage.contains("Error parsing flag \"foo.bar\": flag undefined") should be(true) + e.getMessage equals(e2.getMessage) + } finally { + server.close() + } + } + test("server#injector error") { val server = new EmbeddedTwitterServer( stage = Stage.PRODUCTION, From bcbb57748bbfad717d3151353a8962a58b20b593 Mon Sep 17 00:00:00 2001 From: Brent Halsey Date: Tue, 29 Jan 2019 16:26:34 +0000 Subject: [PATCH 19/45] finatra-kafka: Expose KafkaConsumer.endOffsets in FinagleKafkaConsumer Problem KafkaConsumer.offsetsForTimes() returns null for times beyond the last offset. In this case, the value from KafkaConsumer.endOffsets() is useful. Solution Expose KafkaConsumer.endOffsets in FinagleKafkaConsumer Result FinagleKafkaConsumer users have access to endOffsets() JIRA Issues: DACC-1216 Differential Revision: https://phabricator.twitter.biz/D263573 --- CHANGELOG.rst | 2 + .../consumers/FinagleKafkaConsumer.scala | 13 ++++ .../FinagleKafkaConsumerIntegrationTest.scala | 72 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 kafka/src/test/scala/com/twitter/finatra/kafka/test/integration/FinagleKafkaConsumerIntegrationTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f570ae60d9..ae678282ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,8 @@ Unreleased Added ~~~~~ +* finatra-kafka: Expose endOffsets() in FinagleKafkaConsumer. ``PHAB_ID=D263573`` + * finatra-kafka-streams: Adding missing ScalaDocs. Adding metric for elapsed state restore time. RocksDB configuration now contains a flag for adjusting the number of cache shard bits, `rocksdb.block.cache.shard.bits`. ``PHAB_ID=D255771`` diff --git a/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/FinagleKafkaConsumer.scala b/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/FinagleKafkaConsumer.scala index a8e69a7b64..696e87e2f5 100644 --- a/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/FinagleKafkaConsumer.scala +++ b/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/FinagleKafkaConsumer.scala @@ -136,6 +136,19 @@ class FinagleKafkaConsumer[K, V](config: FinagleKafkaConsumerConfig[K, V]) singleThreadFuturePool(consumer.offsetsForTimes(timestampsToSearch)) } + /** + * Get the end offsets for the given partitions. In the default {@code read_uncommitted} isolation level, the end + * offset is the high watermark (that is, the offset of the last successfully replicated message plus one). For + * {@code read_committed} consumers, the end offset is the last stable offset (LSO), which is the minimum of + * the high watermark and the smallest offset of any open transaction. Finally, if the partition has never been + * written to, the end offset is 0. + */ + def endOffsets( + partitions: Seq[TopicPartition] + ): Future[util.Map[TopicPartition, java.lang.Long]] = { + singleThreadFuturePool(consumer.endOffsets(partitions.asJavaCollection)) + } + /** * @param timeout The time, in milliseconds, spent waiting in poll if data is not available in the buffer. * If 0, returns immediately with any records that are available currently in the buffer, else returns empty. diff --git a/kafka/src/test/scala/com/twitter/finatra/kafka/test/integration/FinagleKafkaConsumerIntegrationTest.scala b/kafka/src/test/scala/com/twitter/finatra/kafka/test/integration/FinagleKafkaConsumerIntegrationTest.scala new file mode 100644 index 0000000000..dae558f0f8 --- /dev/null +++ b/kafka/src/test/scala/com/twitter/finatra/kafka/test/integration/FinagleKafkaConsumerIntegrationTest.scala @@ -0,0 +1,72 @@ +package com.twitter.finatra.kafka.test.integration + +import com.twitter.finatra.kafka.consumers.FinagleKafkaConsumerBuilder +import com.twitter.finatra.kafka.domain.{AckMode, KafkaGroupId} +import com.twitter.finatra.kafka.producers.FinagleKafkaProducerBuilder +import com.twitter.finatra.kafka.test.EmbeddedKafka +import com.twitter.util.Duration +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.serialization.Serdes + +class FinagleKafkaConsumerIntegrationTest extends EmbeddedKafka { + private val testTopic = kafkaTopic(Serdes.String, Serdes.String, "test-topic") + private val emptyTestTopic = kafkaTopic(Serdes.String, Serdes.String, "empty-test-topic") + + protected lazy val producer = FinagleKafkaProducerBuilder() + .dest(brokers.map(_.brokerList()).mkString(",")) + .clientId("test-producer") + .ackMode(AckMode.ALL) + .keySerializer(Serdes.String.serializer) + .valueSerializer(Serdes.String.serializer) + .build() + + protected lazy val consumer = FinagleKafkaConsumerBuilder() + .dest(brokers.map(_.brokerList()).mkString(",")) + .clientId("test-consumer") + .groupId(KafkaGroupId("test-group")) + .keyDeserializer(Serdes.String.deserializer) + .valueDeserializer(Serdes.String.deserializer) + .requestTimeout(Duration.fromSeconds(1)) + .build() + + test("endOffset returns 0 for empty topic with no events") { + val emptyTopicPartition = new TopicPartition(emptyTestTopic.topic, 0) + val endOffsets = await(consumer.endOffsets(Seq(emptyTopicPartition))) + assert(endOffsets.get(emptyTopicPartition) == 0) + } + + test("endOffset increases by 1 after publish") { + val topicPartition = new TopicPartition(testTopic.topic, 0) + val initEndOffsets = await(consumer.endOffsets(Seq(topicPartition))) + val initEndOffset = initEndOffsets.get(topicPartition) + + await(producer.send(testTopic.topic, "Foo", "Bar", System.currentTimeMillis)) + + val writtenEndOffsets = await(consumer.endOffsets(Seq(topicPartition))) + assert(writtenEndOffsets.get(topicPartition) == initEndOffset + 1) + } + + test("endOffset increases by 3 after 3 publishes") { + val topicPartition = new TopicPartition(testTopic.topic, 0) + val initEndOffsets = await(consumer.endOffsets(Seq(topicPartition))) + val initEndOffset = initEndOffsets.get(topicPartition) + + await(producer.send(testTopic.topic, "Fee", "Bee", System.currentTimeMillis)) + await(producer.send(testTopic.topic, "Fi", "Bye", System.currentTimeMillis)) + await(producer.send(testTopic.topic, "Foo", "Boo", System.currentTimeMillis)) + + val writtenEndOffsets = await(consumer.endOffsets(Seq(topicPartition))) + assert(writtenEndOffsets.get(topicPartition) == initEndOffset + 3) + } + + test("endOffset returns empty map for empty sequence of partitions") { + val emptyEndOffsets = await(consumer.endOffsets(Seq.empty[TopicPartition])) + assert(emptyEndOffsets.size == 0) + } + + test("endOffset times out for non-existent topic") { + val notExistTopicPartition = new TopicPartition("topic-does-not-exist", 0) + assertThrows[org.apache.kafka.common.errors.TimeoutException]( + await(consumer.endOffsets(Seq(notExistTopicPartition)))) + } +} From f7f6d64f2ad96998bc7b139d6f9d36e6db2d5731 Mon Sep 17 00:00:00 2001 From: Christopher Coco Date: Tue, 29 Jan 2019 19:43:42 +0000 Subject: [PATCH 20/45] finatra-jackson: Patch CaseClassField reflection Problem/Solution Finding annotations in the `CaseClassField` via reflection doesn't work properly in all cases. Update to rely less on reflection to make the annotation parsing less brittle. JIRA Issues: CSL-7527, CSL-7547 Differential Revision: https://phabricator.twitter.biz/D266289 --- .../caseclass/jackson/CaseClassField.scala | 41 +++++++------------ .../finatra/validation/ValidatorTest.scala | 2 +- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala index 87179e2b41..fb4a87c6b8 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala @@ -13,6 +13,7 @@ import com.twitter.finatra.json.internal.caseclass.exceptions.{ } import com.twitter.finatra.json.internal.caseclass.reflection.{ CaseClassSigParser, + ConstructorParam, DefaultMethodUtils } import com.twitter.finatra.json.internal.caseclass.utils.AnnotationUtils._ @@ -22,7 +23,6 @@ import com.twitter.finatra.validation.ValidationResult._ import com.twitter.finatra.validation.{ErrorCode, Validation} import com.twitter.inject.Logging import com.twitter.inject.conversions.string._ -import com.twitter.util.{Return, Try} import java.lang.annotation.Annotation import scala.annotation.tailrec import scala.language.existentials @@ -35,13 +35,14 @@ private[finatra] object CaseClassField { namingStrategy: PropertyNamingStrategy, typeFactory: TypeFactory ): Seq[CaseClassField] = { - val constructorParams = CaseClassSigParser.parseConstructorParams(clazz) + val constructorParams: Seq[ConstructorParam] = CaseClassSigParser.parseConstructorParams(clazz) assert( clazz.getConstructors.head.getParameterCount == constructorParams.size, "Non-static inner 'case classes' not supported" ) - val annotationsMap: Map[String, Seq[Annotation]] = findAnnotations(clazz) + // field name to list of parsed annotations + val annotationsMap: Map[String, Seq[Annotation]] = findAnnotations(clazz, constructorParams) val companionObject = Class.forName(clazz.getName + "$").getField("MODULE$").get(null) val companionObjectClass = companionObject.getClass @@ -64,37 +65,23 @@ private[finatra] object CaseClassField { } /** Finds the sequence of Annotations per field in the clazz, keyed by field name */ - private[finatra] def findAnnotations(clazz: Class[_]): Map[String, Seq[Annotation]] = { + private[finatra] def findAnnotations( + clazz: Class[_], + constructorParams: Seq[ConstructorParam] + ): Map[String, Seq[Annotation]] = { // for case classes, the annotations are only visible on the constructor. - val clazzAnnotationsArray: Array[Array[Annotation]] = + val clazzConstructorAnnotations: Array[Array[Annotation]] = clazz.getConstructors.head.getParameterAnnotations - val clazzConstructorParamNames: Array[String] = - clazz.getConstructors.head.getParameters.map(_.getName) - val clazzDeclaredFieldNames: Array[String] = clazz.getDeclaredFields.map(_.getName) - - /* NOTE: we want to prefer using the constructor Array[Parameter] names, however in Scala 2.11 - Parameters don't have actual names (only "arg" + index), so we fall back to relying on - the declared fields found via reflection -- which conversely is incorrect in Scala 2.12 - for this purpose. */ - val clazzFields: Array[String] = - if (clazzConstructorParamNames.exists(!_.startsWith("arg"))) { - clazzConstructorParamNames - } else { - clazzDeclaredFieldNames - } - + // find case class field annotations val clazzAnnotations: Map[String, Seq[Annotation]] = (for { - (field, index) <- clazzFields.zipWithIndex + (field, index) <- constructorParams.zipWithIndex + fieldAnnotations = clazzConstructorAnnotations(index) } yield { - Try(clazzAnnotationsArray.apply(index)) match { - case Return(annotations) if annotations.nonEmpty => - field -> annotations.toSeq - case _ => - field -> Nil - } + field.name -> fieldAnnotations.toSeq }).toMap + // find inherited annotations val inheritedAnnotations: Map[String, Seq[Annotation]] = findDeclaredMethodAnnotations(clazz, Map.empty[String, Seq[Annotation]]) diff --git a/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala b/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala index 24de57a325..0a0f447c5c 100644 --- a/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala +++ b/jackson/src/test/scala/com/twitter/finatra/validation/ValidatorTest.scala @@ -24,7 +24,7 @@ class ValidatorTest extends Test { def getValidationAnnotations(clazz: Class[_], paramName: String): Seq[Annotation] = { val constructorParams = parseConstructorParams(clazz) - val annotations = findAnnotations(clazz) + val annotations = findAnnotations(clazz, constructorParams) for { param <- constructorParams From 093b31b8739dc8e776c7d9d78b0059dd7e880ce1 Mon Sep 17 00:00:00 2001 From: Vaughn Ganem Haka Date: Tue, 29 Jan 2019 20:22:11 +0000 Subject: [PATCH 21/45] kafka-streams: Use the Time domain class for timestamps Problem Finatra Kafka Streams currently uses Longs to represent timestamps. This leads to unintuitive method signatures. Solution This branch uses the existing `c.t.finatra.streams.transformer.domain.Time` domain class to represent time. This branch heavily leverages the Time value class to replace most primitive timestamps. Result Time is more straightforward. The `c.t.finatra.streams.transformer.domain.Time` value class is now used for most timestamps in Finatra Kafka Streams. JIRA Issues: DINS-2533 Differential Revision: https://phabricator.twitter.biz/D255736 --- CHANGELOG.rst | 5 ++ .../dsl/FinatraDslWindowedAggregations.scala | 4 +- .../utils/FinatraDslV2Implicits.scala | 4 +- .../ReservoirSamplingTransformer.scala | 2 +- .../finatra/streams/converters/time.scala | 13 +++- ...QueryableFinatraCompositeWindowStore.scala | 16 ++--- .../query/QueryableFinatraWindowStore.scala | 21 ++++--- .../transformer/AggregatorTransformer.scala | 16 ++--- .../transformer/CompositeSumAggregator.scala | 28 ++++----- .../transformer/PersistentTimerStore.scala | 16 ++--- .../transformer/PersistentTimers.scala | 4 +- .../streams/transformer/SumAggregator.scala | 30 +++++---- .../domain/FixedTimeWindowedSerde.scala | 12 ++-- .../streams/transformer/domain/Time.scala | 62 +++++++++++++++---- .../transformer/domain/TimeWindowed.scala | 44 ++++++------- .../internal/WatermarkTracker.scala | 4 +- .../transformer/internal/domain/Timer.scala | 6 +- .../internal/domain/TimerSerde.scala | 11 +++- .../com/twitter/unittests/TimeTest.scala | 61 ++++++++++++++++++ .../UserClicksTopologyFeatureTest.scala | 39 +++++------- .../WordLengthFinatraTransformer.scala | 2 +- 21 files changed, 258 insertions(+), 142 deletions(-) create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/TimeTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ae678282ca..6e1f0fee8c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -22,6 +22,11 @@ Added Changed ~~~~~~~ +* finatra-kafka-streams: `c.t.finatra.streams.transformer.domain.Time` is now the canonical + representation of time for watermarks and timers. `RichLong` implicit from + `com.twitter.finatra.streams.converters.time` has been renamed to `RichFinatraKafkaStreamsLong`. + ``PHAB_ID=D255736`` + * finatra-jackson: Fix `CaseClassField` annotation reflection for Scala 2.12. ``PHAB_ID=D264423`` * finatra-kafka-streams: Combine FinatraTransformer with FinatraTransformerV2. ``PHAB_ID=D254411`` diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala index 7c8f9d4887..a415f0d2a2 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala @@ -111,7 +111,7 @@ trait FinatraDslWindowedAggregations aggregateSerde: Serde[Aggregate], initializer: () => Aggregate, aggregator: ((K, V), Aggregate) => Aggregate, - windowStart: (Time, K, V) => Long = null, + windowStart: (Time, K, V) => Time = null, emitOnClose: Boolean = true, emitUpdatedEntriesOnCommit: Boolean = false, windowSizeRetentionMultiplier: Int = 2 @@ -290,7 +290,7 @@ trait FinatraDslWindowedAggregations windowStart = { case (time, key, timeWindowedCount) => assert(timeWindowedCount.sizeMillis == windowSizeMillis) - timeWindowedCount.startMs + timeWindowedCount.start }, emitOnClose = emitOnClose, emitUpdatedEntriesOnCommit = emitUpdatedEntriesOnCommit, diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala index 0bc4ea2550..8d5735bf87 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala @@ -100,7 +100,7 @@ trait FinatraDslV2Implicits extends ScalaStreamsImplicits { queryableAfterClose = queryableAfterClose, countToAggregate = (key, count) => count, windowStart = (messageTime, key, value) => - TimeWindowed.windowStart(messageTime, windowSize.inMillis) + TimeWindowed.windowStart(messageTime, windowSize) ) inner.transform(transformerSupplier, stateStore, timerStore.name) @@ -148,7 +148,7 @@ trait FinatraDslV2Implicits extends ScalaStreamsImplicits { emitOnClose = emitOnClose, queryableAfterClose = queryableAfterClose, countToAggregate = (key, windowedValue) => windowedValue.value, - windowStart = (messageTime, key, windowedValue) => windowedValue.startMs + windowStart = (messageTime, key, windowedValue) => windowedValue.start ) ).asInstanceOf[() => Transformer[ K, diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala index bbd257d9c4..da431239fb 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala @@ -49,7 +49,7 @@ class ReservoirSamplingTransformer[ for (eTime <- expirationTime) { if (isFirstTimeSampleKeySeen(totalCount)) { - timerStore.addTimer(messageTime.plus(eTime), Expire, sampleKey) + timerStore.addTimer(messageTime + eTime, Expire, sampleKey) } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/converters/time.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/converters/time.scala index 3a54920473..832b6b74e2 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/converters/time.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/converters/time.scala @@ -1,9 +1,14 @@ package com.twitter.finatra.streams.converters +import com.twitter.finatra.streams.transformer.domain.Time +import org.joda.time.DateTime import org.joda.time.format.ISODateTimeFormat +/** + * Time conversion utilities. + */ object time { - implicit class RichLong(long: Long) { + implicit class RichFinatraKafkaStreamsLong(val long: Long) extends AnyVal { def iso8601Millis: String = { ISODateTimeFormat.dateTime.print(long) } @@ -12,4 +17,10 @@ object time { ISODateTimeFormat.dateTimeNoMillis.print(long) } } + + implicit class RichFinatraKafkaStreamsDatetime(val datetime: DateTime) extends AnyVal { + def toTime: Time = { + Time.create(datetime) + } + } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraCompositeWindowStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraCompositeWindowStore.scala index ba1fd4a19b..6401371863 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraCompositeWindowStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraCompositeWindowStore.scala @@ -1,5 +1,6 @@ package com.twitter.finatra.streams.query +import com.twitter.conversions.DurationOps._ import com.twitter.finatra.streams.converters.time._ import com.twitter.finatra.streams.queryable.thrift.domain.ServiceShardId import com.twitter.finatra.streams.queryable.thrift.partitioning.{ @@ -90,13 +91,13 @@ class QueryableFinatraCompositeWindowStore[PK, SK, V]( allowStaleReads: Boolean, resultMap: scala.collection.mutable.Map[Long, scala.collection.mutable.Map[SK, V]] ): Unit = { - trace(s"QueryWindow $startCompositeKey to $endCompositeKey ${windowStartTime.iso8601}") + trace(s"QueryWindow $startCompositeKey to $endCompositeKey ${windowStartTime.asInstanceOf[Long].iso8601}") //TODO: Use store.taskId to find exact store where the key is assigned for (store <- FinatraStoresGlobalManager.getWindowedCompositeStores[PK, SK, V](storeName)) { val iterator = store.range( - TimeWindowed.forSize(startMs = windowStartTime, windowSizeMillis, startCompositeKey), - TimeWindowed.forSize(startMs = windowStartTime, windowSizeMillis, endCompositeKey), + TimeWindowed.forSize(start = Time(windowStartTime), windowSize, startCompositeKey), + TimeWindowed.forSize(start = Time(windowStartTime), windowSize, endCompositeKey), allowStaleReads = allowStaleReads ) @@ -104,7 +105,7 @@ class QueryableFinatraCompositeWindowStore[PK, SK, V]( val entry = iterator.next() trace(s"$store\t$entry") val innerMap = - resultMap.getOrElseUpdate(entry.key.startMs, scala.collection.mutable.Map[SK, V]()) + resultMap.getOrElseUpdate(entry.key.start.millis, scala.collection.mutable.Map[SK, V]()) innerMap += (entry.key.value.secondary -> entry.value) } } @@ -116,9 +117,10 @@ class QueryableFinatraCompositeWindowStore[PK, SK, V]( windowSizeMillis: DateTimeMillis ): (DateTimeMillis, DateTimeMillis) = { val endWindowRange = endTime.getOrElse { - TimeWindowed.windowStart( - messageTime = Time(DateTimeUtils.currentTimeMillis), - sizeMs = windowSizeMillis) + defaultWindowMultiplier * windowSizeMillis + TimeWindowed + .windowStart(messageTime = Time(DateTimeUtils.currentTimeMillis), size = windowSize) + .+(windowSizeMillis.millis * defaultWindowMultiplier) + .millis } val startWindowRange = diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraWindowStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraWindowStore.scala index f73e4eb9f6..92f3b26614 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraWindowStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraWindowStore.scala @@ -31,7 +31,7 @@ class QueryableFinatraWindowStore[K, V]( private val currentServiceShardId = ServiceShardId(currentShardId) - private val windowSizeMillis = windowSize.inMillis + private val queryWindowSize = windowSize * defaultWindowMultiplier private val partitioner = new KafkaPartitioner( StaticServiceShardPartitioner(numShards = numShards), @@ -46,28 +46,29 @@ class QueryableFinatraWindowStore[K, V]( throwIfNonLocalKey(key, keySerializer) val endWindowRange = endTime.getOrElse( - TimeWindowed.windowStart( - messageTime = Time(DateTimeUtils.currentTimeMillis), - sizeMs = windowSizeMillis) + defaultWindowMultiplier * windowSizeMillis) + TimeWindowed + .windowStart(messageTime = Time(DateTimeUtils.currentTimeMillis), size = windowSize) + .+(queryWindowSize) + .millis) val startWindowRange = - startTime.getOrElse(endWindowRange - (defaultWindowMultiplier * windowSizeMillis)) + startTime.getOrElse(endWindowRange - queryWindowSize.inMillis) val windowedMap = new java.util.TreeMap[DateTimeMillis, V] - var currentWindowStart = startWindowRange - while (currentWindowStart <= endWindowRange) { - val windowedKey = TimeWindowed.forSize(currentWindowStart, windowSize.inMillis, key) + var currentWindowStart = Time(startWindowRange) + while (currentWindowStart.millis <= endWindowRange) { + val windowedKey = TimeWindowed.forSize(currentWindowStart, windowSize, key) //TODO: Use store.taskId to find exact store where the key is assigned for (store <- stores) { val result = store.get(windowedKey) if (result != null) { - windowedMap.put(currentWindowStart, result) + windowedMap.put(currentWindowStart.millis, result) } } - currentWindowStart = currentWindowStart + windowSizeMillis + currentWindowStart = currentWindowStart + windowSize } windowedMap.asScala.toMap diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala index 203e5a8fcf..e5de507c79 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala @@ -50,7 +50,7 @@ class AggregatorTransformer[K, V, Aggregate]( allowedLateness: Duration, initializer: () => Aggregate, aggregator: ((K, V), Aggregate) => Aggregate, - customWindowStart: (Time, K, V) => Long, + customWindowStart: (Time, K, V) => Time, emitOnClose: Boolean = false, queryableAfterClose: Duration, emitUpdatedEntriesOnCommit: Boolean, @@ -89,14 +89,14 @@ class AggregatorTransformer[K, V, Aggregate]( override def onMessage(time: Time, key: K, value: V): Unit = { val windowedKey = TimeWindowed.forSize( - startMs = windowStart(time, key, value), - sizeMs = windowSizeMillis, + start = windowStart(time, key, value), + size = windowSize, value = key) - if (windowedKey.isLate(allowedLatenessMillis, watermark)) { + if (windowedKey.isLate(allowedLateness, watermark)) { restatement(time, key, value, windowedKey) } else { - addWindowTimersIfNew(windowedKey.startMs) + addWindowTimersIfNew(windowedKey.start.millis) val currentAggregateValue = stateStore.getOrDefault(windowedKey, initializer()) stateStore.put(windowedKey, aggregator((key, value), currentAggregateValue)) @@ -165,7 +165,7 @@ class AggregatorTransformer[K, V, Aggregate]( ): Unit = { while (windowIterator.hasNext) { val entry = windowIterator.next() - assert(entry.key.startMs == windowStartTime) + assert(entry.key.start.millis == windowStartTime) forward( key = entry.key, value = WindowedValue(resultState = WindowClosed, value = entry.value), @@ -192,11 +192,11 @@ class AggregatorTransformer[K, V, Aggregate]( longSerializer.serialize("", windowStartMs) } - private def windowStart(time: Time, key: K, value: V): Long = { + private def windowStart(time: Time, key: K, value: V): Time = { if (customWindowStart != null) { customWindowStart(time, key, value) } else { - TimeWindowed.windowStart(time, windowSizeMillis) + TimeWindowed.windowStart(time, windowSize) } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala index 20aa521c29..5eb4afcc1e 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala @@ -1,5 +1,6 @@ package com.twitter.finatra.streams.transformer +import com.twitter.conversions.DurationOps._ import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.streams.transformer.FinatraTransformer.WindowStartTime import com.twitter.finatra.streams.transformer.domain._ @@ -31,10 +32,6 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( with StateStoreImplicits with IteratorImplicits { - private val windowSizeMillis = windowSize.inMillis - private val allowedLatenessMillis = allowedLateness.inMillis - private val queryableAfterCloseMillis = queryableAfterClose.inMillis - private val restatementsCounter = statsReceiver.counter("numRestatements") private val deletesCounter = statsReceiver.counter("numDeletes") @@ -51,8 +48,8 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( ) override def onMessage(time: Time, compositeKey: CK, count: Int): Unit = { - val windowedCompositeKey = TimeWindowed.forSize(time.hourMillis, windowSizeMillis, compositeKey) - if (windowedCompositeKey.isLate(allowedLatenessMillis, Watermark(watermark.timeMillis))) { + val windowedCompositeKey = TimeWindowed.forSize(time.hour, windowSize, compositeKey) + if (windowedCompositeKey.isLate(allowedLateness, Watermark(watermark.timeMillis))) { restatementsCounter.incr() forward(windowedCompositeKey.map { _ => compositeKey.primary @@ -65,14 +62,14 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( putStat = putLatencyStat ) if (newCount == count) { - val closeTime = windowedCompositeKey.startMs + windowSizeMillis + allowedLatenessMillis + val closeTime = windowedCompositeKey.start + windowSize + allowedLateness if (emitOnClose) { - timerStore.addTimer(Time(closeTime), Close, windowedCompositeKey.startMs) + timerStore.addTimer(closeTime, Close, windowedCompositeKey.start.millis) } timerStore.addTimer( - Time(closeTime + queryableAfterCloseMillis), + closeTime + queryableAfterClose, Expire, - windowedCompositeKey.startMs + windowedCompositeKey.start.millis ) } } @@ -91,14 +88,15 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( windowStartMs: WindowStartTime ): Unit = { debug(s"onEventTimer $time $timerMetadata") + val windowStart = Time(windowStartMs) val windowIterator = stateStore.range( - TimeWindowed.forSize(windowStartMs, windowSizeMillis, compositeKeyRangeStart), - TimeWindowed.forSize(windowStartMs + 1, windowSizeMillis, compositeKeyRangeStart) + TimeWindowed.forSize(windowStart, windowSize, compositeKeyRangeStart), + TimeWindowed.forSize(windowStart + 1.millis, windowSize, compositeKeyRangeStart) ) try { if (timerMetadata == Close) { - onClosed(windowStartMs, windowIterator) + onClosed(windowStart, windowIterator) } else { onExpired(windowIterator) } @@ -108,7 +106,7 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( } private def onClosed( - windowStartMs: Long, + windowStart: Time, windowIterator: KeyValueIterator[TimeWindowed[CK], Int] ): Unit = { windowIterator @@ -122,7 +120,7 @@ class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( .foreach { case (key, countsMap) => forward( - key = TimeWindowed.forSize(windowStartMs, windowSizeMillis, key), + key = TimeWindowed.forSize(windowStart, windowSize, key), value = WindowedValue(resultState = WindowClosed, value = countsMap) ) } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimerStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimerStore.scala index 81d2dd5717..8d9144be9b 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimerStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimerStore.scala @@ -26,12 +26,12 @@ class PersistentTimerStore[TimerKey]( def onInit(): Unit = { setNextTimerTime(Long.MaxValue) - currentWatermark = Watermark(0) + currentWatermark = Watermark(0L) val iterator = timersStore.all() try { if (iterator.hasNext) { - setNextTimerTime(iterator.next.key.time) + setNextTimerTime(iterator.next.key.time.millis) } } finally { iterator.close() @@ -61,7 +61,7 @@ class PersistentTimerStore[TimerKey]( } else { debug(f"${"AddTimer:"}%-20s ${metadata.getClass.getSimpleName}%-12s Key $key Timer $time") timersStore.put( - Timer(time = time.millis, metadata = metadata, key = key), + Timer(time = time, metadata = metadata, key = key), Array.emptyByteArray) if (time.millis < nextTimerTime) { @@ -97,7 +97,7 @@ class PersistentTimerStore[TimerKey]( while (timerIterator.hasNext && !timerIteratorState.done) { currentTimer = timerIterator.next().key - if (watermark.timeMillis >= currentTimer.time) { + if (watermark.timeMillis >= currentTimer.time.millis) { fireAndDeleteTimer(currentTimer) numTimerFires += 1 if (numTimerFires >= maxTimerFiresPerWatermark) { @@ -109,11 +109,11 @@ class PersistentTimerStore[TimerKey]( } if (timerIteratorState == FoundTimerAfterWatermark) { - setNextTimerTime(currentTimer.time) + setNextTimerTime(currentTimer.time.millis) } else if (timerIteratorState == ExceededMaxTimers && timerIterator.hasNext) { - setNextTimerTime(timerIterator.next().key.time) + setNextTimerTime(timerIterator.next().key.time.millis) debug( - s"Exceeded $maxTimerFiresPerWatermark max timer fires per watermark. LastTimerFired: ${currentTimer.time.iso8601Millis} NextTimer: ${nextTimerTime.iso8601Millis}" + s"Exceeded $maxTimerFiresPerWatermark max timer fires per watermark. LastTimerFired: ${currentTimer.time.millis.iso8601Millis} NextTimer: ${nextTimerTime.iso8601Millis}" ) } else { assert(!timerIterator.hasNext) @@ -139,7 +139,7 @@ class PersistentTimerStore[TimerKey]( private def fireAndDeleteTimer(timer: Timer[TimerKey]): Unit = { trace(s"fireAndDeleteTimer $timer") - onTimer(Time(timer.time), timer.metadata, timer.key) + onTimer(timer.time, timer.metadata, timer.key) timersStore.deleteWithoutGettingPriorValue(timer) } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimers.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimers.scala index 3581314f8a..e54623b96f 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimers.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimers.scala @@ -13,8 +13,8 @@ import scala.reflect.ClassTag * Per-Key Persistent Timers inspired by Flink's ProcessFunction: * https://ci.apache.org/projects/flink/flink-docs-stable/dev/stream/operators/process_function.html * - * Note: Timers are based on a sorted RocksDB KeyValueStore - * Note: Timers that fire at the same time MAY NOT fire in the order which they were added + * @note Timers are based on a sorted RocksDB KeyValueStore + * @note Timers that fire at the same time MAY NOT fire in the order which they were added */ @Beta trait PersistentTimers extends OnWatermark with OnInit { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala index d328f74e09..e3faf5fc71 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala @@ -1,5 +1,6 @@ package com.twitter.finatra.streams.transformer +import com.twitter.conversions.DurationOps._ import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.streams.transformer.FinatraTransformer.WindowStartTime import com.twitter.finatra.streams.transformer.domain._ @@ -18,7 +19,7 @@ class SumAggregator[K, V]( windowSize: Duration, allowedLateness: Duration, queryableAfterClose: Duration, - windowStart: (Time, K, V) => Long, + windowStart: (Time, K, V) => Time, countToAggregate: (K, V) => Int, emitOnClose: Boolean = true, maxActionsPerTimer: Int = 25000) @@ -33,10 +34,6 @@ class SumAggregator[K, V]( with StateStoreImplicits with IteratorImplicits { - private val windowSizeMillis = windowSize.inMillis - private val allowedLatenessMillis = allowedLateness.inMillis - private val queryableAfterCloseMillis = queryableAfterClose.inMillis - private val restatementsCounter = statsReceiver.counter("numRestatements") private val closedCounter = statsReceiver.counter("closedWindows") private val expiredCounter = statsReceiver.counter("expiredWindows") @@ -50,26 +47,26 @@ class SumAggregator[K, V]( override def onMessage(time: Time, key: K, value: V): Unit = { val windowedKey = TimeWindowed.forSize( - startMs = windowStart(time, key, value), - sizeMs = windowSizeMillis, + start = windowStart(time, key, value), + size = windowSize, value = key ) val count = countToAggregate(key, value) - if (windowedKey.isLate(allowedLatenessMillis, Watermark(watermark.timeMillis))) { + if (windowedKey.isLate(allowedLateness, Watermark(watermark.timeMillis))) { restatementsCounter.incr() forward(windowedKey, WindowedValue(Restatement, count)) } else { val newCount = stateStore.increment(windowedKey, count) if (newCount == count) { - val closeTime = windowedKey.startMs + windowSizeMillis + allowedLatenessMillis + val closeTime = windowedKey.start + windowSize + allowedLateness if (emitOnClose) { - timerStore.addTimer(Time(closeTime), Close, windowedKey.startMs) + timerStore.addTimer(closeTime, Close, windowedKey.start.millis) } timerStore.addTimer( - Time(closeTime + queryableAfterCloseMillis), + closeTime + queryableAfterClose, Expire, - windowedKey.startMs + windowedKey.start.millis ) } } @@ -80,14 +77,15 @@ class SumAggregator[K, V]( timerMetadata: TimerMetadata, windowStartMs: WindowStartTime ): Unit = { + val windowStart = Time(windowStartMs) val hourlyWindowIterator = stateStore.range( - TimeWindowed.forSize(windowStartMs, windowSizeMillis, keyRangeStart), - TimeWindowed.forSize(windowStartMs + 1, windowSizeMillis, keyRangeStart) + TimeWindowed.forSize(windowStart, windowSize, keyRangeStart), + TimeWindowed.forSize(windowStart + 1.millis, windowSize, keyRangeStart) ) try { if (timerMetadata == Close) { - onClosed(windowStartMs, hourlyWindowIterator) + onClosed(windowStart, hourlyWindowIterator) } else { onExpired(hourlyWindowIterator) } @@ -97,7 +95,7 @@ class SumAggregator[K, V]( } private def onClosed( - windowStartMs: Long, + windowStart: Time, windowIterator: KeyValueIterator[TimeWindowed[K], Int] ): Unit = { windowIterator diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/FixedTimeWindowedSerde.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/FixedTimeWindowedSerde.scala index 6573effffb..64d3e3e60f 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/FixedTimeWindowedSerde.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/FixedTimeWindowedSerde.scala @@ -37,20 +37,24 @@ class FixedTimeWindowedSerde[K](val inner: Serde[K], windowSize: Duration) bb.get(keyBytes) val endMs = startMs + windowSizeMillis - TimeWindowed(startMs = startMs, endMs = endMs, innerDeserializer.deserialize(topic, keyBytes)) + TimeWindowed( + start = Time(startMs), + end = Time(endMs), + innerDeserializer.deserialize(topic, keyBytes) + ) } final override def serialize(timeWindowedKey: TimeWindowed[K]): Array[Byte] = { assert( - timeWindowedKey.startMs + windowSizeMillis == timeWindowedKey.endMs, - s"TimeWindowed element being serialized has end time which is not consistent with the FixedTimeWindowedSerde window size of $windowSize. ${timeWindowedKey.startMs + windowSizeMillis} != ${timeWindowedKey.endMs}" + timeWindowedKey.start + windowSize == timeWindowedKey.end, + s"TimeWindowed element being serialized has end time which is not consistent with the FixedTimeWindowedSerde window size of $windowSize. ${timeWindowedKey.start + windowSize} != ${timeWindowedKey.end}" ) val keyBytes = innerSerializer.serialize(topic, timeWindowedKey.value) val windowAndKeyBytesSize = new Array[Byte](WindowStartTimeSizeBytes + keyBytes.length) val bb = ByteBuffer.wrap(windowAndKeyBytesSize) - bb.putLong(timeWindowedKey.startMs) + bb.putLong(timeWindowedKey.start.millis) bb.put(keyBytes) bb.array() } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Time.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Time.scala index b0edf56ba2..e0bc9031c5 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Time.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Time.scala @@ -1,32 +1,72 @@ package com.twitter.finatra.streams.transformer.domain import com.twitter.util.Duration -import org.joda.time.DateTimeConstants +import org.joda.time.{DateTime, DateTimeConstants} import com.twitter.finatra.streams.converters.time._ object Time { - def nextInterval(time: Long, duration: Duration): Long = { + /** + * Construct a [[Time]] from a [[DateTime]]. + */ + def create(datetime: DateTime): Time = { + new Time(datetime.getMillis) + } + + /** + * Finds the next interval occurrence of a [[Duration]] from a [[Time]]. + * + * @param time A point in time. + * @param duration The duration of an interval. + */ + def nextInterval(time: Time, duration: Duration): Time = { val durationMillis = duration.inMillis - val currentNumIntervals = time / durationMillis - (currentNumIntervals + 1) * durationMillis + val currentNumIntervals = time.millis / durationMillis + Time((currentNumIntervals + 1) * durationMillis) } } -//TODO: Refactor +/** + * A Value Class representing a point in time. + * + * @param millis A millisecond timestamp. + */ case class Time(millis: Long) extends AnyVal { - final def plus(duration: Duration): Time = { + /** + * Adds a [[Time]] to the [[Time]]. + */ + final def +(time: Time): Time = { + new Time(millis + time.millis) + } + + /** + * Adds a [[Duration]] to the [[Time]]. + */ + final def +(duration: Duration): Time = { new Time(millis + duration.inMillis) } - final def hourMillis: Long = { - val unitsSinceEpoch = millis / DateTimeConstants.MILLIS_PER_HOUR - unitsSinceEpoch * DateTimeConstants.MILLIS_PER_HOUR + /** + * Rounds down [[Time]] to the nearest hour. + */ + final def hour: Time = { + roundDown(DateTimeConstants.MILLIS_PER_HOUR) + } + + /** + * Rounds down ''millis'' to the nearest multiple of ''milliseconds''. + */ + final def roundDown(milliseconds: Long): Time = { + val unitsSinceEpoch = millis / milliseconds + Time(unitsSinceEpoch * milliseconds) } + /** + * @return An hourly window from derived from the [[Time]]. + */ final def hourlyWindowed[K](key: K): TimeWindowed[K] = { - val start = hourMillis - val end = start + DateTimeConstants.MILLIS_PER_HOUR + val start = hour + val end = Time(start.millis + DateTimeConstants.MILLIS_PER_HOUR) TimeWindowed(start, end, key) } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimeWindowed.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimeWindowed.scala index 195d367540..a5cc7a3256 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimeWindowed.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimeWindowed.scala @@ -5,30 +5,30 @@ import org.joda.time.{DateTime, DateTimeConstants} object TimeWindowed { - def forSize[V](startMs: Long, sizeMs: Long, value: V): TimeWindowed[V] = { - TimeWindowed(startMs, startMs + sizeMs, value) + def forSize[V](start: Time, size: Duration, value: V): TimeWindowed[V] = { + TimeWindowed(start, start + size, value) } - def forSizeFromMessageTime[V](messageTime: Time, sizeMs: Long, value: V): TimeWindowed[V] = { - val windowStartMs = windowStart(messageTime, sizeMs) - TimeWindowed(windowStartMs, windowStartMs + sizeMs, value) + def forSizeFromMessageTime[V](messageTime: Time, size: Duration, value: V): TimeWindowed[V] = { + val startWindow = windowStart(messageTime, size) + TimeWindowed(startWindow, startWindow + size, value) } - def hourly[V](startMs: Long, value: V): TimeWindowed[V] = { - TimeWindowed(startMs, startMs + DateTimeConstants.MILLIS_PER_HOUR, value) + def hourly[V](start: Time, value: V): TimeWindowed[V] = { + TimeWindowed(start, Time(start.millis + DateTimeConstants.MILLIS_PER_HOUR), value) } - def windowStart(messageTime: Time, sizeMs: Long): Long = { - (messageTime.millis / sizeMs) * sizeMs + def windowStart(messageTime: Time, size: Duration): Time = { + Time((messageTime.millis / size.inMillis) * size.inMillis) } } /** * A time windowed value specified by a start and end time - * @param startMs the start timestamp of the window (inclusive) - * @param endMs the end timestamp of the window (exclusive) + * @param start the start time of the window (inclusive) + * @param end the end time of the window (exclusive) */ -case class TimeWindowed[V](startMs: Long, endMs: Long, value: V) { +case class TimeWindowed[V](start: Time, end: Time, value: V) { /** * Determine if this windowed value is late given the allowedLateness configuration and the @@ -38,15 +38,15 @@ case class TimeWindowed[V](startMs: Long, endMs: Long, value: V) { * @param watermark a watermark used to determine if this windowed value is late * @return If the windowed value is late */ - def isLate(allowedLateness: Long, watermark: Watermark): Boolean = { - watermark.timeMillis > endMs + allowedLateness + def isLate(allowedLateness: Duration, watermark: Watermark): Boolean = { + watermark.timeMillis > end.millis + allowedLateness.inMillis } /** * Determine the start of the next fixed window interval */ - def nextInterval(time: Long, duration: Duration): Long = { - val intervalStart = math.max(startMs, time) + def nextInterval(time: Time, duration: Duration): Time = { + val intervalStart = Time(math.max(start.millis, time.millis)) Time.nextInterval(intervalStart, duration) } @@ -60,20 +60,20 @@ case class TimeWindowed[V](startMs: Long, endMs: Long, value: V) { /** * The size of this windowed value in milliseconds */ - def sizeMillis: Long = endMs - startMs + def sizeMillis: Long = end.millis - start.millis final override val hashCode: Int = { var result = value.hashCode() - result = 31 * result + (startMs ^ (startMs >>> 32)).toInt - result = 31 * result + (endMs ^ (endMs >>> 32)).toInt + result = 31 * result + (start.millis ^ (start.millis >>> 32)).toInt + result = 31 * result + (end.millis ^ (end.millis >>> 32)).toInt result } final override def equals(obj: scala.Any): Boolean = { obj match { case other: TimeWindowed[V] => - startMs == other.startMs && - endMs == other.endMs && + start == other.start && + end == other.end && value == other.value case _ => false @@ -81,6 +81,6 @@ case class TimeWindowed[V](startMs: Long, endMs: Long, value: V) { } override def toString: String = { - s"TimeWindowed(${new DateTime(startMs)}-${new DateTime(endMs)}-$value)" + s"TimeWindowed(${new DateTime(start.millis)}-${new DateTime(end.millis)}-$value)" } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala index 010dc56696..355e4ffacd 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala @@ -1,11 +1,13 @@ package com.twitter.finatra.streams.transformer.internal +import com.twitter.finatra.streams.transformer.domain.Time + //TODO: Need method called by processing timer so that watermarks can be emitted without input records class WatermarkTracker { private var _watermark: Long = 0L reset() - def watermark: Long = _watermark + def watermark: Time = Time(_watermark) def reset(): Unit = { _watermark = 0L diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/Timer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/Timer.scala index 0c48bbef40..f3f857c56a 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/Timer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/Timer.scala @@ -1,14 +1,14 @@ package com.twitter.finatra.streams.transformer.internal.domain import com.twitter.finatra.streams.converters.time._ -import com.twitter.finatra.streams.transformer.domain.TimerMetadata +import com.twitter.finatra.streams.transformer.domain.{Time, TimerMetadata} /** * @param time Time to fire the timer */ -case class Timer[K](time: Long, metadata: TimerMetadata, key: K) { +case class Timer[K](time: Time, metadata: TimerMetadata, key: K) { override def toString: String = { - s"Timer(${metadata.getClass.getName} $key @${time.iso8601Millis})" + s"Timer(${metadata.getClass.getName} $key @${time.millis.iso8601Millis})" } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/TimerSerde.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/TimerSerde.scala index 7145774126..4a9328dc56 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/TimerSerde.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/TimerSerde.scala @@ -2,7 +2,7 @@ package com.twitter.finatra.streams.transformer.internal.domain import com.google.common.primitives.Longs import com.twitter.finatra.kafka.serde.AbstractSerde -import com.twitter.finatra.streams.transformer.domain.TimerMetadata +import com.twitter.finatra.streams.transformer.domain.{Time, TimerMetadata} import java.nio.ByteBuffer import org.apache.kafka.common.serialization.Serde @@ -19,6 +19,11 @@ object TimerSerde { } } +/** + * Serde for the [[Timer]] class. + * + * @param inner Serde for [[Timer.key]]. + */ class TimerSerde[K](inner: Serde[K]) extends AbstractSerde[Timer[K]] { private val TimerTimeSizeBytes = Longs.BYTES @@ -29,7 +34,7 @@ class TimerSerde[K](inner: Serde[K]) extends AbstractSerde[Timer[K]] { final override def deserialize(bytes: Array[Byte]): Timer[K] = { val bb = ByteBuffer.wrap(bytes) - val time = bb.getLong() + val time = Time(bb.getLong()) val metadata = TimerMetadata(bb.get) val keyBytes = new Array[Byte](bb.remaining()) @@ -44,7 +49,7 @@ class TimerSerde[K](inner: Serde[K]) extends AbstractSerde[Timer[K]] { val timerBytes = new Array[Byte](TimerTimeSizeBytes + MetadataSizeBytes + keyBytes.length) val bb = ByteBuffer.wrap(timerBytes) - bb.putLong(timer.time) + bb.putLong(timer.time.millis) bb.put(timer.metadata.value) bb.put(keyBytes) timerBytes diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/TimeTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/TimeTest.scala new file mode 100644 index 0000000000..2ece7c9de7 --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/TimeTest.scala @@ -0,0 +1,61 @@ +package com.twitter.unittests + +import com.twitter.finatra.streams.transformer.domain.Time +import com.twitter.inject.Test +import com.twitter.util.Duration +import java.util.concurrent.TimeUnit +import org.joda.time.{DateTime, DateTimeConstants} + +class TimeTest extends Test { + + private val timestamp = DateTimeConstants.MILLIS_PER_WEEK + private val durationMs = DateTimeConstants.MILLIS_PER_MINUTE + + test("create Time from DateTime") { + val datetime = new DateTime(timestamp) + val time = Time.create(datetime) + assert(time.millis == datetime.getMillis) + } + + test("test nextInterval") { + val baseTime = Time(0L) + val duration = Duration(durationMs, TimeUnit.MILLISECONDS) + val offsetTime = baseTime + duration + assert(Time.nextInterval(baseTime, duration).millis == durationMs) + assert(Time.nextInterval(offsetTime, duration).millis == 2 * durationMs) + } + + test("test Time equality") { + val timeA = Time(timestamp) + val timeB = Time(timestamp) + assert(timeA == timeB) + } + + test("add Time with Time") { + val time = Time(timestamp) + Time(timestamp) + assert(time == Time(2 * timestamp)) + } + + test("add Time with Duration") { + val adjustedTime = Time(timestamp) + Duration(durationMs, TimeUnit.MILLISECONDS) + assert(adjustedTime == Time(timestamp + durationMs)) + } + + test("test nearest hour") { + val oneHourTime = Time(DateTimeConstants.MILLIS_PER_HOUR) + val twoHourTime = Time(2 * DateTimeConstants.MILLIS_PER_HOUR) + val hourTimePositiveOffset = oneHourTime + Duration(1, TimeUnit.MILLISECONDS) + val twoHourTimeNegativeOffset = twoHourTime + Duration(-1, TimeUnit.MILLISECONDS) + assert(oneHourTime.hour == oneHourTime) + assert(twoHourTime.hour == twoHourTime) + assert(hourTimePositiveOffset.hour == oneHourTime) + assert(twoHourTimeNegativeOffset.hour == oneHourTime) + } + + test("test roundDown") { + val baseTimestamp = 100L + val roundingMillis = 20L + val offsetTimestamp = baseTimestamp + (roundingMillis / 2) + assert(Time(offsetTimestamp).roundDown(roundingMillis) == Time(baseTimestamp)) + } +} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTopologyFeatureTest.scala index a6b3589031..94ca014ee1 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTopologyFeatureTest.scala @@ -2,19 +2,8 @@ package com.twitter.unittests.integration.compositesum import com.twitter.conversions.DurationOps._ import com.twitter.finatra.streams.tests.{FinatraTopologyTester, TopologyFeatureTest} -import com.twitter.finatra.streams.transformer.domain.{ - FixedTimeWindowedSerde, - TimeWindowed, - WindowClosed, - WindowOpen, - WindowedValue, - WindowedValueSerde -} -import com.twitter.unittests.integration.compositesum.UserClicksTypes.{ - ClickTypeSerde, - NumClicksSerde, - UserIdSerde -} +import com.twitter.finatra.streams.transformer.domain._ +import com.twitter.unittests.integration.compositesum.UserClicksTypes.{ClickTypeSerde, NumClicksSerde, UserIdSerde} import org.joda.time.DateTime class UserClicksTopologyFeatureTest extends TopologyFeatureTest { @@ -35,8 +24,8 @@ class UserClicksTopologyFeatureTest extends TopologyFeatureTest { test("windowed clicks") { val userId1 = 1 - val firstHourStartMillis = new DateTime("2018-01-01T00:00:00Z").getMillis - val fifthHourStartMillis = new DateTime("2018-01-01T05:00:00Z").getMillis + val firstHourStartTime = Time.create(new DateTime("2018-01-01T00:00:00Z")) + val fifthHourStartTime = Time.create(new DateTime("2018-01-01T05:00:00Z")) userIdToClicksTopic.pipeInput(userId1, 100) userIdToClicksTopic.pipeInput(userId1, 200) @@ -47,15 +36,15 @@ class UserClicksTopologyFeatureTest extends TopologyFeatureTest { topologyTester.advanceWallClockTime(30.seconds) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 100)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 100)), WindowedValue(WindowOpen, 1)) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 300)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 300)), WindowedValue(WindowOpen, 3)) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 200)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 200)), WindowedValue(WindowOpen, 2)) userIdToClicksTopic.pipeInput(userId1, 100) @@ -64,34 +53,34 @@ class UserClicksTopologyFeatureTest extends TopologyFeatureTest { topologyTester.advanceWallClockTime(5.hours) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 100)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 100)), WindowedValue(WindowOpen, 2)) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 300)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 300)), WindowedValue(WindowOpen, 4)) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 200)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 200)), WindowedValue(WindowOpen, 3)) userIdToClicksTopic.pipeInput(userId1, 1) topologyTester.advanceWallClockTime(30.seconds) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(fifthHourStartMillis, UserClicks(userId1, clickType = 1)), + TimeWindowed.hourly(fifthHourStartTime, UserClicks(userId1, clickType = 1)), WindowedValue(WindowOpen, 1)) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 100)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 100)), WindowedValue(WindowClosed, 2)) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 200)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 200)), WindowedValue(WindowClosed, 3)) hourlyWordAndCountTopic.assertOutput( - TimeWindowed.hourly(firstHourStartMillis, UserClicks(userId1, clickType = 300)), + TimeWindowed.hourly(firstHourStartTime, UserClicks(userId1, clickType = 300)), WindowedValue(WindowClosed, 4)) } } diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala index 344dad4ec6..0e2035b2f1 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala @@ -22,7 +22,7 @@ class WordLengthFinatraTransformer(statsReceiver: StatsReceiver, timerStoreName: override def onMessage(messageTime: Time, key: String, value: String): Unit = { forward(key, "onMessage " + key + " " + key.length) - val time = messageTime.plus(delayedMessageTime) + val time = messageTime + delayedMessageTime timerStore.addTimer(time, Expire, key) } From ee948398653e7f387d29e815dec0d7b651bb9305 Mon Sep 17 00:00:00 2001 From: Steve Cosenza Date: Fri, 1 Feb 2019 21:54:03 +0000 Subject: [PATCH 22/45] finatra-kafka-streams: Delete deprecated and unused classes Problem Finatra Kafka Streams contains some deprecated and unused classes Solution Delete deprecated and unused classes Result Finatra Kafka Streams contains no deprecated and unused classes JIRA Issues: DINS-2604 Differential Revision: https://phabricator.twitter.biz/D267921 --- CHANGELOG.rst | 2 + .../queryable/thrift/QueryableState.scala | 8 - .../utils/FinatraDslV2Implicits.scala | 254 ------------------ .../utils/ProcessorContextLogging.scala | 5 - .../ReservoirSamplingTransformer.scala | 4 +- .../RoundRobinStreamPartitioner.scala | 21 -- .../punctuators/AdvancedPunctuator.scala | 28 -- .../streams/stores/FinatraKeyValueStore.scala | 14 +- .../CachingFinatraKeyValueStoreImpl.scala | 5 +- .../internal/FinatraKeyValueStoreImpl.scala | 35 +-- .../thriftscala/WindowResultType.scala | 11 - .../transformer/CompositeSumAggregator.scala | 144 ---------- .../streams/transformer/SumAggregator.scala | 121 --------- .../transformer/domain/timerResults.scala | 33 --- .../internal/WatermarkTracker.scala | 34 --- .../FinatraAbstractStoreBuilder.scala | 13 - ...InMemoryKeyValueFlushingStoreBuilder.scala | 2 +- 17 files changed, 22 insertions(+), 712 deletions(-) delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/partitioners/RoundRobinStreamPartitioner.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/punctuators/AdvancedPunctuator.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/thriftscala/WindowResultType.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/timerResults.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/FinatraAbstractStoreBuilder.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6e1f0fee8c..4a2a9321ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -22,6 +22,8 @@ Added Changed ~~~~~~~ +* finatra-kafka-streams: Delete deprecated and unused classes. ``PHAB_ID=D267921`` + * finatra-kafka-streams: `c.t.finatra.streams.transformer.domain.Time` is now the canonical representation of time for watermarks and timers. `RichLong` implicit from `com.twitter.finatra.streams.converters.time` has been renamed to `RichFinatraKafkaStreamsLong`. diff --git a/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala b/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala index 7141ea270a..bd49548a85 100644 --- a/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala +++ b/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala @@ -40,14 +40,6 @@ trait QueryableState extends StaticPartitioning { currentShardId = currentShard()) } - @deprecated("Use queryableFinatraWindowStore without a windowSize", "1/7/2019") - protected def queryableFinatraWindowStore[K, V]( - storeName: String, - primaryKeySerde: Serde[K] - ): QueryableFinatraWindowStore[K, V] = { - queryableFinatraWindowStore(storeName, null, primaryKeySerde) - } - protected def queryableFinatraCompositeWindowStore[PK, SK, V]( storeName: String, windowSize: Duration, diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala deleted file mode 100644 index 8d5735bf87..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/FinatraDslV2Implicits.scala +++ /dev/null @@ -1,254 +0,0 @@ -package com.twitter.finatra.kafkastreams.internal.utils - -import com.twitter.app.Flag -import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.kafkastreams.internal.ScalaStreamsImplicits -import com.twitter.finatra.streams.config.DefaultTopicConfig -import com.twitter.finatra.streams.transformer.domain.{ - CompositeKey, - FixedTimeWindowedSerde, - TimeWindowed, - WindowedValue -} -import com.twitter.finatra.streams.transformer.{ - CompositeSumAggregator, - FinatraTransformer, - SumAggregator -} -import com.twitter.inject.Logging -import com.twitter.util.Duration -import org.apache.kafka.common.serialization.Serde -import org.apache.kafka.streams.kstream.Transformer -import org.apache.kafka.streams.scala.kstream.{KStream => KStreamS} -import org.apache.kafka.streams.state.Stores -import org.apache.kafka.streams.{KafkaStreams, StreamsBuilder} -import scala.reflect.ClassTag - -@deprecated("Use FinatraDslWindowedAggregations", "1/7/2019") -trait FinatraDslV2Implicits extends ScalaStreamsImplicits { - - protected def kafkaStreams: KafkaStreams - - protected def streamsStatsReceiver: StatsReceiver - - protected def kafkaStreamsBuilder: StreamsBuilder - - protected def commitInterval: Flag[Duration] - - /* ---------------------------------------- */ - implicit class FinatraKStream[K: ClassTag](inner: KStreamS[K, Int]) extends Logging { - - /** - * For each unique key, sum the values in the stream that occurred within a given time window - * and store those values in a StateStore named stateStore. - * - * A TimeWindow is a tumbling window of fixed length defined by the windowSize parameter. - * - * A Window is closed after event time passes the end of a TimeWindow + allowedLateness. - * - * After a window is closed it is forwarded out of this transformer with a - * [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.WindowClosed]] - * - * If a record arrives after a window is closed it is immediately forwarded out of this - * transformer with a [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.Restatement]] - * - * @param stateStore the name of the StateStore used to maintain the counts. - * @param windowSize splits the stream of data into buckets of data of windowSize, - * based on the timestamp of each message. - * @param allowedLateness allow messages that are upto this amount late to be added to the - * store, otherwise they are emitted as restatements. - * @param queryableAfterClose allow state to be queried upto this amount after the window is - * closed. - * @param keyRangeStart The minimum value that will be stored in the key based on binary sort order. - * @param keySerde Serde for the keys in the StateStore. - * @return a stream of Keys for a particular timewindow, and the sum of the values for that key - * within a particular timewindow. - */ - def sum( - stateStore: String, - windowSize: Duration, - allowedLateness: Duration, - queryableAfterClose: Duration, - keyRangeStart: K, - keySerde: Serde[K] - ): KStreamS[TimeWindowed[K], WindowedValue[Int]] = { - - kafkaStreamsBuilder.addStateStore( - Stores - .keyValueStoreBuilder( - Stores.persistentKeyValueStore(stateStore), - FixedTimeWindowedSerde(keySerde, windowSize), - ScalaSerdes.Int - ) - .withLoggingEnabled(DefaultTopicConfig.FinatraChangelogConfig) - ) - - //Note: The TimerKey is a WindowStartMs value used by MultiAttributeCountAggregator - val timerStore = FinatraTransformer.timerStore(s"$stateStore-TimerStore", ScalaSerdes.Long) - kafkaStreamsBuilder.addStateStore(timerStore) - - val transformerSupplier = () => - new SumAggregator[K, Int]( - commitInterval = commitInterval(), - keyRangeStart = keyRangeStart, - statsReceiver = streamsStatsReceiver, - stateStoreName = stateStore, - timerStoreName = timerStore.name(), - windowSize = windowSize, - allowedLateness = allowedLateness, - queryableAfterClose = queryableAfterClose, - countToAggregate = (key, count) => count, - windowStart = (messageTime, key, value) => - TimeWindowed.windowStart(messageTime, windowSize) - ) - - inner.transform(transformerSupplier, stateStore, timerStore.name) - } - } - - /* ---------------------------------------- */ - implicit class FinatraKeyToWindowedValueStream[K, TimeWindowedType <: TimeWindowed[Int]]( - inner: KStreamS[K, TimeWindowedType]) - extends Logging { - - def sum( - stateStore: String, - allowedLateness: Duration, - queryableAfterClose: Duration, - emitOnClose: Boolean, - windowSize: Duration, - keyRangeStart: K, - keySerde: Serde[K] - ): KStreamS[TimeWindowed[K], WindowedValue[Int]] = { - kafkaStreamsBuilder.addStateStore( - Stores - .keyValueStoreBuilder( - Stores.persistentKeyValueStore(stateStore), - FixedTimeWindowedSerde(keySerde, windowSize), - ScalaSerdes.Int - ) - .withLoggingEnabled(DefaultTopicConfig.FinatraChangelogConfig) - ) - - //Note: The TimerKey is a WindowStartMs value used by MultiAttributeCountAggregator - val timerStore = FinatraTransformer.timerStore(s"$stateStore-TimerStore", ScalaSerdes.Long) - kafkaStreamsBuilder.addStateStore(timerStore) - - val transformerSupplier = ( - () => - new SumAggregator[K, TimeWindowed[Int]]( - commitInterval = commitInterval(), - keyRangeStart = keyRangeStart, - statsReceiver = streamsStatsReceiver, - stateStoreName = stateStore, - timerStoreName = timerStore.name(), - windowSize = windowSize, - allowedLateness = allowedLateness, - emitOnClose = emitOnClose, - queryableAfterClose = queryableAfterClose, - countToAggregate = (key, windowedValue) => windowedValue.value, - windowStart = (messageTime, key, windowedValue) => windowedValue.start - ) - ).asInstanceOf[() => Transformer[ - K, - TimeWindowedType, - (TimeWindowed[K], WindowedValue[Int])]] //Coerce TimeWindowed[Int] into TimeWindowedType :-/ - - inner - .transform(transformerSupplier, stateStore, timerStore.name) - } - } - - /* ---------------------------------------- */ - implicit class FinatraCompositeKeyKStream[CompositeKeyType <: CompositeKey[_, _]: ClassTag]( - inner: KStreamS[CompositeKeyType, Int]) - extends Logging { - - /** - * For each unique composite key, sum the values in the stream that occurred within a given time window. - * - * A composite key is a multi part key that can be efficiently range scanned using the - * primary key, or the primary key and the secondary key. - * - * A TimeWindow is a tumbling window of fixed length defined by the windowSize parameter. - * - * A Window is closed after event time passes the end of a TimeWindow + allowedLateness. - * - * After a window is closed it is forwarded out of this transformer with a - * [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.WindowClosed]] - * - * If a record arrives after a window is closed it is immediately forwarded out of this - * transformer with a [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.Restatement]] - * - * @param stateStore the name of the StateStore used to maintain the counts. - * @param windowSize splits the stream of data into buckets of data of windowSize, - * based on the timestamp of each message. - * @param allowedLateness allow messages that are upto this amount late to be added to the - * store, otherwise they are emitted as restatements. - * @param queryableAfterClose allow state to be queried upto this amount after the window is - * closed. - * @param emitOnClose whether or not to emit a record when the window is closed. - * @param compositeKeyRangeStart The minimum value that will be stored in the key based on binary sort order. - * @param compositeKeySerde serde for the composite key in the StateStore. - * @tparam PrimaryKey the type for the primary key - * @tparam SecondaryKey the type for the secondary key - * - * @return - */ - def compositeSum[PrimaryKey, SecondaryKey]( - stateStore: String, - windowSize: Duration, - allowedLateness: Duration, - queryableAfterClose: Duration, - emitOnClose: Boolean, - compositeKeyRangeStart: CompositeKey[PrimaryKey, SecondaryKey], - compositeKeySerde: Serde[CompositeKeyType] - ): KStreamS[TimeWindowed[PrimaryKey], WindowedValue[scala.collection.Map[SecondaryKey, Int]]] = { - - kafkaStreamsBuilder.addStateStore( - Stores - .keyValueStoreBuilder( - Stores.persistentKeyValueStore(stateStore), - FixedTimeWindowedSerde(compositeKeySerde, windowSize), - ScalaSerdes.Int - ) - .withLoggingEnabled(DefaultTopicConfig.FinatraChangelogConfig) - ) - - //Note: The TimerKey is a WindowStartMs value used by MultiAttributeCountAggregator - val timerStore = FinatraTransformer.timerStore(s"$stateStore-TimerStore", ScalaSerdes.Long) - kafkaStreamsBuilder.addStateStore(timerStore) - - val transformerSupplier = ( - () => - new CompositeSumAggregator[ - PrimaryKey, - SecondaryKey, - CompositeKey[ - PrimaryKey, - SecondaryKey - ]]( - commitInterval = commitInterval(), - compositeKeyRangeStart = compositeKeyRangeStart, - statsReceiver = streamsStatsReceiver, - stateStoreName = stateStore, - timerStoreName = timerStore.name(), - windowSize = windowSize, - allowedLateness = allowedLateness, - queryableAfterClose = queryableAfterClose, - emitOnClose = emitOnClose - ) - ).asInstanceOf[() => Transformer[ - CompositeKeyType, - Int, - (TimeWindowed[PrimaryKey], WindowedValue[scala.collection.Map[SecondaryKey, Int]]) - ]] //Coerce CompositeKey[PrimaryKey, SecondaryKey] into CompositeKeyType :-/ - - inner - .transform(transformerSupplier, stateStore, timerStore.name) - } - - } - -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala index b81032af9c..d39a81ad96 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala @@ -9,11 +9,6 @@ trait ProcessorContextLogging { private val _logger = Logger(getClass) - @deprecated("Use error, warn, info, debug, or trace methods directly") - protected def logger: Logger = { - _logger - } - protected def processorContext: ProcessorContext final protected[this] def error(message: => Any): Unit = { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala index da431239fb..d8d3d8ee44 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala @@ -66,9 +66,7 @@ class ReservoirSamplingTransformer[ sampleStore .deleteRange( IndexedSampleKey.rangeStart(key), - IndexedSampleKey.rangeEnd(key), - maxDeletes = sampleSize - ) + IndexedSampleKey.rangeEnd(key)) numExpiredCounter.incr() } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/partitioners/RoundRobinStreamPartitioner.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/partitioners/RoundRobinStreamPartitioner.scala deleted file mode 100644 index 8d0243b0cb..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/partitioners/RoundRobinStreamPartitioner.scala +++ /dev/null @@ -1,21 +0,0 @@ -package com.twitter.finatra.kafkastreams.partitioners - -import org.apache.kafka.streams.processor.StreamPartitioner - -/** - * Partitions in a round robin fashion going from 0 to numPartitions -1 and wrapping around again. - * - * @tparam K the key on the stream - * @tparam V the value on the stream - */ -@deprecated("no longer supported", "1/7/2019") -class RoundRobinStreamPartitioner[K, V] extends StreamPartitioner[K, V] { - - private var nextPartitionId: Int = 0 - - override def partition(topic: String, key: K, value: V, numPartitions: Int): Integer = { - val partitionIdToReturn = nextPartitionId - nextPartitionId = (nextPartitionId + 1) % numPartitions - partitionIdToReturn - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/punctuators/AdvancedPunctuator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/punctuators/AdvancedPunctuator.scala deleted file mode 100644 index 0914e8d24d..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/punctuators/AdvancedPunctuator.scala +++ /dev/null @@ -1,28 +0,0 @@ -package com.twitter.finatra.kafkastreams.punctuators - -import org.apache.kafka.streams.processor.Punctuator - -/** - * A Punctuator that will only only call 'punctuateAdvanced' when the timestamp is greater than the last timestamp. - * - * *Note* if you extend this class you probably do not want to override 'punctuate' - */ -@deprecated("no longer supported", "1/7/2019") -trait AdvancedPunctuator extends Punctuator { - - private var lastPunctuateTimeMillis = Long.MinValue - - override def punctuate(timestampMillis: Long): Unit = { - if (timestampMillis > lastPunctuateTimeMillis) { - punctuateAdvanced(timestampMillis) - lastPunctuateTimeMillis = timestampMillis - } - } - - /** - * This will only be called if the timestamp is greater than the previous time - * - * @param timestampMillis the timestamp of the punctuate - */ - def punctuateAdvanced(timestampMillis: Long): Unit -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/FinatraKeyValueStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/FinatraKeyValueStore.scala index 617b011451..aef69df439 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/FinatraKeyValueStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/FinatraKeyValueStore.scala @@ -1,5 +1,5 @@ package com.twitter.finatra.streams.stores -import com.twitter.finatra.streams.transformer.domain.TimerResult +import org.apache.kafka.streams.errors.InvalidStateStoreException import org.apache.kafka.streams.processor.TaskId import org.apache.kafka.streams.state.{KeyValueIterator, KeyValueStore} @@ -25,10 +25,18 @@ trait FinatraKeyValueStore[K, V] extends KeyValueStore[K, V] { * @throws NullPointerException If null is used for from or to. * @throws InvalidStateStoreException if the store is not initialized */ + @throws[InvalidStateStoreException] def range(from: K, to: K, allowStaleReads: Boolean): KeyValueIterator[K, V] - @deprecated("no longer supported", "1/7/2019") - def deleteRange(from: K, to: K, maxDeletes: Int = 25000): TimerResult[K] + /** + * Removes the database entries in the range ["from", "to"), i.e., + * including "from" and excluding "to". It is not an error if no keys exist + * in the range ["from", "to"). + * + * @throws InvalidStateStoreException if the store is not initialized + */ + @throws[InvalidStateStoreException] + def deleteRange(from: K, to: K): Unit /** * Delete the value from the store (if there is one) diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/CachingFinatraKeyValueStoreImpl.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/CachingFinatraKeyValueStoreImpl.scala index c3e8efe6fc..222bdda5e6 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/CachingFinatraKeyValueStoreImpl.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/CachingFinatraKeyValueStoreImpl.scala @@ -2,7 +2,6 @@ package com.twitter.finatra.streams.stores.internal import com.twitter.finagle.stats.{Gauge, StatsReceiver} import com.twitter.finatra.streams.stores.{CachingFinatraKeyValueStore, FinatraKeyValueStore} -import com.twitter.finatra.streams.transformer.domain.TimerResult import com.twitter.inject.Logging import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap import java.util @@ -204,9 +203,9 @@ class CachingFinatraKeyValueStoreImpl[K: ClassTag, V]( keyValueStore.deleteRangeExperimentalWithNoChangelogUpdates(beginKeyInclusive, endKeyExclusive) } - override def deleteRange(from: K, to: K, maxDeletes: Int): TimerResult[K] = { + override def deleteRange(from: K, to: K): Unit = { flushObjectCache() - keyValueStore.deleteRange(from, to, maxDeletes) + keyValueStore.deleteRange(from, to) } override def approximateNumEntries(): Long = { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraKeyValueStoreImpl.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraKeyValueStoreImpl.scala index 22e84cea9d..54886fe27a 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraKeyValueStoreImpl.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraKeyValueStoreImpl.scala @@ -5,7 +5,6 @@ import com.twitter.finatra.kafkastreams.internal.utils.ReflectionUtils import com.twitter.finatra.streams.stores.FinatraKeyValueStore import com.twitter.finatra.streams.stores.internal.FinatraKeyValueStoreImpl._ import com.twitter.finatra.streams.transformer.IteratorImplicits -import com.twitter.finatra.streams.transformer.domain.{DeleteTimer, RetainTimer, TimerResult} import com.twitter.inject.Logging import java.util import java.util.Comparator @@ -14,14 +13,9 @@ import org.apache.kafka.common.serialization.{Deserializer, Serializer} import org.apache.kafka.common.utils.Bytes import org.apache.kafka.streams.KeyValue import org.apache.kafka.streams.processor.{ProcessorContext, StateStore, TaskId} -import org.apache.kafka.streams.state.internals.{ - MeteredKeyValueBytesStore, - RocksDBStore, - RocksKeyValueIterator -} +import org.apache.kafka.streams.state.internals.{MeteredKeyValueBytesStore, RocksDBStore, RocksKeyValueIterator} import org.apache.kafka.streams.state.{KeyValueIterator, KeyValueStore, StateSerdes} import org.rocksdb.{RocksDB, WriteOptions} -import scala.collection.JavaConverters._ import scala.reflect.ClassTag object FinatraKeyValueStoreImpl { @@ -175,19 +169,13 @@ case class FinatraKeyValueStoreImpl[K: ClassTag, V]( /* Finatra Additions */ - @deprecated("no longer supported", "1/7/2019") - override def deleteRange(from: K, to: K, maxDeletes: Int = 25000): TimerResult[K] = { + override def deleteRange(from: K, to: K): Unit = { meterLatency(deleteRangeLatencyStat) { val iterator = range(from, to) try { - val keysToDelete = iterator.asScala - .take(maxDeletes) - .map(keyValue => new KeyValue(keyValue.key, null.asInstanceOf[V])) - .toList - .asJava - - putAll(keysToDelete) - deleteOrRetainTimer(iterator) + while (iterator.hasNext) { + delete(iterator.next.key) + } } finally { iterator.close() } @@ -269,19 +257,6 @@ case class FinatraKeyValueStoreImpl[K: ClassTag, V]( } } - @deprecated - private def deleteOrRetainTimer( - iterator: KeyValueIterator[K, _], - onDeleteTimer: => Unit = () => () - ): TimerResult[K] = { - if (iterator.hasNext) { - RetainTimer(stateStoreCursor = iterator.peekNextKeyOpt, throttled = true) - } else { - onDeleteTimer - DeleteTimer() - } - } - private def keyValueStore: KeyValueStore[K, V] = { assert( _keyValueStore != null, diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/thriftscala/WindowResultType.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/thriftscala/WindowResultType.scala deleted file mode 100644 index 563eec85aa..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/thriftscala/WindowResultType.scala +++ /dev/null @@ -1,11 +0,0 @@ -package com.twitter.finatra.streams.thriftscala - -object WindowResultType { - @deprecated("Use com.twitter.finatra.streams.transformer.domain.WindowClosed") - object WindowClosed - extends com.twitter.finatra.streams.transformer.domain.WindowResultType( - com.twitter.finatra.streams.transformer.domain.WindowClosed.value) { - - override def toString: String = "WindowClosed" - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala deleted file mode 100644 index 5eb4afcc1e..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CompositeSumAggregator.scala +++ /dev/null @@ -1,144 +0,0 @@ -package com.twitter.finatra.streams.transformer - -import com.twitter.conversions.DurationOps._ -import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.streams.transformer.FinatraTransformer.WindowStartTime -import com.twitter.finatra.streams.transformer.domain._ -import com.twitter.finatra.streams.transformer.internal.StateStoreImplicits -import com.twitter.util.Duration -import org.apache.kafka.streams.processor.PunctuationType -import org.apache.kafka.streams.state.KeyValueIterator - -@deprecated("Use AggregatorTransformer", "1/7/2019") -class CompositeSumAggregator[K, A, CK <: CompositeKey[K, A]]( - commitInterval: Duration, - compositeKeyRangeStart: CK, - statsReceiver: StatsReceiver, - stateStoreName: String, - timerStoreName: String, - windowSize: Duration, - allowedLateness: Duration, - queryableAfterClose: Duration, - emitOnClose: Boolean = true, - maxActionsPerTimer: Int = 25000) - extends FinatraTransformer[ - CK, - Int, - TimeWindowed[K], - WindowedValue[ - scala.collection.Map[A, Int] - ]](statsReceiver = statsReceiver) - with PersistentTimers - with StateStoreImplicits - with IteratorImplicits { - - private val restatementsCounter = statsReceiver.counter("numRestatements") - private val deletesCounter = statsReceiver.counter("numDeletes") - - private val closedCounter = statsReceiver.counter("closedWindows") - private val expiredCounter = statsReceiver.counter("expiredWindows") - private val getLatencyStat = statsReceiver.stat("getLatency") - private val putLatencyStat = statsReceiver.stat("putLatency") - - private val stateStore = getKeyValueStore[TimeWindowed[CK], Int](stateStoreName) - private val timerStore = getPersistentTimerStore[WindowStartTime]( - timerStoreName, - onEventTimer, - PunctuationType.STREAM_TIME - ) - - override def onMessage(time: Time, compositeKey: CK, count: Int): Unit = { - val windowedCompositeKey = TimeWindowed.forSize(time.hour, windowSize, compositeKey) - if (windowedCompositeKey.isLate(allowedLateness, Watermark(watermark.timeMillis))) { - restatementsCounter.incr() - forward(windowedCompositeKey.map { _ => - compositeKey.primary - }, WindowedValue(Restatement, Map(compositeKey.secondary -> count))) - } else { - val newCount = stateStore.increment( - windowedCompositeKey, - count, - getStat = getLatencyStat, - putStat = putLatencyStat - ) - if (newCount == count) { - val closeTime = windowedCompositeKey.start + windowSize + allowedLateness - if (emitOnClose) { - timerStore.addTimer(closeTime, Close, windowedCompositeKey.start.millis) - } - timerStore.addTimer( - closeTime + queryableAfterClose, - Expire, - windowedCompositeKey.start.millis - ) - } - } - } - - /* - * TimeWindowedKey(2018-08-04T10:00:00.000Z-20-displayed) -> 50 - * TimeWindowedKey(2018-08-04T10:00:00.000Z-20-fav) -> 10 - * TimeWindowedKey(2018-08-04T10:00:00.000Z-30-displayed) -> 30 - * TimeWindowedKey(2018-08-04T10:00:00.000Z-40-retweet) -> 4 - */ - //Note: We use the cursor even for deletes to skip tombstones that may otherwise slow down the range scan - private def onEventTimer( - time: Time, - timerMetadata: TimerMetadata, - windowStartMs: WindowStartTime - ): Unit = { - debug(s"onEventTimer $time $timerMetadata") - val windowStart = Time(windowStartMs) - val windowIterator = stateStore.range( - TimeWindowed.forSize(windowStart, windowSize, compositeKeyRangeStart), - TimeWindowed.forSize(windowStart + 1.millis, windowSize, compositeKeyRangeStart) - ) - - try { - if (timerMetadata == Close) { - onClosed(windowStart, windowIterator) - } else { - onExpired(windowIterator) - } - } finally { - windowIterator.close() - } - } - - private def onClosed( - windowStart: Time, - windowIterator: KeyValueIterator[TimeWindowed[CK], Int] - ): Unit = { - windowIterator - .groupBy( - primaryKey = timeWindowed => timeWindowed.value.primary, - secondaryKey = timeWindowed => timeWindowed.value.secondary, - mapValue = count => count, - sharedMap = true - ) - .take(maxActionsPerTimer) - .foreach { - case (key, countsMap) => - forward( - key = TimeWindowed.forSize(windowStart, windowSize, key), - value = WindowedValue(resultState = WindowClosed, value = countsMap) - ) - } - - closedCounter.incr() - } - - //Note: We call "put" w/ a null value instead of calling "delete" since "delete" also gets the previous value :-/ - //TODO: Consider performing deletes in a transaction so that queryable state sees all or no keys per "primary key" - private def onExpired(windowIterator: KeyValueIterator[TimeWindowed[CK], Int]): Unit = { - windowIterator - .take(maxActionsPerTimer) - .foreach { - case (timeWindowedCompositeKey, count) => - deletesCounter.incr() - stateStore.put(timeWindowedCompositeKey, null.asInstanceOf[Int]) - } - - expiredCounter.incr() - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala deleted file mode 100644 index e3faf5fc71..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SumAggregator.scala +++ /dev/null @@ -1,121 +0,0 @@ -package com.twitter.finatra.streams.transformer - -import com.twitter.conversions.DurationOps._ -import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.streams.transformer.FinatraTransformer.WindowStartTime -import com.twitter.finatra.streams.transformer.domain._ -import com.twitter.finatra.streams.transformer.internal.StateStoreImplicits -import com.twitter.util.Duration -import org.apache.kafka.streams.state.KeyValueIterator -import org.apache.kafka.streams.processor.PunctuationType - -@deprecated("Use AggregatorTransformer") -class SumAggregator[K, V]( - commitInterval: Duration, - keyRangeStart: K, - statsReceiver: StatsReceiver, - stateStoreName: String, - timerStoreName: String, - windowSize: Duration, - allowedLateness: Duration, - queryableAfterClose: Duration, - windowStart: (Time, K, V) => Time, - countToAggregate: (K, V) => Int, - emitOnClose: Boolean = true, - maxActionsPerTimer: Int = 25000) - extends FinatraTransformer[ - K, - V, - TimeWindowed[K], - WindowedValue[ - Int - ]](statsReceiver = statsReceiver) - with PersistentTimers - with StateStoreImplicits - with IteratorImplicits { - - private val restatementsCounter = statsReceiver.counter("numRestatements") - private val closedCounter = statsReceiver.counter("closedWindows") - private val expiredCounter = statsReceiver.counter("expiredWindows") - - private val stateStore = getKeyValueStore[TimeWindowed[K], Int](stateStoreName) - private val timerStore = getPersistentTimerStore[WindowStartTime]( - timerStoreName, - onEventTimer, - PunctuationType.STREAM_TIME - ) - - override def onMessage(time: Time, key: K, value: V): Unit = { - val windowedKey = TimeWindowed.forSize( - start = windowStart(time, key, value), - size = windowSize, - value = key - ) - - val count = countToAggregate(key, value) - if (windowedKey.isLate(allowedLateness, Watermark(watermark.timeMillis))) { - restatementsCounter.incr() - forward(windowedKey, WindowedValue(Restatement, count)) - } else { - val newCount = stateStore.increment(windowedKey, count) - if (newCount == count) { - val closeTime = windowedKey.start + windowSize + allowedLateness - if (emitOnClose) { - timerStore.addTimer(closeTime, Close, windowedKey.start.millis) - } - timerStore.addTimer( - closeTime + queryableAfterClose, - Expire, - windowedKey.start.millis - ) - } - } - } - - private def onEventTimer( - time: Time, - timerMetadata: TimerMetadata, - windowStartMs: WindowStartTime - ): Unit = { - val windowStart = Time(windowStartMs) - val hourlyWindowIterator = stateStore.range( - TimeWindowed.forSize(windowStart, windowSize, keyRangeStart), - TimeWindowed.forSize(windowStart + 1.millis, windowSize, keyRangeStart) - ) - - try { - if (timerMetadata == Close) { - onClosed(windowStart, hourlyWindowIterator) - } else { - onExpired(hourlyWindowIterator) - } - } finally { - hourlyWindowIterator.close() - } - } - - private def onClosed( - windowStart: Time, - windowIterator: KeyValueIterator[TimeWindowed[K], Int] - ): Unit = { - windowIterator - .take(maxActionsPerTimer) - .foreach { - case (key, value) => - forward(key = key, value = WindowedValue(resultState = WindowClosed, value = value)) - } - - closedCounter.incr() - } - - private def onExpired(windowIterator: KeyValueIterator[TimeWindowed[K], Int]): Unit = { - windowIterator - .take(maxActionsPerTimer) - .foreach { - case (key, value) => - stateStore.deleteWithoutGettingPriorValue(key) - } - - expiredCounter.incr() - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/timerResults.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/timerResults.scala deleted file mode 100644 index 78aee918ab..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/timerResults.scala +++ /dev/null @@ -1,33 +0,0 @@ -package com.twitter.finatra.streams.transformer.domain - -/** - * Indicates the result of a Timer-based operation. - */ -sealed trait TimerResult[SK] { - def map[SKR](f: SK => SKR): TimerResult[SKR] = { - this match { - case result @ RetainTimer(Some(cursor), throttled) => - result.copy(stateStoreCursor = Some(f(cursor))) - case _ => - this.asInstanceOf[TimerResult[SKR]] - } - } -} - -/** - * A [[TimerResult]] that represents the completion of a deletion. - * - * @param throttled Indicates the number of operations has surpassed those allocated - * for a period of time. - */ -case class DeleteTimer[SK](throttled: Boolean = false) extends TimerResult[SK] - -/** - * A [[TimerResult]] that represents the retention of an incomplete deletion. - * - * @param stateStoreCursor A cursor representing the next key in an iterator. - * @param throttled Indicates the number of operations has surpassed those allocated - * for a period of time. - */ -case class RetainTimer[SK](stateStoreCursor: Option[SK] = None, throttled: Boolean = false) - extends TimerResult[SK] diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala deleted file mode 100644 index 355e4ffacd..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/WatermarkTracker.scala +++ /dev/null @@ -1,34 +0,0 @@ -package com.twitter.finatra.streams.transformer.internal - -import com.twitter.finatra.streams.transformer.domain.Time - -//TODO: Need method called by processing timer so that watermarks can be emitted without input records -class WatermarkTracker { - private var _watermark: Long = 0L - reset() - - def watermark: Time = Time(_watermark) - - def reset(): Unit = { - _watermark = 0L - } - - /** - * @param timestamp - * - * @return True if watermark changed - */ - //TODO: Verify topic is correct when merging inputs - //TODO: Also take in deserialized key and value since we can extract source info (e.g. source of interactions) - //TODO: Also take in maxOutOfOrder param - //TODO: Use rolling histogram - def track(topic: String, timestamp: Long): Boolean = { - val potentialWatermark = timestamp - 1 - if (potentialWatermark > _watermark) { - _watermark = potentialWatermark - true - } else { - false - } - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/FinatraAbstractStoreBuilder.scala b/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/FinatraAbstractStoreBuilder.scala deleted file mode 100644 index cd7578a65a..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/FinatraAbstractStoreBuilder.scala +++ /dev/null @@ -1,13 +0,0 @@ -package org.apache.kafka.streams.state.internals - -import org.apache.kafka.common.serialization.Serde -import org.apache.kafka.common.utils.Time -import org.apache.kafka.streams.processor.StateStore - -/* Note: To avoid code duplication for now, this class is created for access to package protected AbstractStoreBuilder */ -abstract class FinatraAbstractStoreBuilder[K, V, T <: StateStore]( - name: String, - keySerde: Serde[K], - valueSerde: Serde[V], - time: Time) - extends AbstractStoreBuilder[K, V, T](name, keySerde, valueSerde, time) diff --git a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala b/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala index 35240489ea..e066628eec 100644 --- a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala +++ b/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala @@ -9,7 +9,7 @@ class InMemoryKeyValueFlushingStoreBuilder[K, V]( keySerde: Serde[K], valueSerde: Serde[V], time: Time = Time.SYSTEM) - extends FinatraAbstractStoreBuilder[K, V, KeyValueStore[K, V]](name, keySerde, valueSerde, time) { + extends AbstractStoreBuilder[K, V, KeyValueStore[K, V]](name, keySerde, valueSerde, time) { override def build(): KeyValueStore[K, V] = { val inMemoryKeyValueStore = new InMemoryKeyValueStore[K, V](name, keySerde, valueSerde) From a2ad0ef3df8de67486a49ddcc3661fb030a3670c Mon Sep 17 00:00:00 2001 From: Steve Cosenza Date: Sat, 2 Feb 2019 01:56:36 +0000 Subject: [PATCH 23/45] finatra-kafka-streams: Refactor package names Problem Finatra Kafka Streams contains some unorganized packages Solution Move all classes in com.twitter.finatra.streams to com.twitter.finatra.kafkastreams and further organize package names Result Finatra Kafka Streams has a more organized package structure JIRA Issues: DINS-2607 Differential Revision: https://phabricator.twitter.biz/D268027 --- CHANGELOG.rst | 3 + .../prerestore/PreRestoreState.scala | 6 +- .../PreRestoreWordCountRocksDbServer.scala | 2 +- .../queryable/thrift/QueryableState.scala | 8 +- .../partitioning/StaticPartitioning.scala | 4 +- .../internal/ClientStateAndHostInfo.scala | 2 +- ...titioningKafkaClientSupplierSupplier.scala | 2 +- .../StaticPartitioningStreamAssignor.scala | 2 +- .../internal/StaticTaskAssignor.scala | 10 +- .../internal/TaskAssignments.scala | 2 +- .../assignment/StaticTaskAssignorTest.scala | 7 +- .../kafka-streams/src/main/java/BUILD | 5 +- .../InMemoryKeyValueFlushingLoggedStore.java | 154 ------------------ .../internals/StoreChangeFlushingLogger.java | 153 ----------------- .../KafkaStreamsTwitterServer.scala | 20 +-- .../config/DefaultTopicConfig.scala | 4 +- .../config}/FinatraTransformerFlags.scala | 4 +- .../config}/RocksDbFlags.scala | 3 +- .../kafkastreams/dsl/FinatraDslSampling.scala | 11 +- .../dsl/FinatraDslWindowedAggregations.scala | 34 ++-- .../internal => flushing}/AsyncFlushing.scala | 6 +- .../AsyncProcessor.scala | 6 +- .../AsyncTransformer.scala | 12 +- .../internal => flushing}/Flushing.scala | 4 +- .../FlushingAwareServer.scala | 2 +- .../FlushingProcessor.scala | 7 +- .../FlushingTransformer.scala | 3 +- ...StreamsMonitoringConsumerInterceptor.scala | 4 +- .../internal/serde/AvoidDefaultSerde.scala | 2 +- .../KafkaStreamsFinagleMetricsReporter.scala | 4 +- .../internal/stats/RocksDBStatsCallback.scala | 2 +- .../internal/utils/KafkaFlagUtils.scala | 4 +- .../utils/ProcessorContextLogging.scala | 3 +- .../internal/utils/ReflectionUtils.scala | 2 +- .../utils/TopologyReflectionUtils.scala | 2 +- .../utils/sampling}/IndexedSampleKey.scala | 2 +- .../sampling/IndexedSampleKeySerde.scala | 5 +- .../ReservoirSamplingTransformer.scala | 10 +- ...QueryableFinatraCompositeWindowStore.scala | 16 +- .../query/QueryableFinatraKeyValueStore.scala | 11 +- .../query/QueryableFinatraWindowStore.scala | 16 +- .../transformer/FinatraTransformer.scala | 22 ++- .../aggregation}/AggregatorTransformer.scala | 20 ++- .../aggregation}/FixedTimeWindowedSerde.scala | 3 +- .../aggregation}/TimeWindowed.scala | 4 +- .../aggregation}/WindowValueResult.scala | 2 +- .../aggregation/WindowedValue.scala | 3 + .../aggregation}/WindowedValueSerde.scala | 6 +- .../transformer/domain/CompositeKey.scala | 2 +- .../transformer/domain/Time.scala | 5 +- .../transformer/domain/TimerMetadata.scala | 4 +- .../internal/ProcessorContextUtils.scala | 2 +- .../transformer/lifecycle/OnClose.scala | 5 + .../transformer/lifecycle}/OnFlush.scala | 2 +- .../transformer/lifecycle/OnInit.scala | 5 + .../transformer/lifecycle/OnWatermark.scala | 7 + .../stores/CachingFinatraKeyValueStore.scala | 2 +- .../stores}/CachingKeyValueStores.scala | 11 +- .../stores/FinatraKeyValueStore.scala | 7 +- .../stores}/PersistentTimerStore.scala | 14 +- .../stores}/PersistentTimers.scala | 10 +- .../CachingFinatraKeyValueStoreImpl.scala | 4 +- .../internal/FinatraKeyValueStoreImpl.scala | 13 +- .../internal/FinatraStoresGlobalManager.scala | 9 +- .../transformer/stores/internal}/Timer.scala | 6 +- .../stores/internal}/TimerSerde.scala | 3 +- .../utils}/IteratorImplicits.scala | 3 +- .../utils}/MultiSpanIterator.scala | 2 +- .../transformer/utils}/SamplingUtils.scala | 2 +- .../watermarks/DefaultWatermarkAssignor.scala | 4 +- .../PeriodicWatermarkManager.scala | 2 +- .../transformer/watermarks}/Watermark.scala | 4 +- .../watermarks/WatermarkAssignor.scala | 4 +- .../watermarks}/WatermarkManager.scala | 9 +- .../utils}/RocksKeyValueIterator.scala | 2 +- .../ScalaStreamsImplicits.scala | 2 +- .../StatelessKafkaStreamsTwitterServer.scala | 3 +- .../utils}/time.scala | 4 +- .../package.scala => utils/utils.scala} | 2 +- .../streams/transformer/OnWatermark.scala | 7 - .../transformer/domain/WindowedValue.scala | 9 - .../transformer/internal/OnClose.scala | 5 - .../streams/transformer/internal/OnInit.scala | 5 - .../internal/StateStoreImplicits.scala | 45 ----- ...InMemoryKeyValueFlushingStoreBuilder.scala | 20 --- .../kafka-streams/src/test/scala/BUILD | 94 ++++++++++- .../src/test/scala/com/twitter/BUILD | 66 -------- .../KafkaStreamsAdminServerFeatureTest.scala | 2 +- .../WordLookupAsyncServer.scala | 4 +- .../WordLookupAsyncServerFeatureTest.scala | 2 +- ...LookupAsyncServerTopologyFeatureTest.scala | 4 +- .../WordLookupAsyncTransformer.scala | 5 +- .../integration/compositesum/UserClicks.scala | 5 + .../compositesum/UserClicksSerde.scala | 2 +- .../compositesum/UserClicksServer.scala | 6 +- .../UserClicksTopologyFeatureTest.scala | 9 +- .../compositesum/UserClicksTypes.scala | 2 +- .../DefaultSerdeWordCountDbServer.scala | 2 +- ...faultSerdeWordCountServerFeatureTest.scala | 2 +- .../WordLengthFinatraTransformer.scala | 9 +- .../finatratransformer/WordLengthServer.scala | 6 +- .../WordLengthServerTopologyFeatureTest.scala | 4 +- .../globaltable/GlobalTableServer.scala | 2 +- .../GlobalTableServerFeatureTest.scala | 2 +- .../integration/sampling/SamplingServer.scala | 18 +- .../SamplingServerTopologyFeatureTest.scala | 28 ++-- .../stateless/VerifyFailureServer.scala | 4 +- .../VerifyFailureServerFeatureTest.scala | 2 +- .../window/WindowedTweetWordCountServer.scala | 4 +- ...etWordCountServerTopologyFeatureTest.scala | 8 +- .../wordcount/WordCountRocksDbServer.scala | 2 +- .../WordCountServerFeatureTest.scala | 2 +- .../WordCountServerTopologyFeatureTest.scala | 4 +- .../WordCountInMemoryServer.scala | 2 +- .../WordCountInMemoryServerFeatureTest.scala | 2 +- .../test}/FinatraTopologyTester.scala | 16 +- .../test}/TopologyFeatureTest.scala | 2 +- .../test}/TopologyTesterTopic.scala | 4 +- .../transformer/FinatraTransformerTest.scala | 14 +- .../transformer/domain}/TimeTest.scala | 3 +- .../stores}/PersistentTimerStoreTest.scala | 10 +- .../FinatraKeyValueStoreLatencyTest.scala | 3 +- .../utils}/MultiSpanIteratorTest.scala | 3 +- .../twitter/finatra/streams/transformer/BUILD | 12 -- .../test/scala/com/twitter/unittests/BUILD | 26 --- .../integration/compositesum/UserClicks.scala | 5 - 126 files changed, 417 insertions(+), 849 deletions(-) rename kafka-streams/kafka-streams-prerestore/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/prerestore/PreRestoreState.scala (97%) rename kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/partitioning/StaticPartitioning.scala (80%) rename kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/partitioning/internal/ClientStateAndHostInfo.scala (86%) rename kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/partitioning/internal/StaticPartitioningKafkaClientSupplierSupplier.scala (91%) rename kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/partitioning/internal/StaticPartitioningStreamAssignor.scala (97%) rename kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/partitioning/internal/StaticTaskAssignor.scala (97%) rename kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/partitioning/internal/TaskAssignments.scala (88%) delete mode 100644 kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingLoggedStore.java delete mode 100644 kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/StoreChangeFlushingLogger.java rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/config/DefaultTopicConfig.scala (95%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/flags => kafkastreams/config}/FinatraTransformerFlags.scala (89%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/flags => kafkastreams/config}/RocksDbFlags.scala (95%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors/internal => flushing}/AsyncFlushing.scala (91%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors => flushing}/AsyncProcessor.scala (81%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors => flushing}/AsyncTransformer.scala (95%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors/internal => flushing}/Flushing.scala (92%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors => flushing}/FlushingAwareServer.scala (94%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors => flushing}/FlushingProcessor.scala (69%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors => flushing}/FlushingTransformer.scala (54%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams/internal}/interceptors/KafkaStreamsMonitoringConsumerInterceptor.scala (81%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/domain => kafkastreams/internal/utils/sampling}/IndexedSampleKey.scala (91%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/query/QueryableFinatraCompositeWindowStore.scala (90%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/query/QueryableFinatraKeyValueStore.scala (92%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/query/QueryableFinatraWindowStore.scala (83%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/FinatraTransformer.scala (88%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/aggregation}/AggregatorTransformer.scala (90%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/domain => kafkastreams/transformer/aggregation}/FixedTimeWindowedSerde.scala (94%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/domain => kafkastreams/transformer/aggregation}/TimeWindowed.scala (92%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/domain => kafkastreams/transformer/aggregation}/WindowValueResult.scala (93%) create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowedValue.scala rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/domain => kafkastreams/transformer/aggregation}/WindowedValueSerde.scala (87%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/domain/CompositeKey.scala (52%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/domain/Time.scala (90%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/domain/TimerMetadata.scala (89%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/internal/ProcessorContextUtils.scala (93%) create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnClose.scala rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/internal => kafkastreams/transformer/lifecycle}/OnFlush.scala (75%) create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnInit.scala create mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnWatermark.scala rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams/transformer}/stores/CachingFinatraKeyValueStore.scala (88%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/stores}/CachingKeyValueStores.scala (76%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams/transformer}/stores/FinatraKeyValueStore.scala (96%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/stores}/PersistentTimerStore.scala (89%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/stores}/PersistentTimers.scala (84%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams/transformer}/stores/internal/CachingFinatraKeyValueStoreImpl.scala (97%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams/transformer}/stores/internal/FinatraKeyValueStoreImpl.scala (95%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams/transformer}/stores/internal/FinatraStoresGlobalManager.scala (86%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/internal/domain => kafkastreams/transformer/stores/internal}/Timer.scala (53%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/internal/domain => kafkastreams/transformer/stores/internal}/TimerSerde.scala (90%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/utils}/IteratorImplicits.scala (98%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/utils}/MultiSpanIterator.scala (96%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/utils}/SamplingUtils.scala (84%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/watermarks/DefaultWatermarkAssignor.scala (80%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer => kafkastreams/transformer/watermarks}/PeriodicWatermarkManager.scala (86%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/domain => kafkastreams/transformer/watermarks}/Watermark.scala (64%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/watermarks/WatermarkAssignor.scala (51%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/transformer/watermarks/internal => kafkastreams/transformer/watermarks}/WatermarkManager.scala (86%) rename kafka-streams/kafka-streams/src/main/scala/{org/apache/kafka/streams/state/internals => com/twitter/finatra/kafkastreams/utils}/RocksKeyValueIterator.scala (96%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{internal => utils}/ScalaStreamsImplicits.scala (98%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{ => utils}/StatelessKafkaStreamsTwitterServer.scala (89%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/{streams/converters => kafkastreams/utils}/time.scala (82%) rename kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/{processors/package.scala => utils/utils.scala} (72%) delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/OnWatermark.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowedValue.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnClose.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnInit.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/StateStoreImplicits.scala delete mode 100644 kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala delete mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/BUILD rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/admin/KafkaStreamsAdminServerFeatureTest.scala (97%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/async_transformer/WordLookupAsyncServer.scala (85%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/async_transformer/WordLookupAsyncServerFeatureTest.scala (94%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/async_transformer/WordLookupAsyncServerTopologyFeatureTest.scala (87%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/async_transformer/WordLookupAsyncTransformer.scala (82%) create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicks.scala rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/compositesum/UserClicksSerde.scala (89%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/compositesum/UserClicksServer.scala (79%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/compositesum/UserClicksTopologyFeatureTest.scala (87%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/compositesum/UserClicksTypes.scala (84%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/default_serde/DefaultSerdeWordCountDbServer.scala (93%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/default_serde/DefaultSerdeWordCountServerFeatureTest.scala (94%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/finatratransformer/WordLengthFinatraTransformer.scala (70%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/finatratransformer/WordLengthServer.scala (81%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/finatratransformer/WordLengthServerTopologyFeatureTest.scala (89%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/globaltable/GlobalTableServer.scala (92%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/globaltable/GlobalTableServerFeatureTest.scala (96%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/sampling/SamplingServer.scala (85%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/sampling/SamplingServerTopologyFeatureTest.scala (76%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/stateless/VerifyFailureServer.scala (84%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/stateless/VerifyFailureServerFeatureTest.scala (92%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/window/WindowedTweetWordCountServer.scala (87%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/window/WindowedTweetWordCountServerTopologyFeatureTest.scala (84%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/wordcount/WordCountRocksDbServer.scala (93%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/wordcount/WordCountServerFeatureTest.scala (98%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/wordcount/WordCountServerTopologyFeatureTest.scala (90%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/wordcount_in_memory/WordCountInMemoryServer.scala (94%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams}/integration/wordcount_in_memory/WordCountInMemoryServerFeatureTest.scala (96%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/{streams/tests => kafkastreams/test}/FinatraTopologyTester.scala (95%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/{streams/tests => kafkastreams/test}/TopologyFeatureTest.scala (97%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/{streams/tests => kafkastreams/test}/TopologyTesterTopic.scala (95%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/{streams => kafkastreams}/transformer/FinatraTransformerTest.scala (91%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams/transformer/domain}/TimeTest.scala (95%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams/transformer/stores}/PersistentTimerStoreTest.scala (93%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams/transformer/stores/internal}/FinatraKeyValueStoreLatencyTest.scala (98%) rename kafka-streams/kafka-streams/src/test/scala/com/twitter/{unittests => finatra/kafkastreams/transformer/utils}/MultiSpanIteratorTest.scala (93%) delete mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD delete mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD delete mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicks.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4a2a9321ce..83312740f3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -22,6 +22,9 @@ Added Changed ~~~~~~~ +* finatra-kafka-streams: Refactor package names. All classes moved from + com.twitter.finatra.streams to com.twitter.finatra.kafkastreams. ``PHAB_ID=D268027`` + * finatra-kafka-streams: Delete deprecated and unused classes. ``PHAB_ID=D267921`` * finatra-kafka-streams: `c.t.finatra.streams.transformer.domain.Time` is now the canonical diff --git a/kafka-streams/kafka-streams-prerestore/src/main/scala/com/twitter/finatra/streams/prerestore/PreRestoreState.scala b/kafka-streams/kafka-streams-prerestore/src/main/scala/com/twitter/finatra/kafkastreams/prerestore/PreRestoreState.scala similarity index 97% rename from kafka-streams/kafka-streams-prerestore/src/main/scala/com/twitter/finatra/streams/prerestore/PreRestoreState.scala rename to kafka-streams/kafka-streams-prerestore/src/main/scala/com/twitter/finatra/kafkastreams/prerestore/PreRestoreState.scala index bbcfcd64c8..820d461fd2 100644 --- a/kafka-streams/kafka-streams-prerestore/src/main/scala/com/twitter/finatra/streams/prerestore/PreRestoreState.scala +++ b/kafka-streams/kafka-streams-prerestore/src/main/scala/com/twitter/finatra/kafkastreams/prerestore/PreRestoreState.scala @@ -1,10 +1,10 @@ -package com.twitter.finatra.streams.prerestore +package com.twitter.finatra.kafkastreams.prerestore -import com.twitter.finatra.annotations.Experimental import com.twitter.conversions.DurationOps._ +import com.twitter.finatra.annotations.Experimental import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer import com.twitter.finatra.kafkastreams.internal.utils.ReflectionUtils -import com.twitter.finatra.streams.partitioning.StaticPartitioning +import com.twitter.finatra.kafkastreams.partitioning.StaticPartitioning import java.util.Properties import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger diff --git a/kafka-streams/kafka-streams-prerestore/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/PreRestoreWordCountRocksDbServer.scala b/kafka-streams/kafka-streams-prerestore/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/PreRestoreWordCountRocksDbServer.scala index e7621ff58b..3b51af5001 100644 --- a/kafka-streams/kafka-streams-prerestore/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/PreRestoreWordCountRocksDbServer.scala +++ b/kafka-streams/kafka-streams-prerestore/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/PreRestoreWordCountRocksDbServer.scala @@ -2,7 +2,7 @@ package com.twitter.finatra.kafkastreams.integration.wordcount import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.streams.prerestore.PreRestoreState +import com.twitter.finatra.kafkastreams.prerestore.PreRestoreState import org.apache.kafka.common.serialization.Serdes import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream.{Consumed, Materialized, Produced, Serialized} diff --git a/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala b/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala index bd49548a85..d23891cc9e 100644 --- a/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala +++ b/kafka-streams/kafka-streams-queryable-thrift/src/main/scala/com/twitter/finatra/streams/queryable/thrift/QueryableState.scala @@ -1,12 +1,8 @@ package com.twitter.finatra.streams.queryable.thrift import com.twitter.app.Flag -import com.twitter.finatra.streams.partitioning.StaticPartitioning -import com.twitter.finatra.streams.query.{ - QueryableFinatraCompositeWindowStore, - QueryableFinatraKeyValueStore, - QueryableFinatraWindowStore -} +import com.twitter.finatra.kafkastreams.partitioning.StaticPartitioning +import com.twitter.finatra.kafkastreams.query.{QueryableFinatraCompositeWindowStore, QueryableFinatraKeyValueStore, QueryableFinatraWindowStore} import com.twitter.util.Duration import org.apache.kafka.common.serialization.Serde diff --git a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/StaticPartitioning.scala b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/StaticPartitioning.scala similarity index 80% rename from kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/StaticPartitioning.scala rename to kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/StaticPartitioning.scala index 76544b0636..a195451c3e 100644 --- a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/StaticPartitioning.scala +++ b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/StaticPartitioning.scala @@ -1,8 +1,8 @@ -package com.twitter.finatra.streams.partitioning +package com.twitter.finatra.kafkastreams.partitioning import com.twitter.app.Flag import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.streams.partitioning.internal.StaticPartitioningKafkaClientSupplierSupplier +import com.twitter.finatra.kafkastreams.partitioning.internal.StaticPartitioningKafkaClientSupplierSupplier import org.apache.kafka.streams.KafkaClientSupplier object StaticPartitioning { diff --git a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/ClientStateAndHostInfo.scala b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/ClientStateAndHostInfo.scala similarity index 86% rename from kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/ClientStateAndHostInfo.scala rename to kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/ClientStateAndHostInfo.scala index 2735b33d20..8f7897f173 100644 --- a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/ClientStateAndHostInfo.scala +++ b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/ClientStateAndHostInfo.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.partitioning.internal +package com.twitter.finatra.kafkastreams.partitioning.internal import com.twitter.finatra.streams.queryable.thrift.domain.ServiceShardId import org.apache.kafka.streams.processor.internals.assignment.ClientState diff --git a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticPartitioningKafkaClientSupplierSupplier.scala b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticPartitioningKafkaClientSupplierSupplier.scala similarity index 91% rename from kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticPartitioningKafkaClientSupplierSupplier.scala rename to kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticPartitioningKafkaClientSupplierSupplier.scala index 74d01618f0..1bf5720d7a 100644 --- a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticPartitioningKafkaClientSupplierSupplier.scala +++ b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticPartitioningKafkaClientSupplierSupplier.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.partitioning.internal +package com.twitter.finatra.kafkastreams.partitioning.internal import java.util import org.apache.kafka.clients.consumer.{Consumer, ConsumerConfig} diff --git a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticPartitioningStreamAssignor.scala b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticPartitioningStreamAssignor.scala similarity index 97% rename from kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticPartitioningStreamAssignor.scala rename to kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticPartitioningStreamAssignor.scala index 8da58e1a15..2b5f2de279 100644 --- a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticPartitioningStreamAssignor.scala +++ b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticPartitioningStreamAssignor.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.partitioning.internal +package com.twitter.finatra.kafkastreams.partitioning.internal import com.twitter.finatra.streams.queryable.thrift.domain.ServiceShardId import com.twitter.finatra.streams.queryable.thrift.partitioning.StaticServiceShardPartitioner diff --git a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticTaskAssignor.scala b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticTaskAssignor.scala similarity index 97% rename from kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticTaskAssignor.scala rename to kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticTaskAssignor.scala index 0a9964e0d4..8d08f85a16 100644 --- a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/StaticTaskAssignor.scala +++ b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/StaticTaskAssignor.scala @@ -1,12 +1,8 @@ -package com.twitter.finatra.streams.partitioning.internal +package com.twitter.finatra.kafkastreams.partitioning.internal import com.twitter.finagle.stats.LoadedStatsReceiver -import com.twitter.finatra.streams.partitioning.StaticPartitioning -import com.twitter.finatra.streams.queryable.thrift.domain.{ - KafkaGroupId, - KafkaPartitionId, - ServiceShardId -} +import com.twitter.finatra.kafkastreams.partitioning.StaticPartitioning +import com.twitter.finatra.streams.queryable.thrift.domain.{KafkaGroupId, KafkaPartitionId, ServiceShardId} import com.twitter.finatra.streams.queryable.thrift.partitioning.ServiceShardPartitioner import com.twitter.inject.Logging import org.apache.kafka.streams.processor.TaskId diff --git a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/TaskAssignments.scala b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/TaskAssignments.scala similarity index 88% rename from kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/TaskAssignments.scala rename to kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/TaskAssignments.scala index 4ce2943d0d..9a48fc3ebc 100644 --- a/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/streams/partitioning/internal/TaskAssignments.scala +++ b/kafka-streams/kafka-streams-static-partitioning/src/main/scala/com/twitter/finatra/kafkastreams/partitioning/internal/TaskAssignments.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.partitioning.internal +package com.twitter.finatra.kafkastreams.partitioning.internal import org.apache.kafka.streams.processor.TaskId import scala.collection.mutable.ArrayBuffer diff --git a/kafka-streams/kafka-streams-static-partitioning/src/test/scala/org/apache/kafka/streams/processor/internals/assignment/StaticTaskAssignorTest.scala b/kafka-streams/kafka-streams-static-partitioning/src/test/scala/org/apache/kafka/streams/processor/internals/assignment/StaticTaskAssignorTest.scala index 34c6ed1ea9..8e8499f7ea 100644 --- a/kafka-streams/kafka-streams-static-partitioning/src/test/scala/org/apache/kafka/streams/processor/internals/assignment/StaticTaskAssignorTest.scala +++ b/kafka-streams/kafka-streams-static-partitioning/src/test/scala/org/apache/kafka/streams/processor/internals/assignment/StaticTaskAssignorTest.scala @@ -1,10 +1,7 @@ package org.apache.kafka.streams.processor.internals.assignment -import com.twitter.finatra.streams.partitioning.StaticPartitioning -import com.twitter.finatra.streams.partitioning.internal.{ - ClientStateAndHostInfo, - StaticTaskAssignor -} +import com.twitter.finatra.kafkastreams.partitioning.StaticPartitioning +import com.twitter.finatra.kafkastreams.partitioning.internal.{ClientStateAndHostInfo, StaticTaskAssignor} import com.twitter.finatra.streams.queryable.thrift.partitioning.StaticServiceShardPartitioner import com.twitter.inject.Test import java.util diff --git a/kafka-streams/kafka-streams/src/main/java/BUILD b/kafka-streams/kafka-streams/src/main/java/BUILD index 0e8b8664b7..1027df6be8 100644 --- a/kafka-streams/kafka-streams/src/main/java/BUILD +++ b/kafka-streams/kafka-streams/src/main/java/BUILD @@ -1,8 +1,6 @@ java_library( sources = rglobs( "com/twitter/finatra/kafkastreams/*.java", - "com/twitter/finatra/streams/*.java", - "org/*.java", ), compiler_option_sets = {}, provides = artifact( @@ -11,10 +9,11 @@ java_library( repo = artifactory, ), dependencies = [ - "3rdparty/jvm/org/agrona", "3rdparty/jvm/org/apache/kafka:kafka-clients", "3rdparty/jvm/org/apache/kafka:kafka-streams", ], exports = [ + "3rdparty/jvm/org/apache/kafka:kafka-clients", + "3rdparty/jvm/org/apache/kafka:kafka-streams", ], ) diff --git a/kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingLoggedStore.java b/kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingLoggedStore.java deleted file mode 100644 index 597c473fb7..0000000000 --- a/kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingLoggedStore.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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 org.apache.kafka.streams.state.internals; - -// SUPPRESS CHECKSTYLE:OFF LineLength -// SUPPRESS CHECKSTYLE:OFF ModifierOrder -// SUPPRESS CHECKSTYLE:OFF OperatorWrap -// SUPPRESS CHECKSTYLE:OFF HiddenField -// SUPPRESS CHECKSTYLE:OFF NeedBraces -// SUPPRESS CHECKSTYLE:OFF NestedForDepth -// SUPPRESS CHECKSTYLE:OFF JavadocStyle -// SUPPRESS CHECKSTYLE:OFF NestedForDepth - -import java.util.List; - -import org.apache.kafka.common.serialization.Serde; -import org.apache.kafka.streams.KeyValue; -import org.apache.kafka.streams.processor.ProcessorContext; -import org.apache.kafka.streams.processor.StateStore; -import org.apache.kafka.streams.processor.internals.ProcessorStateManager; -import org.apache.kafka.streams.state.KeyValueIterator; -import org.apache.kafka.streams.state.KeyValueStore; -import org.apache.kafka.streams.state.StateSerdes; - -//Note: This class is copied from Kafka Streams InMemoryKeyValueLoggedStore with the only changes commented with "Twitter Changed" -public class InMemoryKeyValueFlushingLoggedStore extends WrappedStateStore.AbstractStateStore implements KeyValueStore { - - private final KeyValueStore inner; - private final Serde keySerde; - private final Serde valueSerde; - - // Twitter Changed - //private StoreChangeLogger changeLogger; - private StoreChangeFlushingLogger changeLogger; - - InMemoryKeyValueFlushingLoggedStore(final KeyValueStore inner, Serde keySerde, Serde valueSerde) { - super(inner); - this.inner = inner; - this.keySerde = keySerde; - this.valueSerde = valueSerde; - } - - @Override - @SuppressWarnings("unchecked") - public void init(ProcessorContext context, StateStore root) { - inner.init(context, root); - - // construct the serde - StateSerdes serdes = new StateSerdes<>( - ProcessorStateManager.storeChangelogTopic(context.applicationId(), inner.name()), - keySerde == null ? (Serde) context.keySerde() : keySerde, - valueSerde == null ? (Serde) context.valueSerde() : valueSerde); - - // Twitter Changed - //this.changeLogger = new StoreChangeLogger<>(inner.name(), context, serdes); - this.changeLogger = new StoreChangeFlushingLogger<>(inner.name(), context, serdes); - - // if the inner store is an LRU cache, add the eviction listener to log removed record - if (inner instanceof MemoryLRUCache) { - ((MemoryLRUCache) inner).whenEldestRemoved(new MemoryNavigableLRUCache.EldestEntryRemovalListener() { - @Override - public void apply(K key, V value) { - removed(key); - } - }); - } - } - - @Override - public long approximateNumEntries() { - return inner.approximateNumEntries(); - } - - @Override - public V get(K key) { - return this.inner.get(key); - } - - @Override - public void put(K key, V value) { - this.inner.put(key, value); - - changeLogger.logChange(key, value); - } - - @Override - public V putIfAbsent(K key, V value) { - V originalValue = this.inner.putIfAbsent(key, value); - if (originalValue == null) { - changeLogger.logChange(key, value); - } - return originalValue; - } - - @Override - public void putAll(List> entries) { - this.inner.putAll(entries); - - for (KeyValue entry : entries) { - K key = entry.key; - changeLogger.logChange(key, entry.value); - } - } - - @Override - public V delete(K key) { - V value = this.inner.delete(key); - - removed(key); - - return value; - } - - /** - * Called when the underlying {@link #inner} {@link KeyValueStore} removes an entry in response to a call from this - * store. - * - * @param key the key for the entry that the inner store removed - */ - protected void removed(K key) { - changeLogger.logChange(key, null); - } - - @Override - public KeyValueIterator range(K from, K to) { - return this.inner.range(from, to); - } - - @Override - public KeyValueIterator all() { - return this.inner.all(); - } - - @Override - //Twitter added - public void flush() { - changeLogger.flush(); - super.flush(); - } -} diff --git a/kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/StoreChangeFlushingLogger.java b/kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/StoreChangeFlushingLogger.java deleted file mode 100644 index ff5153422a..0000000000 --- a/kafka-streams/kafka-streams/src/main/java/org/apache/kafka/streams/state/internals/StoreChangeFlushingLogger.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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 org.apache.kafka.streams.state.internals; - -// SUPPRESS CHECKSTYLE:OFF LineLength -// SUPPRESS CHECKSTYLE:OFF ModifierOrder -// SUPPRESS CHECKSTYLE:OFF OperatorWrap -// SUPPRESS CHECKSTYLE:OFF HiddenField -// SUPPRESS CHECKSTYLE:OFF NeedBraces -// SUPPRESS CHECKSTYLE:OFF NestedForDepth -// SUPPRESS CHECKSTYLE:OFF JavadocStyle -// SUPPRESS CHECKSTYLE:OFF NestedForDepth -// SUPPRESS CHECKSTYLE:OFF ConstantName - -import java.util.function.BiConsumer; - -import org.agrona.collections.Hashing; -import org.agrona.collections.Object2ObjectHashMap; -import org.apache.kafka.common.serialization.Serializer; -import org.apache.kafka.streams.processor.ProcessorContext; -import org.apache.kafka.streams.processor.TaskId; -import org.apache.kafka.streams.processor.internals.ProcessorStateManager; -import org.apache.kafka.streams.processor.internals.RecordCollector; -import org.apache.kafka.streams.state.StateSerdes; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Note that the use of array-typed keys is discouraged because they result in incorrect caching behavior. - * If you intend to work on byte arrays as key, for example, you may want to wrap them with the {@code Bytes} class, - * i.e. use {@code RocksDBStore} rather than {@code RocksDBStore}. - * - * @param - * @param - */ -//See FlushingStores for motivations of this class -//Note: This class is copied from Kafka Streams StoreChangeLogger with the only changes commented with "Twitter Changed" -// The modifications provide "flushing" functionality which flushes the latest records for a given key to the changelog -// after every Kafka commit (which triggers the flush method below) -class StoreChangeFlushingLogger { - - protected final StateSerdes serialization; - - private final String topic; - private final int partition; - private final ProcessorContext context; - private final RecordCollector collector; - - // Twitter Changed - private static final Logger log = LoggerFactory.getLogger(StoreChangeFlushingLogger.class); - private final TaskId taskId; - private final Serializer keySerializer; - private final Serializer valueSerializer; - private final Object2ObjectHashMap> newEntries = new Object2ObjectHashMap<>(100000, Hashing.DEFAULT_LOAD_FACTOR); - - StoreChangeFlushingLogger(String storeName, ProcessorContext context, StateSerdes serialization) { - this(storeName, context, context.taskId().partition, serialization); - } - - private StoreChangeFlushingLogger(String storeName, ProcessorContext context, int partition, StateSerdes serialization) { - this.topic = ProcessorStateManager.storeChangelogTopic(context.applicationId(), storeName); - this.context = context; - this.partition = partition; - this.serialization = serialization; - this.collector = ((RecordCollector.Supplier) context).recordCollector(); - - // Twitter Added - this.taskId = context.taskId(); - this.keySerializer = serialization.keySerializer(); - this.valueSerializer = serialization.valueSerializer(); - } - - void logChange(final K key, final V value) { - if (collector != null) { - // Twitter Added - newEntries.put(key, new ValueAndTimestamp<>(value, context.timestamp())); - } - } - - // Twitter Changed - /* - * logChange now saves new entries into a map, which collapses entries using the same key. When flush - * is called, we send the latest collapsed entries to the changelog topic. By buffering entries - * before flush is called, we avoid writing every log change to the changelog topic. - * Pros: Less messages to and from changelog. Less broker side compaction needed. Bursts of changelog messages are better batched and compressed. - * Cons: Changelog messages are written to the changelog topic in bursts. - */ - void flush() { - if (!newEntries.isEmpty()) { - newEntries.forEach(foreachConsumer); - log.info("Task " + taskId + " flushed " + newEntries.size() + " entries into " + topic + "." + partition); - newEntries.clear(); - } - } - - private final BiConsumer> foreachConsumer = new BiConsumer>() { - @Override - public final void accept(K key, ValueAndTimestamp valueAndTimestamp) { - // Sending null headers to changelog topics (KIP-244) - collector.send( - topic, - key, - valueAndTimestamp.value, - null, - partition, - valueAndTimestamp.timestamp, - keySerializer, - valueSerializer); - } - }; - - class ValueAndTimestamp { - public final V value; - public final Long timestamp; - - ValueAndTimestamp(V value, Long timestamp) { - this.value = value; - this.timestamp = timestamp; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - ValueAndTimestamp that = (ValueAndTimestamp) o; - - if (value != null ? !value.equals(that.value) : that.value != null) return false; - return timestamp != null ? timestamp.equals(that.timestamp) : that.timestamp == null; - } - - @Override - public int hashCode() { - int result = value != null ? value.hashCode() : 0; - result = 31 * result + (timestamp != null ? timestamp.hashCode() : 0); - return result; - } - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala index ba749a9730..e6f49d06db 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/KafkaStreamsTwitterServer.scala @@ -4,21 +4,17 @@ import com.twitter.app.Flag import com.twitter.conversions.DurationOps._ import com.twitter.conversions.StorageUnitOps._ import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.kafka.interceptors.{ - InstanceMetadataProducerInterceptor, - MonitoringConsumerInterceptor, - PublishTimeProducerInterceptor -} +import com.twitter.finatra.kafka.interceptors.{InstanceMetadataProducerInterceptor, MonitoringConsumerInterceptor, PublishTimeProducerInterceptor} import com.twitter.finatra.kafka.stats.KafkaFinagleMetricsReporter import com.twitter.finatra.kafkastreams.config.{FinatraRocksDBConfig, KafkaStreamsConfig} import com.twitter.finatra.kafkastreams.domain.ProcessingGuarantee -import com.twitter.finatra.kafkastreams.internal.ScalaStreamsImplicits import com.twitter.finatra.kafkastreams.internal.admin.AdminRoutes +import com.twitter.finatra.kafkastreams.internal.interceptors.KafkaStreamsMonitoringConsumerInterceptor import com.twitter.finatra.kafkastreams.internal.listeners.FinatraStateRestoreListener import com.twitter.finatra.kafkastreams.internal.serde.AvoidDefaultSerde import com.twitter.finatra.kafkastreams.internal.stats.KafkaStreamsFinagleMetricsReporter -import com.twitter.finatra.kafkastreams.utils.KafkaFlagUtils -import com.twitter.finatra.streams.interceptors.KafkaStreamsMonitoringConsumerInterceptor +import com.twitter.finatra.kafkastreams.internal.utils.KafkaFlagUtils +import com.twitter.finatra.kafkastreams.utils.ScalaStreamsImplicits import com.twitter.inject.server.TwitterServer import com.twitter.util.Duration import java.util.Properties @@ -28,13 +24,7 @@ import org.apache.kafka.clients.consumer.{ConsumerConfig, OffsetResetStrategy} import org.apache.kafka.common.metrics.Sensor.RecordingLevel import org.apache.kafka.streams.KafkaStreams.{State, StateListener} import org.apache.kafka.streams.processor.internals.DefaultKafkaClientSupplier -import org.apache.kafka.streams.{ - KafkaClientSupplier, - KafkaStreams, - StreamsBuilder, - StreamsConfig, - Topology -} +import org.apache.kafka.streams.{KafkaClientSupplier, KafkaStreams, StreamsBuilder, StreamsConfig, Topology} /** * A [[com.twitter.server.TwitterServer]] that supports configuring a KafkaStreams topology. diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/config/DefaultTopicConfig.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/DefaultTopicConfig.scala similarity index 95% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/config/DefaultTopicConfig.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/DefaultTopicConfig.scala index bb4de18d22..e7c8eaac31 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/config/DefaultTopicConfig.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/DefaultTopicConfig.scala @@ -1,7 +1,7 @@ -package com.twitter.finatra.streams.config +package com.twitter.finatra.kafkastreams.config -import com.twitter.conversions.StorageUnitOps._ import com.twitter.conversions.DurationOps._ +import com.twitter.conversions.StorageUnitOps._ import java.util import org.apache.kafka.common.config.TopicConfig.{ CLEANUP_POLICY_COMPACT, diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/FinatraTransformerFlags.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraTransformerFlags.scala similarity index 89% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/FinatraTransformerFlags.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraTransformerFlags.scala index f0dbd2fee6..2c505878bf 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/FinatraTransformerFlags.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraTransformerFlags.scala @@ -1,8 +1,8 @@ -package com.twitter.finatra.streams.flags +package com.twitter.finatra.kafkastreams.config import com.twitter.conversions.DurationOps._ import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.streams.flags.FinatraTransformerFlags._ +import com.twitter.finatra.kafkastreams.config.FinatraTransformerFlags._ object FinatraTransformerFlags { val AutoWatermarkInterval = "finatra.streams.watermarks.auto.interval" diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/RocksDbFlags.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala similarity index 95% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/RocksDbFlags.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala index 8cbe495303..d5ef013e54 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/flags/RocksDbFlags.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala @@ -1,7 +1,6 @@ -package com.twitter.finatra.streams.flags +package com.twitter.finatra.kafkastreams.config import com.twitter.conversions.StorageUnitOps._ -import com.twitter.finatra.kafkastreams.config.FinatraRocksDBConfig import com.twitter.inject.server.TwitterServer trait RocksDbFlags extends TwitterServer { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslSampling.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslSampling.scala index 5f210f2160..4bc00a8cb1 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslSampling.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslSampling.scala @@ -3,13 +3,10 @@ package com.twitter.finatra.kafkastreams.dsl import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.kafkastreams.internal.utils.sampling.{ - IndexedSampleKeySerde, - ReservoirSamplingTransformer -} -import com.twitter.finatra.streams.config.DefaultTopicConfig -import com.twitter.finatra.streams.flags.FinatraTransformerFlags -import com.twitter.finatra.streams.transformer.{FinatraTransformer, SamplingUtils} +import com.twitter.finatra.kafkastreams.config.{DefaultTopicConfig, FinatraTransformerFlags} +import com.twitter.finatra.kafkastreams.internal.utils.sampling.{IndexedSampleKeySerde, ReservoirSamplingTransformer} +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer +import com.twitter.finatra.kafkastreams.transformer.utils.SamplingUtils import com.twitter.inject.Logging import com.twitter.util.Duration import org.apache.kafka.common.serialization.Serde diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala index a415f0d2a2..b96d26d466 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala @@ -5,21 +5,15 @@ import com.twitter.conversions.storage._ import com.twitter.conversions.time._ import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.kafkastreams.internal.ScalaStreamsImplicits -import com.twitter.finatra.kafkastreams.processors.FlushingAwareServer -import com.twitter.finatra.streams.flags.FinatraTransformerFlags -import com.twitter.finatra.streams.transformer.domain._ -import com.twitter.finatra.streams.transformer.{AggregatorTransformer, FinatraTransformer} +import com.twitter.finatra.kafkastreams.config.FinatraTransformerFlags +import com.twitter.finatra.kafkastreams.flushing.FlushingAwareServer +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer +import com.twitter.finatra.kafkastreams.transformer.aggregation.{AggregatorTransformer, FixedTimeWindowedSerde, TimeWindowed, WindowedValue} +import com.twitter.finatra.kafkastreams.transformer.domain.Time +import com.twitter.finatra.kafkastreams.utils.ScalaStreamsImplicits import com.twitter.inject.Logging import com.twitter.util.Duration -import org.apache.kafka.common.config.TopicConfig.{ - CLEANUP_POLICY_COMPACT, - CLEANUP_POLICY_CONFIG, - CLEANUP_POLICY_DELETE, - DELETE_RETENTION_MS_CONFIG, - RETENTION_MS_CONFIG, - SEGMENT_BYTES_CONFIG -} +import org.apache.kafka.common.config.TopicConfig.{CLEANUP_POLICY_COMPACT, CLEANUP_POLICY_CONFIG, CLEANUP_POLICY_DELETE, DELETE_RETENTION_MS_CONFIG, RETENTION_MS_CONFIG, SEGMENT_BYTES_CONFIG} import org.apache.kafka.common.serialization.Serde import org.apache.kafka.streams.scala.kstream.{KStream => KStreamS} import org.apache.kafka.streams.state.Stores @@ -73,10 +67,10 @@ trait FinatraDslWindowedAggregations * A Window is closed after event time passes the end of a TimeWindow + allowedLateness. * * After a window is closed, if emitOnClose=true it is forwarded out of this transformer with a - * [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.WindowClosed]] + * [[WindowedValue.windowResultType]] of [[WindowClosed]] * * If a record arrives after a window is closed it is immediately forwarded out of this - * transformer with a [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.Restatement]] + * transformer with a [[WindowedValue.windowResultType]] of [[Restatement]] * * @param stateStore the name of the StateStore used to maintain the counts. * @param windowSize splits the stream of data into buckets of data of windowSize, @@ -174,10 +168,10 @@ trait FinatraDslWindowedAggregations * A Window is closed after event time passes the end of a TimeWindow + allowedLateness. * * After a window is closed, if emitOnClose=true it is forwarded out of this transformer with a - * [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.WindowClosed]] + * [[WindowedValue.windowResultType]] of [[WindowClosed]] * * If a record arrives after a window is closed it is immediately forwarded out of this - * transformer with a [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.Restatement]] + * transformer with a [[WindowedValue.windowResultType]] of [[Restatement]] * * @param stateStore the name of the StateStore used to maintain the counts. * @param windowSize splits the stream of data into buckets of data of windowSize, @@ -192,6 +186,7 @@ trait FinatraDslWindowedAggregations * Streams commit interval. Emitted entries will have a * WindowResultType set to WindowOpen. * @param windowSizeRetentionMultiplier A multiplier on top of the windowSize to ensure data is not deleted from the changelog prematurely. Allows for clock drift. Default is 2 + * * @return a stream of Keys for a particular timewindow, and the sum of the values for that key * within a particular timewindow. */ @@ -242,10 +237,10 @@ trait FinatraDslWindowedAggregations * A Window is closed after event time passes the end of a TimeWindow + allowedLateness. * * After a window is closed, if emitOnClose=true it is forwarded out of this transformer with a - * [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.WindowClosed]] + * [[WindowedValue.windowResultType]] of [[WindowClosed]] * * If a record arrives after a window is closed it is immediately forwarded out of this - * transformer with a [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.Restatement]] + * transformer with a [[WindowedValue.windowResultType]] of [[Restatement]] * * @param stateStore the name of the StateStore used to maintain the counts. * @param windowSize splits the stream of data into buckets of data of windowSize, @@ -260,6 +255,7 @@ trait FinatraDslWindowedAggregations * Streams commit interval. Emitted entries will have a * WindowResultType set to WindowOpen. * @param windowSizeRetentionMultiplier A multiplier on top of the windowSize to ensure data is not deleted from the changelog prematurely. Allows for clock drift. Default is 2 + * * @return a stream of Keys for a particular timewindow, and the sum of the values for that key * within a particular timewindow. */ diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/AsyncFlushing.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncFlushing.scala similarity index 91% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/AsyncFlushing.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncFlushing.scala index 1b5596c11f..f36646b5b7 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/AsyncFlushing.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncFlushing.scala @@ -1,8 +1,8 @@ -package com.twitter.finatra.kafkastreams.processors.internal +package com.twitter.finatra.kafkastreams.flushing import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.kafkastreams.processors.MessageTimestamp -import com.twitter.finatra.streams.transformer.internal.{OnClose, OnInit} +import com.twitter.finatra.kafkastreams.transformer.lifecycle.{OnClose, OnInit} +import com.twitter.finatra.kafkastreams.utils.MessageTimestamp import com.twitter.util.{Await, Duration, Future, Return, Throw} import java.util.concurrent.Semaphore diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/AsyncProcessor.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncProcessor.scala similarity index 81% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/AsyncProcessor.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncProcessor.scala index b70bd549ee..9a0adafeb4 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/AsyncProcessor.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncProcessor.scala @@ -1,7 +1,7 @@ -package com.twitter.finatra.kafkastreams.processors +package com.twitter.finatra.kafkastreams.flushing import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.kafkastreams.processors.internal.AsyncFlushing +import com.twitter.finatra.kafkastreams.utils.MessageTimestamp import com.twitter.util.{Duration, Future} abstract class AsyncProcessor[K, V]( @@ -9,7 +9,7 @@ abstract class AsyncProcessor[K, V]( override val maxOutstandingFuturesPerTask: Int, override val commitInterval: Duration, override val flushTimeout: Duration) - extends FlushingProcessor[K, V] + extends FlushingProcessor[K, V] with AsyncFlushing[K, V, Unit, Unit] { protected def processAsync(key: K, value: V, timestamp: MessageTimestamp): Future[Unit] diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/AsyncTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncTransformer.scala similarity index 95% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/AsyncTransformer.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncTransformer.scala index b6a5d5bb6d..eb8bc26b9b 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/AsyncTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/AsyncTransformer.scala @@ -1,17 +1,11 @@ -package com.twitter.finatra.kafkastreams.processors +package com.twitter.finatra.kafkastreams.flushing import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging -import com.twitter.finatra.kafkastreams.processors.internal.AsyncFlushing +import com.twitter.finatra.kafkastreams.utils.MessageTimestamp import com.twitter.util.{Duration, Future} import java.util.concurrent.ConcurrentHashMap -import org.apache.kafka.streams.processor.{ - Cancellable, - ProcessorContext, - PunctuationType, - Punctuator, - To -} +import org.apache.kafka.streams.processor.{Cancellable, ProcessorContext, PunctuationType, Punctuator, To} /** * The AsyncTransformer trait allows async futures to be used to emit records downstreams diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/Flushing.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/Flushing.scala similarity index 92% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/Flushing.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/Flushing.scala index a3e7492d16..e91c278216 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/internal/Flushing.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/Flushing.scala @@ -1,7 +1,7 @@ -package com.twitter.finatra.kafkastreams.processors.internal +package com.twitter.finatra.kafkastreams.flushing import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging -import com.twitter.finatra.streams.transformer.internal.{OnClose, OnFlush, OnInit} +import com.twitter.finatra.kafkastreams.transformer.lifecycle.{OnClose, OnFlush, OnInit} import com.twitter.util.Duration import org.apache.kafka.streams.StreamsConfig import org.apache.kafka.streams.processor.{Cancellable, PunctuationType, Punctuator} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingAwareServer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingAwareServer.scala similarity index 94% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingAwareServer.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingAwareServer.scala index 319ae7b871..7fba9fc1fc 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingAwareServer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingAwareServer.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.kafkastreams.processors +package com.twitter.finatra.kafkastreams.flushing import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer import com.twitter.finatra.kafkastreams.config.KafkaStreamsConfig diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingProcessor.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingProcessor.scala similarity index 69% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingProcessor.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingProcessor.scala index 61401a715e..e587dc4594 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingProcessor.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingProcessor.scala @@ -1,12 +1,11 @@ -package com.twitter.finatra.kafkastreams.processors +package com.twitter.finatra.kafkastreams.flushing import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging -import com.twitter.finatra.kafkastreams.processors.internal.Flushing -import com.twitter.finatra.streams.transformer.internal.OnInit +import com.twitter.finatra.kafkastreams.transformer.lifecycle.OnInit import org.apache.kafka.streams.processor._ trait FlushingProcessor[K, V] - extends AbstractProcessor[K, V] + extends AbstractProcessor[K, V] with OnInit with Flushing with ProcessorContextLogging { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingTransformer.scala similarity index 54% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingTransformer.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingTransformer.scala index 274244d555..b01ac3c39c 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/FlushingTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/flushing/FlushingTransformer.scala @@ -1,6 +1,5 @@ -package com.twitter.finatra.kafkastreams.processors +package com.twitter.finatra.kafkastreams.flushing -import com.twitter.finatra.kafkastreams.processors.internal.Flushing import org.apache.kafka.streams.kstream.Transformer trait FlushingTransformer[K, V, K1, V1] extends Transformer[K, V, (K1, V1)] with Flushing diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/interceptors/KafkaStreamsMonitoringConsumerInterceptor.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/interceptors/KafkaStreamsMonitoringConsumerInterceptor.scala similarity index 81% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/interceptors/KafkaStreamsMonitoringConsumerInterceptor.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/interceptors/KafkaStreamsMonitoringConsumerInterceptor.scala index 0250e8cac2..1378494e40 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/interceptors/KafkaStreamsMonitoringConsumerInterceptor.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/interceptors/KafkaStreamsMonitoringConsumerInterceptor.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.interceptors +package com.twitter.finatra.kafkastreams.internal.interceptors import com.twitter.finatra.kafka.interceptors.MonitoringConsumerInterceptor @@ -9,7 +9,7 @@ import com.twitter.finatra.kafka.interceptors.MonitoringConsumerInterceptor * Note: Since this interceptor is Kafka Streams aware, it will not calculate stats when reading changelog topics to restore * state, since this has been shown to be a hot-spot during restoration of large amounts of state. */ -class KafkaStreamsMonitoringConsumerInterceptor extends MonitoringConsumerInterceptor { +private[kafkastreams] class KafkaStreamsMonitoringConsumerInterceptor extends MonitoringConsumerInterceptor { /** * Determines if this interceptor should be enabled given the consumer client id diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/serde/AvoidDefaultSerde.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/serde/AvoidDefaultSerde.scala index dfdef92963..4ecd66efcc 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/serde/AvoidDefaultSerde.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/serde/AvoidDefaultSerde.scala @@ -3,7 +3,7 @@ package com.twitter.finatra.kafkastreams.internal.serde import java.util import org.apache.kafka.common.serialization.{Deserializer, Serde, Serializer} -class AvoidDefaultSerde extends Serde[Object] { +private[kafkastreams] class AvoidDefaultSerde extends Serde[Object] { private val exceptionErrorStr = "should be avoided as they are error prone and often result in confusing error messages. " + "Instead, explicitly specify your serdes. See https://kafka.apache.org/10/documentation/streams/developer-guide/datatypes.html#overriding-default-serdes" diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/KafkaStreamsFinagleMetricsReporter.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/KafkaStreamsFinagleMetricsReporter.scala index ca758ac3ad..eed1c2d83c 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/KafkaStreamsFinagleMetricsReporter.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/KafkaStreamsFinagleMetricsReporter.scala @@ -4,8 +4,8 @@ import com.twitter.finatra.kafka.stats.KafkaFinagleMetricsReporter import java.util import org.apache.kafka.clients.CommonClientConfigs import org.apache.kafka.common.MetricName -import org.apache.kafka.common.metrics.Sensor.RecordingLevel import org.apache.kafka.common.metrics.KafkaMetric +import org.apache.kafka.common.metrics.Sensor.RecordingLevel object KafkaStreamsFinagleMetricsReporter { @@ -108,7 +108,7 @@ object KafkaStreamsFinagleMetricsReporter { * Kafka-Streams specific MetricsReporter which adds some additional logic on top of the metrics * reporter used for Kafka consumers and producers */ -class KafkaStreamsFinagleMetricsReporter extends KafkaFinagleMetricsReporter { +private[kafkastreams] class KafkaStreamsFinagleMetricsReporter extends KafkaFinagleMetricsReporter { private var includeProcessorNodeId = false private var includeGlobalTableMetrics = false diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/RocksDBStatsCallback.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/RocksDBStatsCallback.scala index 311df2e538..02600a7f31 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/RocksDBStatsCallback.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/stats/RocksDBStatsCallback.scala @@ -18,7 +18,7 @@ import scala.collection.mutable.{Map => MutableMap} * https://github.com/facebook/rocksdb/wiki/Statistics * https://github.com/facebook/rocksdb/blob/master/include/rocksdb/statistics.h */ -class RocksDBStatsCallback(statsReceiver: StatsReceiver) +private[kafkastreams] class RocksDBStatsCallback(statsReceiver: StatsReceiver) extends StatisticsCollectorCallback with Logging { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/KafkaFlagUtils.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/KafkaFlagUtils.scala index f018923881..1140c1debd 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/KafkaFlagUtils.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/KafkaFlagUtils.scala @@ -1,9 +1,9 @@ -package com.twitter.finatra.kafkastreams.utils +package com.twitter.finatra.kafkastreams.internal.utils import com.twitter.app.{App, Flag, Flaggable} import org.apache.kafka.streams.StreamsConfig -trait KafkaFlagUtils extends App { +private[kafkastreams] trait KafkaFlagUtils extends App { def requiredKafkaFlag[T: Flaggable: Manifest](key: String, helpPrefix: String = ""): Flag[T] = { flag[T](name = "kafka." + key, help = helpPrefix + kafkaDocumentation(key)) diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala index d39a81ad96..82f2c336ac 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ProcessorContextLogging.scala @@ -5,7 +5,8 @@ import org.apache.kafka.streams.processor.ProcessorContext import org.joda.time.DateTime import org.joda.time.format.ISODateTimeFormat -trait ProcessorContextLogging { +//TODO: Change viability to [kafkastreams] after deleting deprecated dependent code +private[finatra] trait ProcessorContextLogging { private val _logger = Logger(getClass) diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ReflectionUtils.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ReflectionUtils.scala index 8b8d04dcd9..19b19b4079 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ReflectionUtils.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/ReflectionUtils.scala @@ -2,7 +2,7 @@ package com.twitter.finatra.kafkastreams.internal.utils import java.lang.reflect.{Field, Modifier} -object ReflectionUtils { +private[kafkastreams] object ReflectionUtils { def getField(clazz: Class[_], fieldName: String): Field = { val field = clazz.getDeclaredField(fieldName) diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/TopologyReflectionUtils.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/TopologyReflectionUtils.scala index f73337c204..5aef2ea947 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/TopologyReflectionUtils.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/TopologyReflectionUtils.scala @@ -3,7 +3,7 @@ package com.twitter.finatra.kafkastreams.internal.utils import org.apache.kafka.streams.Topology import org.apache.kafka.streams.processor.internals.InternalTopologyBuilder -object TopologyReflectionUtils { +private[kafkastreams] object TopologyReflectionUtils { private val internalTopologyBuilderField = ReflectionUtils.getFinalField(classOf[Topology], "internalTopologyBuilder") diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/IndexedSampleKey.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/IndexedSampleKey.scala similarity index 91% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/IndexedSampleKey.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/IndexedSampleKey.scala index 71749c9346..17d57aebd0 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/IndexedSampleKey.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/IndexedSampleKey.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.internal.utils.sampling object IndexedSampleKey { def rangeStart[SampleKey](sampleKey: SampleKey): IndexedSampleKey[SampleKey] = { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/IndexedSampleKeySerde.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/IndexedSampleKeySerde.scala index 7d3e16bd72..ed6cd8a37f 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/IndexedSampleKeySerde.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/IndexedSampleKeySerde.scala @@ -2,11 +2,10 @@ package com.twitter.finatra.kafkastreams.internal.utils.sampling import com.google.common.primitives.Ints import com.twitter.finatra.kafka.serde.AbstractSerde -import com.twitter.finatra.streams.transformer.domain.IndexedSampleKey import java.nio.ByteBuffer import org.apache.kafka.common.serialization.Serde -object IndexedSampleKeySerde { +private[kafkastreams] object IndexedSampleKeySerde { /** * Indexed sample key adds one Integer to the bytes @@ -14,7 +13,7 @@ object IndexedSampleKeySerde { val IndexSize: Int = Ints.BYTES } -class IndexedSampleKeySerde[SampleKey](sampleKeySerde: Serde[SampleKey]) +private[kafkastreams] class IndexedSampleKeySerde[SampleKey](sampleKeySerde: Serde[SampleKey]) extends AbstractSerde[IndexedSampleKey[SampleKey]] { private val sampleKeySerializer = sampleKeySerde.serializer() diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala index d8d3d8ee44..4b193a99af 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/utils/sampling/ReservoirSamplingTransformer.scala @@ -1,13 +1,9 @@ package com.twitter.finatra.kafkastreams.internal.utils.sampling import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.streams.transformer.domain.{ - Expire, - IndexedSampleKey, - Time, - TimerMetadata -} -import com.twitter.finatra.streams.transformer.{FinatraTransformer, PersistentTimers} +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer +import com.twitter.finatra.kafkastreams.transformer.domain.{Expire, Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.stores.PersistentTimers import com.twitter.util.Duration import org.apache.kafka.streams.processor.PunctuationType import scala.reflect.ClassTag diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraCompositeWindowStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraCompositeWindowStore.scala similarity index 90% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraCompositeWindowStore.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraCompositeWindowStore.scala index 6401371863..68d2ab104d 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraCompositeWindowStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraCompositeWindowStore.scala @@ -1,15 +1,13 @@ -package com.twitter.finatra.streams.query +package com.twitter.finatra.kafkastreams.query import com.twitter.conversions.DurationOps._ -import com.twitter.finatra.streams.converters.time._ +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer.{DateTimeMillis, WindowStartTime} +import com.twitter.finatra.kafkastreams.transformer.aggregation.TimeWindowed +import com.twitter.finatra.kafkastreams.transformer.domain.{CompositeKey, Time} +import com.twitter.finatra.kafkastreams.transformer.stores.internal.FinatraStoresGlobalManager +import com.twitter.finatra.kafkastreams.utils.time._ import com.twitter.finatra.streams.queryable.thrift.domain.ServiceShardId -import com.twitter.finatra.streams.queryable.thrift.partitioning.{ - KafkaPartitioner, - StaticServiceShardPartitioner -} -import com.twitter.finatra.streams.stores.internal.FinatraStoresGlobalManager -import com.twitter.finatra.streams.transformer.FinatraTransformer.{DateTimeMillis, WindowStartTime} -import com.twitter.finatra.streams.transformer.domain.{CompositeKey, Time, TimeWindowed} +import com.twitter.finatra.streams.queryable.thrift.partitioning.{KafkaPartitioner, StaticServiceShardPartitioner} import com.twitter.inject.Logging import com.twitter.util.Duration import org.apache.kafka.common.serialization.{Serde, Serializer} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraKeyValueStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraKeyValueStore.scala similarity index 92% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraKeyValueStore.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraKeyValueStore.scala index 86ae4b92b3..b42b9961e6 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraKeyValueStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraKeyValueStore.scala @@ -1,12 +1,9 @@ -package com.twitter.finatra.streams.query +package com.twitter.finatra.kafkastreams.query +import com.twitter.finatra.kafkastreams.transformer.stores.FinatraKeyValueStore +import com.twitter.finatra.kafkastreams.transformer.stores.internal.FinatraStoresGlobalManager import com.twitter.finatra.streams.queryable.thrift.domain.ServiceShardId -import com.twitter.finatra.streams.queryable.thrift.partitioning.{ - KafkaPartitioner, - StaticServiceShardPartitioner -} -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.stores.internal.FinatraStoresGlobalManager +import com.twitter.finatra.streams.queryable.thrift.partitioning.{KafkaPartitioner, StaticServiceShardPartitioner} import com.twitter.inject.Logging import java.util.NoSuchElementException import org.apache.kafka.common.serialization.Serde diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraWindowStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraWindowStore.scala similarity index 83% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraWindowStore.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraWindowStore.scala index 92f3b26614..acdd9e18b6 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/query/QueryableFinatraWindowStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/query/QueryableFinatraWindowStore.scala @@ -1,14 +1,12 @@ -package com.twitter.finatra.streams.query +package com.twitter.finatra.kafkastreams.query +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer.{DateTimeMillis, WindowStartTime} +import com.twitter.finatra.kafkastreams.transformer.aggregation.TimeWindowed +import com.twitter.finatra.kafkastreams.transformer.domain.Time +import com.twitter.finatra.kafkastreams.transformer.stores.FinatraKeyValueStore +import com.twitter.finatra.kafkastreams.transformer.stores.internal.FinatraStoresGlobalManager import com.twitter.finatra.streams.queryable.thrift.domain.ServiceShardId -import com.twitter.finatra.streams.queryable.thrift.partitioning.{ - KafkaPartitioner, - StaticServiceShardPartitioner -} -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.stores.internal.FinatraStoresGlobalManager -import com.twitter.finatra.streams.transformer.FinatraTransformer.{DateTimeMillis, WindowStartTime} -import com.twitter.finatra.streams.transformer.domain.{Time, TimeWindowed} +import com.twitter.finatra.streams.queryable.thrift.partitioning.{KafkaPartitioner, StaticServiceShardPartitioner} import com.twitter.inject.Logging import com.twitter.util.Duration import org.apache.kafka.common.serialization.{Serde, Serializer} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/FinatraTransformer.scala similarity index 88% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformer.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/FinatraTransformer.scala index 770aaa5b5a..e77160d749 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/FinatraTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/FinatraTransformer.scala @@ -1,19 +1,17 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer import com.google.common.annotations.Beta import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.kafka.utils.ConfigUtils +import com.twitter.finatra.kafkastreams.config.{DefaultTopicConfig, FinatraTransformerFlags} import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging -import com.twitter.finatra.streams.config.DefaultTopicConfig -import com.twitter.finatra.streams.flags.FinatraTransformerFlags -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.stores.internal.{FinatraKeyValueStoreImpl, FinatraStoresGlobalManager} -import com.twitter.finatra.streams.transformer.FinatraTransformer.TimerTime -import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} -import com.twitter.finatra.streams.transformer.internal.domain.{Timer, TimerSerde} -import com.twitter.finatra.streams.transformer.internal.{OnClose, OnFlush, OnInit} -import com.twitter.finatra.streams.transformer.watermarks.internal.WatermarkManager -import com.twitter.finatra.streams.transformer.watermarks.{DefaultWatermarkAssignor, WatermarkAssignor} +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer.TimerTime +import com.twitter.finatra.kafkastreams.transformer.domain.Time +import com.twitter.finatra.kafkastreams.transformer.lifecycle.{OnClose, OnFlush, OnInit, OnWatermark} +import com.twitter.finatra.kafkastreams.transformer.stores.FinatraKeyValueStore +import com.twitter.finatra.kafkastreams.transformer.stores.internal.{FinatraKeyValueStoreImpl, FinatraStoresGlobalManager, Timer} +import com.twitter.finatra.kafkastreams.transformer.watermarks.{DefaultWatermarkAssignor, Watermark, WatermarkAssignor, WatermarkManager} +import com.twitter.finatra.streams.transformer.internal.domain.TimerSerde import com.twitter.util.Duration import org.apache.kafka.common.serialization.{Serde, Serdes} import org.apache.kafka.streams.kstream.Transformer @@ -68,7 +66,7 @@ abstract class FinatraTransformer[InputKey, InputValue, OutputKey, OutputValue]( with OnFlush with ProcessorContextLogging { - protected[streams] val finatraKeyValueStoresMap: mutable.Map[String, FinatraKeyValueStore[_, _]] = + protected[kafkastreams] val finatraKeyValueStoresMap: mutable.Map[String, FinatraKeyValueStore[_, _]] = scala.collection.mutable.Map[String, FinatraKeyValueStore[_, _]]() /* Private Mutable */ diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/AggregatorTransformer.scala similarity index 90% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/AggregatorTransformer.scala index e5de507c79..97a12f1406 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/AggregatorTransformer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/AggregatorTransformer.scala @@ -1,10 +1,11 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.aggregation import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.streams.stores.CachingFinatraKeyValueStore -import com.twitter.finatra.streams.transformer.FinatraTransformer.WindowStartTime -import com.twitter.finatra.streams.transformer.domain._ +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer.WindowStartTime +import com.twitter.finatra.kafkastreams.transformer.domain._ +import com.twitter.finatra.kafkastreams.transformer.stores.{CachingFinatraKeyValueStore, CachingKeyValueStores, PersistentTimers} import com.twitter.util.Duration import it.unimi.dsi.fastutil.longs.LongOpenHashSet import org.apache.kafka.streams.processor.PunctuationType @@ -19,10 +20,10 @@ import org.apache.kafka.streams.state.KeyValueIterator * A Window is closed after event time passes the end of a TimeWindow + allowedLateness. * * After a window is closed, if emitOnClose=true it is forwarded out of this transformer with a - * [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.WindowClosed]] + * [[WindowedValue.windowResultType]] of [[WindowClosed]] * * If a record arrives after a window is closed it is immediately forwarded out of this - * transformer with a [[WindowedValue.resultState]] of [[com.twitter.finatra.streams.transformer.domain.Restatement]] + * transformer with a [[WindowedValue.windowResultType]] of [[Restatement]] * * @param statsReceiver The StatsReceiver for collecting stats * @param stateStoreName the name of the StateStore used to maintain the counts. @@ -39,6 +40,7 @@ import org.apache.kafka.streams.state.KeyValueIterator * @param emitUpdatedEntriesOnCommit Emit messages for each updated entry in the window on the Kafka * Streams commit interval. Emitted entries will have a * WindowResultType set to WindowOpen. + * * @return a stream of Keys for a particular timewindow, and the aggregations of the values for that * key within a particular timewindow. */ @@ -124,14 +126,14 @@ class AggregatorTransformer[K, V, Aggregate]( val existing = stateStore.get(timeWindowedKey) forward( key = timeWindowedKey, - value = WindowedValue(resultState = WindowOpen, value = existing), + value = WindowedValue(windowResultType = WindowOpen, value = existing), timestamp = forwardTime) } } private def restatement(time: Time, key: K, value: V, windowedKey: TimeWindowed[K]): Unit = { val windowedValue = - WindowedValue(resultState = Restatement, value = aggregator((key, value), initializer())) + WindowedValue(windowResultType = Restatement, value = aggregator((key, value), initializer())) forward(key = windowedKey, value = windowedValue, timestamp = forwardTime) @@ -168,7 +170,7 @@ class AggregatorTransformer[K, V, Aggregate]( assert(entry.key.start.millis == windowStartTime) forward( key = entry.key, - value = WindowedValue(resultState = WindowClosed, value = entry.value), + value = WindowedValue(windowResultType = WindowClosed, value = entry.value), timestamp = forwardTime) } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/FixedTimeWindowedSerde.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/FixedTimeWindowedSerde.scala similarity index 94% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/FixedTimeWindowedSerde.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/FixedTimeWindowedSerde.scala index 64d3e3e60f..6ab890dd66 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/FixedTimeWindowedSerde.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/FixedTimeWindowedSerde.scala @@ -1,6 +1,7 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.aggregation import com.twitter.finatra.kafka.serde.AbstractSerde +import com.twitter.finatra.kafkastreams.transformer.domain.Time import com.twitter.util.Duration import java.nio.ByteBuffer import org.apache.kafka.common.serialization.Serde diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimeWindowed.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/TimeWindowed.scala similarity index 92% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimeWindowed.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/TimeWindowed.scala index a5cc7a3256..17f2ef53c5 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimeWindowed.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/TimeWindowed.scala @@ -1,5 +1,7 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.aggregation +import com.twitter.finatra.kafkastreams.transformer.domain.Time +import com.twitter.finatra.kafkastreams.transformer.watermarks.Watermark import com.twitter.util.Duration import org.joda.time.{DateTime, DateTimeConstants} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowValueResult.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowValueResult.scala similarity index 93% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowValueResult.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowValueResult.scala index 8b99291499..f2a68294d2 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowValueResult.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowValueResult.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.aggregation object WindowResultType { def apply(value: Byte): WindowResultType = { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowedValue.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowedValue.scala new file mode 100644 index 0000000000..70fe2ea788 --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowedValue.scala @@ -0,0 +1,3 @@ +package com.twitter.finatra.kafkastreams.transformer.aggregation + +case class WindowedValue[V](windowResultType: WindowResultType, value: V) diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowedValueSerde.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowedValueSerde.scala similarity index 87% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowedValueSerde.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowedValueSerde.scala index 5ea0aeacea..9a1cc7ae28 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowedValueSerde.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/aggregation/WindowedValueSerde.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.aggregation import com.twitter.finatra.kafka.serde.AbstractSerde import java.nio.ByteBuffer @@ -27,7 +27,7 @@ class WindowedValueSerde[V](inner: Serde[V]) extends AbstractSerde[WindowedValue System.arraycopy(bytes, 1, valueBytes, 0, valueBytes.length) val value = innerDeserializer.deserialize(topic, valueBytes) - WindowedValue(resultState = resultState, value = value) + WindowedValue(windowResultType = resultState, value = value) } override def serialize(windowedValue: WindowedValue[V]): Array[Byte] = { @@ -35,7 +35,7 @@ class WindowedValueSerde[V](inner: Serde[V]) extends AbstractSerde[WindowedValue val resultTypeAndValueBytes = new Array[Byte](1 + valueBytes.size) val bb = ByteBuffer.wrap(resultTypeAndValueBytes) - bb.put(windowedValue.resultState.value) + bb.put(windowedValue.windowResultType.value) bb.put(valueBytes) resultTypeAndValueBytes } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/CompositeKey.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/CompositeKey.scala similarity index 52% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/CompositeKey.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/CompositeKey.scala index e948f0ed74..b022e97d9c 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/CompositeKey.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/CompositeKey.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.domain trait CompositeKey[P, S] { def primary: P diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Time.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/Time.scala similarity index 90% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Time.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/Time.scala index e0bc9031c5..f22ab679af 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Time.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/Time.scala @@ -1,8 +1,9 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.domain +import com.twitter.finatra.kafkastreams.transformer.aggregation.TimeWindowed +import com.twitter.finatra.kafkastreams.utils.time._ import com.twitter.util.Duration import org.joda.time.{DateTime, DateTimeConstants} -import com.twitter.finatra.streams.converters.time._ object Time { /** diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimerMetadata.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/TimerMetadata.scala similarity index 89% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimerMetadata.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/TimerMetadata.scala index 901cdb9cb9..c1db7ae9c1 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/TimerMetadata.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/domain/TimerMetadata.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.domain object TimerMetadata { def apply(value: Byte): TimerMetadata = { @@ -13,7 +13,7 @@ object TimerMetadata { /** * Metadata used to convey the purpose of a - * [[com.twitter.finatra.streams.transformer.internal.domain.Timer]]. + * [[Timer]]. * * [[TimerMetadata]] represents the following Timer actions: [[EmitEarly]], [[Close]], [[Expire]] */ diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/ProcessorContextUtils.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/internal/ProcessorContextUtils.scala similarity index 93% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/ProcessorContextUtils.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/internal/ProcessorContextUtils.scala index d13c2fe90a..6332678fe2 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/ProcessorContextUtils.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/internal/ProcessorContextUtils.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer.internal +package com.twitter.finatra.kafkastreams.transformer.internal import com.twitter.finatra.kafkastreams.internal.utils.ReflectionUtils import java.lang.reflect.Field diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnClose.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnClose.scala new file mode 100644 index 0000000000..640a8c5c79 --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnClose.scala @@ -0,0 +1,5 @@ +package com.twitter.finatra.kafkastreams.transformer.lifecycle + +trait OnClose { + protected def onClose(): Unit = {} +} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnFlush.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnFlush.scala similarity index 75% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnFlush.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnFlush.scala index 95904b90ae..dee9a53ea4 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnFlush.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnFlush.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer.internal +package com.twitter.finatra.kafkastreams.transformer.lifecycle trait OnFlush { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnInit.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnInit.scala new file mode 100644 index 0000000000..f639256443 --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnInit.scala @@ -0,0 +1,5 @@ +package com.twitter.finatra.kafkastreams.transformer.lifecycle + +trait OnInit { + protected def onInit(): Unit = {} +} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnWatermark.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnWatermark.scala new file mode 100644 index 0000000000..b2800d03a0 --- /dev/null +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/lifecycle/OnWatermark.scala @@ -0,0 +1,7 @@ +package com.twitter.finatra.kafkastreams.transformer.lifecycle + +import com.twitter.finatra.kafkastreams.transformer.watermarks.Watermark + +trait OnWatermark { + def onWatermark(watermark: Watermark): Unit +} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/CachingFinatraKeyValueStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/CachingFinatraKeyValueStore.scala similarity index 88% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/CachingFinatraKeyValueStore.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/CachingFinatraKeyValueStore.scala index eafd7cba16..8e2543ec8c 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/CachingFinatraKeyValueStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/CachingFinatraKeyValueStore.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.stores +package com.twitter.finatra.kafkastreams.transformer.stores /** * A FinatraKeyValueStore with a callback that fires when an entry is flushed into the underlying store diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CachingKeyValueStores.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/CachingKeyValueStores.scala similarity index 76% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CachingKeyValueStores.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/CachingKeyValueStores.scala index 51ddddb53f..c2f61adfb1 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/CachingKeyValueStores.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/CachingKeyValueStores.scala @@ -1,13 +1,8 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.stores import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.kafkastreams.processors.FlushingTransformer -import com.twitter.finatra.streams.stores.internal.{ - CachingFinatraKeyValueStoreImpl, - FinatraKeyValueStoreImpl, - FinatraStoresGlobalManager -} -import com.twitter.finatra.streams.stores.{CachingFinatraKeyValueStore, FinatraKeyValueStore} +import com.twitter.finatra.kafkastreams.flushing.FlushingTransformer +import com.twitter.finatra.kafkastreams.transformer.stores.internal.{CachingFinatraKeyValueStoreImpl, FinatraKeyValueStoreImpl, FinatraStoresGlobalManager} import scala.collection.mutable import scala.reflect.ClassTag diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/FinatraKeyValueStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/FinatraKeyValueStore.scala similarity index 96% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/FinatraKeyValueStore.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/FinatraKeyValueStore.scala index aef69df439..8c001efc67 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/FinatraKeyValueStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/FinatraKeyValueStore.scala @@ -1,4 +1,5 @@ -package com.twitter.finatra.streams.stores +package com.twitter.finatra.kafkastreams.transformer.stores + import org.apache.kafka.streams.errors.InvalidStateStoreException import org.apache.kafka.streams.processor.TaskId import org.apache.kafka.streams.state.{KeyValueIterator, KeyValueStore} @@ -71,7 +72,10 @@ trait FinatraKeyValueStore[K, V] extends KeyValueStore[K, V] { * Note 2: If this RocksDB instance is configured in "prefix seek mode", than fromBytes will be used as a "prefix" and the iteration will end when the prefix is no longer part of the next element. * Enabling "prefix seek mode" can be done by calling options.useFixedLengthPrefixExtractor. When enabled, prefix scans can take advantage of a prefix based bloom filter for better seek performance * See: https://github.com/facebook/rocksdb/wiki/Prefix-Seek-API-Changes + * + * @throws InvalidStateStoreException if the store is not initialized */ + @throws[InvalidStateStoreException] def range(fromBytes: Array[Byte]): KeyValueIterator[K, V] /** @@ -87,6 +91,7 @@ trait FinatraKeyValueStore[K, V] extends KeyValueStore[K, V] { * @throws NullPointerException If null is used for from or to. * @throws InvalidStateStoreException if the store is not initialized */ + @throws[InvalidStateStoreException] def range(fromBytesInclusive: Array[Byte], toBytesExclusive: Array[Byte]): KeyValueIterator[K, V] /** diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimerStore.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimerStore.scala similarity index 89% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimerStore.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimerStore.scala index 8d9144be9b..77fb36e801 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimerStore.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimerStore.scala @@ -1,11 +1,13 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.stores import com.google.common.annotations.Beta -import com.twitter.finatra.streams.converters.time._ -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.transformer.FinatraTransformer.TimerTime -import com.twitter.finatra.streams.transformer.domain.{Time, TimerMetadata, Watermark} -import com.twitter.finatra.streams.transformer.internal.domain.{Timer, TimerSerde} +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer.TimerTime +import com.twitter.finatra.kafkastreams.transformer.domain.{Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.lifecycle.OnWatermark +import com.twitter.finatra.kafkastreams.transformer.stores.internal.Timer +import com.twitter.finatra.kafkastreams.transformer.watermarks.Watermark +import com.twitter.finatra.kafkastreams.utils.time._ +import com.twitter.finatra.streams.transformer.internal.domain.TimerSerde import com.twitter.inject.Logging import org.apache.kafka.streams.state.KeyValueIterator diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimers.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimers.scala similarity index 84% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimers.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimers.scala index e54623b96f..d0712c3f86 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PersistentTimers.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimers.scala @@ -1,10 +1,10 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.stores import com.google.common.annotations.Beta -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.transformer.domain.{Time, TimerMetadata, Watermark} -import com.twitter.finatra.streams.transformer.internal.OnInit -import com.twitter.finatra.streams.transformer.internal.domain.Timer +import com.twitter.finatra.kafkastreams.transformer.domain.{Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.lifecycle.{OnInit, OnWatermark} +import com.twitter.finatra.kafkastreams.transformer.stores.internal.Timer +import com.twitter.finatra.kafkastreams.transformer.watermarks.Watermark import java.util import org.apache.kafka.streams.processor.PunctuationType import scala.reflect.ClassTag diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/CachingFinatraKeyValueStoreImpl.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/CachingFinatraKeyValueStoreImpl.scala similarity index 97% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/CachingFinatraKeyValueStoreImpl.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/CachingFinatraKeyValueStoreImpl.scala index 222bdda5e6..a019ef0e9a 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/CachingFinatraKeyValueStoreImpl.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/CachingFinatraKeyValueStoreImpl.scala @@ -1,7 +1,7 @@ -package com.twitter.finatra.streams.stores.internal +package com.twitter.finatra.kafkastreams.transformer.stores.internal import com.twitter.finagle.stats.{Gauge, StatsReceiver} -import com.twitter.finatra.streams.stores.{CachingFinatraKeyValueStore, FinatraKeyValueStore} +import com.twitter.finatra.kafkastreams.transformer.stores.{CachingFinatraKeyValueStore, FinatraKeyValueStore} import com.twitter.inject.Logging import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap import java.util diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraKeyValueStoreImpl.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraKeyValueStoreImpl.scala similarity index 95% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraKeyValueStoreImpl.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraKeyValueStoreImpl.scala index 54886fe27a..2e95e3d758 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraKeyValueStoreImpl.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraKeyValueStoreImpl.scala @@ -1,10 +1,11 @@ -package com.twitter.finatra.streams.stores.internal +package com.twitter.finatra.kafkastreams.transformer.stores.internal import com.twitter.finagle.stats.{Gauge, Stat, StatsReceiver} import com.twitter.finatra.kafkastreams.internal.utils.ReflectionUtils -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.stores.internal.FinatraKeyValueStoreImpl._ -import com.twitter.finatra.streams.transformer.IteratorImplicits +import FinatraKeyValueStoreImpl._ +import com.twitter.finatra.kafkastreams.transformer.stores.FinatraKeyValueStore +import com.twitter.finatra.kafkastreams.transformer.utils.IteratorImplicits +import com.twitter.finatra.kafkastreams.utils.RocksKeyValueIterator import com.twitter.inject.Logging import java.util import java.util.Comparator @@ -13,7 +14,7 @@ import org.apache.kafka.common.serialization.{Deserializer, Serializer} import org.apache.kafka.common.utils.Bytes import org.apache.kafka.streams.KeyValue import org.apache.kafka.streams.processor.{ProcessorContext, StateStore, TaskId} -import org.apache.kafka.streams.state.internals.{MeteredKeyValueBytesStore, RocksDBStore, RocksKeyValueIterator} +import org.apache.kafka.streams.state.internals.{MeteredKeyValueBytesStore, RocksDBStore} import org.apache.kafka.streams.state.{KeyValueIterator, KeyValueStore, StateSerdes} import org.rocksdb.{RocksDB, WriteOptions} import scala.reflect.ClassTag @@ -229,7 +230,7 @@ case class FinatraKeyValueStoreImpl[K: ClassTag, V]( override def hasNext: Boolean = { super.hasNext && - comparator.compare(iterator.key(), toBytesExclusive) < 0 // < 0 since to is exclusive + comparator.compare(iterator.key(), toBytesExclusive) < 0 // < 0 since to is exclusive } } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraStoresGlobalManager.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraStoresGlobalManager.scala similarity index 86% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraStoresGlobalManager.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraStoresGlobalManager.scala index 68bff217ad..43ca5776f3 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/stores/internal/FinatraStoresGlobalManager.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraStoresGlobalManager.scala @@ -1,9 +1,10 @@ -package com.twitter.finatra.streams.stores.internal +package com.twitter.finatra.kafkastreams.transformer.stores.internal -import scala.collection.JavaConverters._ import com.google.common.collect.{ArrayListMultimap, Multimaps} -import com.twitter.finatra.streams.stores.FinatraKeyValueStore -import com.twitter.finatra.streams.transformer.domain.{CompositeKey, TimeWindowed} +import com.twitter.finatra.kafkastreams.transformer.aggregation.TimeWindowed +import com.twitter.finatra.kafkastreams.transformer.domain.CompositeKey +import com.twitter.finatra.kafkastreams.transformer.stores.FinatraKeyValueStore +import scala.collection.JavaConverters._ import scala.reflect.ClassTag /** diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/Timer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/Timer.scala similarity index 53% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/Timer.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/Timer.scala index f3f857c56a..e296f8a289 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/Timer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/Timer.scala @@ -1,7 +1,7 @@ -package com.twitter.finatra.streams.transformer.internal.domain +package com.twitter.finatra.kafkastreams.transformer.stores.internal -import com.twitter.finatra.streams.converters.time._ -import com.twitter.finatra.streams.transformer.domain.{Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.domain.{Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.utils.time._ /** * @param time Time to fire the timer diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/TimerSerde.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/TimerSerde.scala similarity index 90% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/TimerSerde.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/TimerSerde.scala index 4a9328dc56..78104ef5b1 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/domain/TimerSerde.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/TimerSerde.scala @@ -2,7 +2,8 @@ package com.twitter.finatra.streams.transformer.internal.domain import com.google.common.primitives.Longs import com.twitter.finatra.kafka.serde.AbstractSerde -import com.twitter.finatra.streams.transformer.domain.{Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.domain.{Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.stores.internal.Timer import java.nio.ByteBuffer import org.apache.kafka.common.serialization.Serde diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/IteratorImplicits.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/IteratorImplicits.scala similarity index 98% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/IteratorImplicits.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/IteratorImplicits.scala index 91022fbadf..4fe4703f54 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/IteratorImplicits.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/IteratorImplicits.scala @@ -1,8 +1,9 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.utils import org.agrona.collections.{Hashing, Object2ObjectHashMap} import org.apache.kafka.streams.state.KeyValueIterator import scala.collection.JavaConverters._ + trait IteratorImplicits { implicit class RichIterator[T](iterator: Iterator[T]) { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/MultiSpanIterator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/MultiSpanIterator.scala similarity index 96% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/MultiSpanIterator.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/MultiSpanIterator.scala index c0f15271df..936bbe78de 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/MultiSpanIterator.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/MultiSpanIterator.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.utils /** * This Iterator will take an Iterator and split it into subiterators, where each subiterator diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SamplingUtils.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/SamplingUtils.scala similarity index 84% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SamplingUtils.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/SamplingUtils.scala index cf68035731..d0eda67774 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/SamplingUtils.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/utils/SamplingUtils.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.utils object SamplingUtils { def getNumCountsStoreName(sampleName: String): String = { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/DefaultWatermarkAssignor.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/DefaultWatermarkAssignor.scala similarity index 80% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/DefaultWatermarkAssignor.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/DefaultWatermarkAssignor.scala index 665c05e529..cb5d6dbf01 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/DefaultWatermarkAssignor.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/DefaultWatermarkAssignor.scala @@ -1,6 +1,6 @@ -package com.twitter.finatra.streams.transformer.watermarks +package com.twitter.finatra.kafkastreams.transformer.watermarks -import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} +import com.twitter.finatra.kafkastreams.transformer.domain.Time import com.twitter.inject.Logging class DefaultWatermarkAssignor[K, V] extends WatermarkAssignor[K, V] with Logging { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PeriodicWatermarkManager.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/PeriodicWatermarkManager.scala similarity index 86% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PeriodicWatermarkManager.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/PeriodicWatermarkManager.scala index 6b3f9a131d..318eda6e66 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/PeriodicWatermarkManager.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/PeriodicWatermarkManager.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer.watermarks trait PeriodicWatermarkManager[K, V] { diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Watermark.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/Watermark.scala similarity index 64% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Watermark.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/Watermark.scala index 2f8993a19f..b1772cfd59 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/Watermark.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/Watermark.scala @@ -1,6 +1,6 @@ -package com.twitter.finatra.streams.transformer.domain +package com.twitter.finatra.kafkastreams.transformer.watermarks -import com.twitter.finatra.streams.converters.time._ +import com.twitter.finatra.kafkastreams.utils.time._ object Watermark { val unknown: Watermark = Watermark(0L) diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/WatermarkAssignor.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/WatermarkAssignor.scala similarity index 51% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/WatermarkAssignor.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/WatermarkAssignor.scala index a70ebc68c7..9753a9ea8c 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/WatermarkAssignor.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/WatermarkAssignor.scala @@ -1,6 +1,6 @@ -package com.twitter.finatra.streams.transformer.watermarks +package com.twitter.finatra.kafkastreams.transformer.watermarks -import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} +import com.twitter.finatra.kafkastreams.transformer.domain.Time trait WatermarkAssignor[K, V] { def onMessage(topic: String, timestamp: Time, key: K, value: V): Unit diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/internal/WatermarkManager.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/WatermarkManager.scala similarity index 86% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/internal/WatermarkManager.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/WatermarkManager.scala index ab71f4e4e4..7b144cc32c 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/watermarks/internal/WatermarkManager.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/transformer/watermarks/WatermarkManager.scala @@ -1,8 +1,7 @@ -package com.twitter.finatra.streams.transformer.watermarks.internal +package com.twitter.finatra.kafkastreams.transformer.watermarks -import com.twitter.finatra.streams.transformer.OnWatermark -import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} -import com.twitter.finatra.streams.transformer.watermarks.WatermarkAssignor +import com.twitter.finatra.kafkastreams.transformer.domain.Time +import com.twitter.finatra.kafkastreams.transformer.lifecycle.OnWatermark import com.twitter.inject.Logging import org.apache.kafka.streams.processor.TaskId @@ -57,7 +56,7 @@ class WatermarkManager[K, V]( } } - protected[streams] def setLastEmittedWatermark(newWatermark: Watermark): Unit = { + protected[kafkastreams] def setLastEmittedWatermark(newWatermark: Watermark): Unit = { lastEmittedWatermark = newWatermark } } diff --git a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/RocksKeyValueIterator.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/RocksKeyValueIterator.scala similarity index 96% rename from kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/RocksKeyValueIterator.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/RocksKeyValueIterator.scala index 64b8efda3a..5a8c913326 100644 --- a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/RocksKeyValueIterator.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/RocksKeyValueIterator.scala @@ -1,4 +1,4 @@ -package org.apache.kafka.streams.state.internals +package com.twitter.finatra.kafkastreams.utils import java.util.NoSuchElementException import org.apache.kafka.common.serialization.Deserializer diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/ScalaStreamsImplicits.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/ScalaStreamsImplicits.scala similarity index 98% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/ScalaStreamsImplicits.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/ScalaStreamsImplicits.scala index f0d53568dc..573d5de604 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/internal/ScalaStreamsImplicits.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/ScalaStreamsImplicits.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.kafkastreams.internal +package com.twitter.finatra.kafkastreams.utils import org.apache.kafka.streams.kstream.{Transformer, TransformerSupplier, KStream => KStreamJ} import org.apache.kafka.streams.processor.ProcessorContext diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/StatelessKafkaStreamsTwitterServer.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/StatelessKafkaStreamsTwitterServer.scala similarity index 89% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/StatelessKafkaStreamsTwitterServer.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/StatelessKafkaStreamsTwitterServer.scala index 8f59d6ecf9..512aa0db56 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/StatelessKafkaStreamsTwitterServer.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/StatelessKafkaStreamsTwitterServer.scala @@ -1,5 +1,6 @@ -package com.twitter.finatra.kafkastreams +package com.twitter.finatra.kafkastreams.utils +import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer import com.twitter.finatra.kafkastreams.internal.utils.TopologyReflectionUtils import org.apache.kafka.streams.Topology diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/converters/time.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/time.scala similarity index 82% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/converters/time.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/time.scala index 832b6b74e2..6d086b046d 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/converters/time.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/time.scala @@ -1,6 +1,6 @@ -package com.twitter.finatra.streams.converters +package com.twitter.finatra.kafkastreams.utils -import com.twitter.finatra.streams.transformer.domain.Time +import com.twitter.finatra.kafkastreams.transformer.domain.Time import org.joda.time.DateTime import org.joda.time.format.ISODateTimeFormat diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/package.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/utils.scala similarity index 72% rename from kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/package.scala rename to kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/utils.scala index 9e32f2ff97..b6e58e63b8 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/processors/package.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/utils/utils.scala @@ -1,5 +1,5 @@ package com.twitter.finatra.kafkastreams -package object processors { +package object utils { type MessageTimestamp = Long } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/OnWatermark.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/OnWatermark.scala deleted file mode 100644 index 249198150a..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/OnWatermark.scala +++ /dev/null @@ -1,7 +0,0 @@ -package com.twitter.finatra.streams.transformer - -import com.twitter.finatra.streams.transformer.domain.Watermark - -trait OnWatermark { - def onWatermark(watermark: Watermark): Unit -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowedValue.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowedValue.scala deleted file mode 100644 index 9a619e8817..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/domain/WindowedValue.scala +++ /dev/null @@ -1,9 +0,0 @@ -package com.twitter.finatra.streams.transformer.domain - -//TODO: Rename resultState to WindowResultType -case class WindowedValue[V](resultState: WindowResultType, value: V) { - - def map[VV](f: V => VV): WindowedValue[VV] = { - copy(value = f(value)) - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnClose.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnClose.scala deleted file mode 100644 index 960d7fac6b..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnClose.scala +++ /dev/null @@ -1,5 +0,0 @@ -package com.twitter.finatra.streams.transformer.internal - -trait OnClose { - protected def onClose(): Unit = {} -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnInit.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnInit.scala deleted file mode 100644 index 9070439f5b..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/OnInit.scala +++ /dev/null @@ -1,5 +0,0 @@ -package com.twitter.finatra.streams.transformer.internal - -trait OnInit { - protected def onInit(): Unit = {} -} diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/StateStoreImplicits.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/StateStoreImplicits.scala deleted file mode 100644 index 08088cdaeb..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/streams/transformer/internal/StateStoreImplicits.scala +++ /dev/null @@ -1,45 +0,0 @@ -package com.twitter.finatra.streams.transformer.internal - -import com.twitter.finagle.stats.Stat -import com.twitter.finatra.kafkastreams.internal.utils.ProcessorContextLogging -import com.twitter.util.Stopwatch -import org.apache.kafka.streams.state.KeyValueStore - -trait StateStoreImplicits extends ProcessorContextLogging { - - /* ------------------------------------------ */ - implicit class RichKeyIntValueStore[SK](keyValueStore: KeyValueStore[SK, Int]) { - - /** - * @return the new value associated with the specified key - */ - final def increment(key: SK, amount: Int): Int = { - val existingCount = keyValueStore.get(key) - val newCount = existingCount + amount - trace(s"keyValueStore.put($key, $newCount)") - keyValueStore.put(key, newCount) - newCount - } - - /** - * @return the new value associated with the specified key - */ - final def increment(key: SK, amount: Int, getStat: Stat, putStat: Stat): Int = { - val getElapsed = Stopwatch.start() - val existingCount = keyValueStore.get(key) - val getElapsedMillis = getElapsed.apply().inMillis - getStat.add(getElapsedMillis) - if (getElapsedMillis > 10) { - warn(s"SlowGet $getElapsedMillis ms for key $key") - } - - val newCount = existingCount + amount - - val putElapsed = Stopwatch.start() - keyValueStore.put(key, newCount) - putStat.add(putElapsed.apply().inMillis) - - newCount - } - } -} diff --git a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala b/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala deleted file mode 100644 index e066628eec..0000000000 --- a/kafka-streams/kafka-streams/src/main/scala/org/apache/kafka/streams/state/internals/InMemoryKeyValueFlushingStoreBuilder.scala +++ /dev/null @@ -1,20 +0,0 @@ -package org.apache.kafka.streams.state.internals - -import org.apache.kafka.common.serialization.Serde -import org.apache.kafka.common.utils.Time -import org.apache.kafka.streams.state.KeyValueStore - -class InMemoryKeyValueFlushingStoreBuilder[K, V]( - name: String, - keySerde: Serde[K], - valueSerde: Serde[V], - time: Time = Time.SYSTEM) - extends AbstractStoreBuilder[K, V, KeyValueStore[K, V]](name, keySerde, valueSerde, time) { - - override def build(): KeyValueStore[K, V] = { - val inMemoryKeyValueStore = new InMemoryKeyValueStore[K, V](name, keySerde, valueSerde) - val inMemoryFlushingKeyValueStore = - new InMemoryKeyValueFlushingLoggedStore[K, V](inMemoryKeyValueStore, keySerde, valueSerde) - new MeteredKeyValueStore[K, V](inMemoryFlushingKeyValueStore, "in-memory-state", time) - } -} diff --git a/kafka-streams/kafka-streams/src/test/scala/BUILD b/kafka-streams/kafka-streams/src/test/scala/BUILD index a0a7124dd8..19cfa417bc 100644 --- a/kafka-streams/kafka-streams/src/test/scala/BUILD +++ b/kafka-streams/kafka-streams/src/test/scala/BUILD @@ -1,6 +1,96 @@ -target( +scala_library( name = "test-deps", + sources = globs( + "com/twitter/finatra/kafkastreams/test/*.scala", + "com/twitter/inject/*.scala", + ), + compiler_option_sets = {"fatal_warnings"}, + provides = scala_artifact( + org = "com.twitter", + name = "finatra-streams-tests", + repo = artifactory, + ), + strict_deps = False, dependencies = [ - "finatra/kafka-streams/kafka-streams/src/test/scala/com/twitter:test-deps", + "3rdparty/jvm/com/google/inject:guice", + "3rdparty/jvm/junit", + "3rdparty/jvm/org/apache/kafka", + "3rdparty/jvm/org/apache/kafka:kafka-clients", + "3rdparty/jvm/org/apache/kafka:kafka-clients-test", + "3rdparty/jvm/org/apache/kafka:kafka-streams-test", + "3rdparty/jvm/org/apache/kafka:kafka-streams-test-utils", + "3rdparty/jvm/org/apache/kafka:kafka-test", + "3rdparty/jvm/org/scalatest", + "finatra/inject/inject-core/src/main/scala", + "finatra/inject/inject-core/src/test/scala:test-deps", + "finatra/inject/inject-server/src/test/scala:test-deps", + "finatra/inject/inject-slf4j/src/main/scala", + "finatra/jackson/src/main/scala", + "finatra/kafka-streams/kafka-streams/src/main/scala", + "finatra/kafka/src/test/scala:test-deps", + "util/util-slf4j-api/src/main/scala", + ], + excludes = [ + exclude( + org = "com.twitter", + name = "twitter-server-internal-naming_2.11", + ), + exclude( + org = "com.twitter", + name = "loglens-log4j-logging_2.11", + ), + exclude( + org = "log4j", + name = "log4j", + ), + ], + exports = [ + "3rdparty/jvm/com/google/inject:guice", + "3rdparty/jvm/junit", + "3rdparty/jvm/org/apache/kafka", + "3rdparty/jvm/org/apache/kafka:kafka-clients", + "3rdparty/jvm/org/apache/kafka:kafka-clients-test", + "3rdparty/jvm/org/apache/kafka:kafka-streams-test", + "3rdparty/jvm/org/apache/kafka:kafka-streams-test-utils", + "3rdparty/jvm/org/apache/kafka:kafka-test", + "3rdparty/jvm/org/scalatest", + "finatra/inject/inject-core/src/main/scala", + "finatra/inject/inject-core/src/test/scala:test-deps", + "finatra/inject/inject-server/src/test/scala:test-deps", + "finatra/inject/inject-slf4j/src/main/scala", + "finatra/jackson/src/main/scala", + "finatra/kafka-streams/kafka-streams/src/main/scala", + "finatra/kafka/src/test/scala:test-deps", + "util/util-slf4j-api/src/main/scala", + ], +) + +junit_tests( + sources = rglobs( + "com/twitter/finatra/kafkastreams/integration/*.scala", + "com/twitter/finatra/kafkastreams/transformer/*.scala", + ), + compiler_option_sets = {"fatal_warnings"}, + strict_deps = False, + dependencies = [ + "3rdparty/jvm/ch/qos/logback:logback-classic", + "3rdparty/jvm/org/apache/kafka:kafka-clients-test", + "3rdparty/jvm/org/apache/kafka:kafka-streams", + "3rdparty/jvm/org/apache/kafka:kafka-streams-test", + "3rdparty/jvm/org/apache/kafka:kafka-streams-test-utils", + "3rdparty/jvm/org/apache/kafka:kafka-test", + "3rdparty/jvm/org/apache/zookeeper:zookeeper-client", + "3rdparty/jvm/org/apache/zookeeper:zookeeper-server", + "finatra/inject/inject-app/src/main/scala", + "finatra/inject/inject-core/src/main/scala", + "finatra/inject/inject-core/src/test/scala:test-deps", + "finatra/inject/inject-server/src/main/scala", + "finatra/inject/inject-server/src/test/scala:test-deps", + "finatra/inject/inject-slf4j/src/main/scala", + "finatra/kafka-streams/kafka-streams/src/main/scala", + "finatra/kafka-streams/kafka-streams/src/test/resources", + "finatra/kafka-streams/kafka-streams/src/test/scala:test-deps", + "finatra/kafka/src/test/scala:test-deps", + "finatra/thrift/src/test/scala:test-deps", ], ) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/BUILD b/kafka-streams/kafka-streams/src/test/scala/com/twitter/BUILD deleted file mode 100644 index ddad30417c..0000000000 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/BUILD +++ /dev/null @@ -1,66 +0,0 @@ -scala_library( - name = "test-deps", - sources = globs( - "finatra/kafkastreams/test/*.scala", - "finatra/streams/tests/*.scala", - "inject/*.scala", - ), - compiler_option_sets = {"fatal_warnings"}, - provides = scala_artifact( - org = "com.twitter", - name = "finatra-streams-tests", - repo = artifactory, - ), - strict_deps = False, - dependencies = [ - "3rdparty/jvm/com/google/inject:guice", - "3rdparty/jvm/junit", - "3rdparty/jvm/org/apache/kafka", - "3rdparty/jvm/org/apache/kafka:kafka-clients", - "3rdparty/jvm/org/apache/kafka:kafka-clients-test", - "3rdparty/jvm/org/apache/kafka:kafka-streams-test", - "3rdparty/jvm/org/apache/kafka:kafka-streams-test-utils", - "3rdparty/jvm/org/apache/kafka:kafka-test", - "3rdparty/jvm/org/scalatest", - "finatra/inject/inject-core/src/main/scala", - "finatra/inject/inject-core/src/test/scala:test-deps", - "finatra/inject/inject-server/src/test/scala:test-deps", - "finatra/inject/inject-slf4j/src/main/scala", - "finatra/jackson/src/main/scala", - "finatra/kafka-streams/kafka-streams/src/main/scala", - "finatra/kafka/src/test/scala:test-deps", - "util/util-slf4j-api/src/main/scala", - ], - excludes = [ - exclude( - org = "com.twitter", - name = "twitter-server-internal-naming_2.11", - ), - exclude( - org = "com.twitter", - name = "loglens-log4j-logging_2.11", - ), - exclude( - org = "log4j", - name = "log4j", - ), - ], - exports = [ - "3rdparty/jvm/com/google/inject:guice", - "3rdparty/jvm/junit", - "3rdparty/jvm/org/apache/kafka", - "3rdparty/jvm/org/apache/kafka:kafka-clients", - "3rdparty/jvm/org/apache/kafka:kafka-clients-test", - "3rdparty/jvm/org/apache/kafka:kafka-streams-test", - "3rdparty/jvm/org/apache/kafka:kafka-test", - "3rdparty/jvm/org/scalatest", - "finatra/inject/inject-core/src/main/scala", - "finatra/inject/inject-core/src/test/scala:test-deps", - "finatra/inject/inject-server/src/test/scala:test-deps", - "finatra/inject/inject-slf4j/src/main/scala", - "finatra/jackson/src/main/scala", - "finatra/kafka-streams/kafka-streams/src/main/scala", - "finatra/kafka/src/test/scala:test-deps", - "util/util-slf4j-api/src/main/scala", - ], -) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/admin/KafkaStreamsAdminServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/admin/KafkaStreamsAdminServerFeatureTest.scala similarity index 97% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/admin/KafkaStreamsAdminServerFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/admin/KafkaStreamsAdminServerFeatureTest.scala index a02401e6e0..62c2734f77 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/admin/KafkaStreamsAdminServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/admin/KafkaStreamsAdminServerFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.admin +package com.twitter.finatra.kafkastreams.integration.admin import com.twitter.finatra.kafka.serde.UnKeyedSerde import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServer.scala similarity index 85% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServer.scala index 2907671fca..876396c629 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServer.scala @@ -1,8 +1,8 @@ -package com.twitter.unittests.integration.async_transformer +package com.twitter.finatra.kafkastreams.integration.async_transformer import com.twitter.finatra.kafka.serde.{ScalaSerdes, UnKeyedSerde} import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.kafkastreams.processors.FlushingAwareServer +import com.twitter.finatra.kafkastreams.flushing.FlushingAwareServer import org.apache.kafka.common.serialization.Serdes import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream.{Consumed, Produced} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServerFeatureTest.scala similarity index 94% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServerFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServerFeatureTest.scala index da79ba819a..2f29ed7a68 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServerFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.async_transformer +package com.twitter.finatra.kafkastreams.integration.async_transformer import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.test.KafkaStreamsFeatureTest diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServerTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServerTopologyFeatureTest.scala similarity index 87% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServerTopologyFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServerTopologyFeatureTest.scala index 568533f4f7..c8fdc3c9ec 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncServerTopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncServerTopologyFeatureTest.scala @@ -1,8 +1,8 @@ -package com.twitter.unittests.integration.async_transformer +package com.twitter.finatra.kafkastreams.integration.async_transformer import com.twitter.conversions.DurationOps._ import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.streams.tests.{FinatraTopologyTester, TopologyFeatureTest} +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} import org.apache.kafka.common.serialization.Serdes import org.joda.time.DateTime diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncTransformer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncTransformer.scala similarity index 82% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncTransformer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncTransformer.scala index c968d154bd..3c598fa920 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/async_transformer/WordLookupAsyncTransformer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/async_transformer/WordLookupAsyncTransformer.scala @@ -1,9 +1,10 @@ -package com.twitter.unittests.integration.async_transformer +package com.twitter.finatra.kafkastreams.integration.async_transformer import com.twitter.conversions.DurationOps._ import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.kafka.serde.UnKeyed -import com.twitter.finatra.kafkastreams.processors.{AsyncTransformer, MessageTimestamp} +import com.twitter.finatra.kafkastreams.flushing.AsyncTransformer +import com.twitter.finatra.kafkastreams.utils.MessageTimestamp import com.twitter.util.{Duration, Future} class WordLookupAsyncTransformer(statsReceiver: StatsReceiver, commitInterval: Duration) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicks.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicks.scala new file mode 100644 index 0000000000..e559c5b92a --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicks.scala @@ -0,0 +1,5 @@ +package com.twitter.finatra.kafkastreams.integration.compositesum + +import UserClicksTypes.UserId + +case class UserClicks(userId: UserId, clickType: Int) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksSerde.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksSerde.scala similarity index 89% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksSerde.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksSerde.scala index 6a29fe7672..5eb61174ab 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksSerde.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksSerde.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.compositesum +package com.twitter.finatra.kafkastreams.integration.compositesum import com.google.common.primitives.Ints import com.twitter.finatra.kafka.serde.AbstractSerde diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksServer.scala similarity index 79% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksServer.scala index 7fcdf0be10..1befd3b533 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksServer.scala @@ -1,11 +1,11 @@ -package com.twitter.unittests.integration.compositesum +package com.twitter.finatra.kafkastreams.integration.compositesum import com.twitter.conversions.DurationOps._ import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer import com.twitter.finatra.kafkastreams.dsl.FinatraDslWindowedAggregations -import com.twitter.finatra.streams.transformer.domain.{FixedTimeWindowedSerde, WindowedValueSerde} -import com.twitter.unittests.integration.compositesum.UserClicksTypes.{NumClicksSerde, UserIdSerde} +import com.twitter.finatra.kafkastreams.integration.compositesum.UserClicksTypes.{NumClicksSerde, UserIdSerde} +import com.twitter.finatra.kafkastreams.transformer.aggregation.{FixedTimeWindowedSerde, WindowedValueSerde} import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream.{Consumed, Produced} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksTopologyFeatureTest.scala similarity index 87% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTopologyFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksTopologyFeatureTest.scala index 94ca014ee1..0e14a93714 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksTopologyFeatureTest.scala @@ -1,9 +1,10 @@ -package com.twitter.unittests.integration.compositesum +package com.twitter.finatra.kafkastreams.integration.compositesum import com.twitter.conversions.DurationOps._ -import com.twitter.finatra.streams.tests.{FinatraTopologyTester, TopologyFeatureTest} -import com.twitter.finatra.streams.transformer.domain._ -import com.twitter.unittests.integration.compositesum.UserClicksTypes.{ClickTypeSerde, NumClicksSerde, UserIdSerde} +import com.twitter.finatra.kafkastreams.integration.compositesum.UserClicksTypes._ +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} +import com.twitter.finatra.kafkastreams.transformer.aggregation.{FixedTimeWindowedSerde, TimeWindowed, WindowClosed, WindowOpen, WindowedValue, WindowedValueSerde} +import com.twitter.finatra.kafkastreams.transformer.domain.Time import org.joda.time.DateTime class UserClicksTopologyFeatureTest extends TopologyFeatureTest { diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTypes.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksTypes.scala similarity index 84% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTypes.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksTypes.scala index a8ba9dd98d..7162471524 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicksTypes.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/compositesum/UserClicksTypes.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.compositesum +package com.twitter.finatra.kafkastreams.integration.compositesum import com.twitter.finatra.kafka.serde.ScalaSerdes import org.apache.kafka.common.serialization.Serde diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/default_serde/DefaultSerdeWordCountDbServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/default_serde/DefaultSerdeWordCountDbServer.scala similarity index 93% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/default_serde/DefaultSerdeWordCountDbServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/default_serde/DefaultSerdeWordCountDbServer.scala index 7de876fecd..6d560ed64b 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/default_serde/DefaultSerdeWordCountDbServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/default_serde/DefaultSerdeWordCountDbServer.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.default_serde +package com.twitter.finatra.kafkastreams.integration.default_serde import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/default_serde/DefaultSerdeWordCountServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/default_serde/DefaultSerdeWordCountServerFeatureTest.scala similarity index 94% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/default_serde/DefaultSerdeWordCountServerFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/default_serde/DefaultSerdeWordCountServerFeatureTest.scala index e324378432..9680b24870 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/default_serde/DefaultSerdeWordCountServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/default_serde/DefaultSerdeWordCountServerFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.default_serde +package com.twitter.finatra.kafkastreams.integration.default_serde import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthFinatraTransformer.scala similarity index 70% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthFinatraTransformer.scala index 0e2035b2f1..d7eb6fc4dd 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthFinatraTransformer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthFinatraTransformer.scala @@ -1,10 +1,11 @@ -package com.twitter.unittests.integration.finatratransformer +package com.twitter.finatra.kafkastreams.integration.finatratransformer import com.twitter.conversions.DurationOps._ import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finatra.streams.transformer.domain.{Expire, Time, TimerMetadata} -import com.twitter.finatra.streams.transformer.{FinatraTransformer, PersistentTimers} -import com.twitter.unittests.integration.finatratransformer.WordLengthFinatraTransformer._ +import com.twitter.finatra.kafkastreams.integration.finatratransformer.WordLengthFinatraTransformer._ +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer +import com.twitter.finatra.kafkastreams.transformer.domain.{Expire, Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.stores.PersistentTimers import com.twitter.util.Duration import org.apache.kafka.streams.processor.PunctuationType diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthServer.scala similarity index 81% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthServer.scala index f1d05c7932..099b6c781c 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthServer.scala @@ -1,8 +1,8 @@ -package com.twitter.unittests.integration.finatratransformer +package com.twitter.finatra.kafkastreams.integration.finatratransformer import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.streams.transformer.FinatraTransformer -import com.twitter.unittests.integration.finatratransformer.WordLengthServer._ +import com.twitter.finatra.kafkastreams.integration.finatratransformer.WordLengthServer._ +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer import org.apache.kafka.common.serialization.Serdes import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream.{Consumed, Produced} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServerTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthServerTopologyFeatureTest.scala similarity index 89% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServerTopologyFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthServerTopologyFeatureTest.scala index 7fc06b441c..2c4306b56a 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/finatratransformer/WordLengthServerTopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/finatratransformer/WordLengthServerTopologyFeatureTest.scala @@ -1,7 +1,7 @@ -package com.twitter.unittests.integration.finatratransformer +package com.twitter.finatra.kafkastreams.integration.finatratransformer import com.twitter.conversions.DurationOps._ -import com.twitter.finatra.streams.tests.{FinatraTopologyTester, TopologyFeatureTest} +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} import org.apache.kafka.common.serialization.Serdes import org.joda.time.DateTime diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/globaltable/GlobalTableServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/globaltable/GlobalTableServer.scala similarity index 92% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/globaltable/GlobalTableServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/globaltable/GlobalTableServer.scala index d50819b5f3..f6c67464ca 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/globaltable/GlobalTableServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/globaltable/GlobalTableServer.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.globaltable +package com.twitter.finatra.kafkastreams.integration.globaltable import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/globaltable/GlobalTableServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/globaltable/GlobalTableServerFeatureTest.scala similarity index 96% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/globaltable/GlobalTableServerFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/globaltable/GlobalTableServerFeatureTest.scala index 3fa8438156..3dd8e0d460 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/globaltable/GlobalTableServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/globaltable/GlobalTableServerFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.globaltable +package com.twitter.finatra.kafkastreams.integration.globaltable import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.config.KafkaStreamsConfig diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/sampling/SamplingServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/sampling/SamplingServer.scala similarity index 85% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/sampling/SamplingServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/sampling/SamplingServer.scala index c5c32a43dd..afae4821a8 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/sampling/SamplingServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/sampling/SamplingServer.scala @@ -1,22 +1,14 @@ -package com.twitter.unittests.integration.sampling +package com.twitter.finatra.kafkastreams.integration.sampling import com.twitter.conversions.DurationOps._ import com.twitter.conversions.StorageUnitOps._ import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.kafkastreams.config.FinatraRocksDBConfig.{ - RocksDbBlockCacheSizeConfig, - RocksDbEnableStatistics, - RocksDbLZ4Config -} -import com.twitter.finatra.kafkastreams.config.{FinatraRocksDBConfig, KafkaStreamsConfig} +import com.twitter.finatra.kafkastreams.config.FinatraRocksDBConfig.{RocksDbBlockCacheSizeConfig, RocksDbEnableStatistics, RocksDbLZ4Config} +import com.twitter.finatra.kafkastreams.config.FinatraTransformerFlags.{AutoWatermarkInterval, EmitWatermarkPerMessage} +import com.twitter.finatra.kafkastreams.config.{FinatraRocksDBConfig, KafkaStreamsConfig, RocksDbFlags} import com.twitter.finatra.kafkastreams.dsl.FinatraDslSampling -import com.twitter.finatra.streams.flags.FinatraTransformerFlags.{ - AutoWatermarkInterval, - EmitWatermarkPerMessage -} -import com.twitter.finatra.streams.flags.RocksDbFlags -import com.twitter.unittests.integration.sampling.SamplingServer._ +import com.twitter.finatra.kafkastreams.integration.sampling.SamplingServer._ import org.apache.kafka.common.record.CompressionType import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream._ diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/sampling/SamplingServerTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/sampling/SamplingServerTopologyFeatureTest.scala similarity index 76% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/sampling/SamplingServerTopologyFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/sampling/SamplingServerTopologyFeatureTest.scala index d95341056f..fd849d2427 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/sampling/SamplingServerTopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/sampling/SamplingServerTopologyFeatureTest.scala @@ -1,14 +1,14 @@ -package com.twitter.unittests.integration.sampling +package com.twitter.finatra.kafkastreams.integration.sampling import com.twitter.conversions.DurationOps._ import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.streams.tests.{FinatraTopologyTester, TopologyFeatureTest} -import com.twitter.finatra.streams.transformer.domain.IndexedSampleKey -import com.twitter.finatra.streams.transformer.{IteratorImplicits, SamplingUtils} +import com.twitter.finatra.kafkastreams.internal.utils.sampling.IndexedSampleKey +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} +import com.twitter.finatra.kafkastreams.transformer.utils.{IteratorImplicits, SamplingUtils} import org.apache.kafka.streams.state.KeyValueStore import org.joda.time.DateTime -class SamplingServerTopologyFeatureTest extends TopologyFeatureTest with IteratorImplicits{ +class SamplingServerTopologyFeatureTest extends TopologyFeatureTest with IteratorImplicits { override val topologyTester = FinatraTopologyTester( kafkaApplicationId = "sampling-server-prod-alice", @@ -17,18 +17,22 @@ class SamplingServerTopologyFeatureTest extends TopologyFeatureTest with Iterato ) private val tweetIdToImpressingUserId = - topologyTester.topic(SamplingServer.tweetToImpressingUserTopic, ScalaSerdes.Long, ScalaSerdes.Long) + topologyTester.topic( + SamplingServer.tweetToImpressingUserTopic, + ScalaSerdes.Long, + ScalaSerdes.Long) private var countStore: KeyValueStore[Long, Long] = _ private var sampleStore: KeyValueStore[IndexedSampleKey[Long], Long] = _ - override def beforeEach(): Unit = { super.beforeEach() - countStore = topologyTester.driver.getKeyValueStore[Long, Long](SamplingUtils.getNumCountsStoreName(SamplingServer.sampleName)) - sampleStore = topologyTester.driver.getKeyValueStore[IndexedSampleKey[Long], Long](SamplingUtils.getSampleStoreName(SamplingServer.sampleName)) + countStore = topologyTester.driver.getKeyValueStore[Long, Long]( + SamplingUtils.getNumCountsStoreName(SamplingServer.sampleName)) + sampleStore = topologyTester.driver.getKeyValueStore[IndexedSampleKey[Long], Long]( + SamplingUtils.getSampleStoreName(SamplingServer.sampleName)) } test("test that a sample does what you want") { @@ -74,12 +78,14 @@ class SamplingServerTopologyFeatureTest extends TopologyFeatureTest with Iterato } private def assertSampleSize(tweetId: Int, expectedSize: Int): Unit = { - val range = sampleStore.range(IndexedSampleKey(tweetId, 0), IndexedSampleKey(tweetId, Int.MaxValue)) + val range = + sampleStore.range(IndexedSampleKey(tweetId, 0), IndexedSampleKey(tweetId, Int.MaxValue)) range.values.toSet.size should be(expectedSize) } private def assertSampleEquals(tweetId: Int, expectedSample: Set[Int]): Unit = { - val range = sampleStore.range(IndexedSampleKey(tweetId, 0), IndexedSampleKey(tweetId, Int.MaxValue)) + val range = + sampleStore.range(IndexedSampleKey(tweetId, 0), IndexedSampleKey(tweetId, Int.MaxValue)) range.values.toSet should be(expectedSample) } diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/stateless/VerifyFailureServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/stateless/VerifyFailureServer.scala similarity index 84% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/stateless/VerifyFailureServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/stateless/VerifyFailureServer.scala index 06148bf77d..ebcebf1f4c 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/stateless/VerifyFailureServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/stateless/VerifyFailureServer.scala @@ -1,7 +1,7 @@ -package com.twitter.unittests.integration.stateless +package com.twitter.finatra.kafkastreams.integration.stateless import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.kafkastreams.StatelessKafkaStreamsTwitterServer +import com.twitter.finatra.kafkastreams.utils.StatelessKafkaStreamsTwitterServer import org.apache.kafka.common.serialization.Serdes import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream.{Consumed, Materialized, Produced, Serialized} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/stateless/VerifyFailureServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/stateless/VerifyFailureServerFeatureTest.scala similarity index 92% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/stateless/VerifyFailureServerFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/stateless/VerifyFailureServerFeatureTest.scala index 4dd8b95599..0029723a07 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/stateless/VerifyFailureServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/stateless/VerifyFailureServerFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.stateless +package com.twitter.finatra.kafkastreams.integration.stateless import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.test.KafkaStreamsMultiServerFeatureTest diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/window/WindowedTweetWordCountServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/window/WindowedTweetWordCountServer.scala similarity index 87% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/window/WindowedTweetWordCountServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/window/WindowedTweetWordCountServer.scala index decbfb29ed..eefd2de35a 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/window/WindowedTweetWordCountServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/window/WindowedTweetWordCountServer.scala @@ -1,10 +1,10 @@ -package com.twitter.unittests.integration.window +package com.twitter.finatra.kafkastreams.integration.window import com.twitter.conversions.DurationOps._ import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer import com.twitter.finatra.kafkastreams.dsl.FinatraDslWindowedAggregations -import com.twitter.finatra.streams.transformer.domain._ +import com.twitter.finatra.kafkastreams.transformer.aggregation.{FixedTimeWindowedSerde, WindowedValueSerde} import org.apache.kafka.common.serialization.Serdes import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.kstream.{Consumed, Produced} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/window/WindowedTweetWordCountServerTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/window/WindowedTweetWordCountServerTopologyFeatureTest.scala similarity index 84% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/window/WindowedTweetWordCountServerTopologyFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/window/WindowedTweetWordCountServerTopologyFeatureTest.scala index 06650991db..6ff926b67c 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/window/WindowedTweetWordCountServerTopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/window/WindowedTweetWordCountServerTopologyFeatureTest.scala @@ -1,10 +1,10 @@ -package com.twitter.unittests.integration.window +package com.twitter.finatra.kafkastreams.integration.window import com.twitter.conversions.DurationOps._ import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.streams.query.QueryableFinatraWindowStore -import com.twitter.finatra.streams.tests.{FinatraTopologyTester, TopologyFeatureTest} -import com.twitter.finatra.streams.transformer.domain.WindowedValueSerde +import com.twitter.finatra.kafkastreams.query.QueryableFinatraWindowStore +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} +import com.twitter.finatra.kafkastreams.transformer.aggregation.WindowedValueSerde import org.apache.kafka.common.serialization.Serdes import org.joda.time.DateTime diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountRocksDbServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountRocksDbServer.scala similarity index 93% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountRocksDbServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountRocksDbServer.scala index 79728e54e7..95d5bb61e7 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountRocksDbServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountRocksDbServer.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.wordcount +package com.twitter.finatra.kafkastreams.integration.wordcount import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountServerFeatureTest.scala similarity index 98% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountServerFeatureTest.scala index 7ea6f4b355..5b93eca8bd 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountServerFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.wordcount +package com.twitter.finatra.kafkastreams.integration.wordcount import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafka.test.utils.InMemoryStatsUtil diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountServerTopologyFeatureTest.scala similarity index 90% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerTopologyFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountServerTopologyFeatureTest.scala index c189d8b8bd..edc5f0b6bc 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount/WordCountServerTopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount/WordCountServerTopologyFeatureTest.scala @@ -1,7 +1,7 @@ -package com.twitter.unittests.integration.wordcount +package com.twitter.finatra.kafkastreams.integration.wordcount import com.twitter.finatra.kafka.serde.ScalaSerdes -import com.twitter.finatra.streams.tests.{FinatraTopologyTester, TopologyFeatureTest} +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} import org.apache.kafka.common.serialization.Serdes import org.joda.time.DateTime diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount_in_memory/WordCountInMemoryServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount_in_memory/WordCountInMemoryServer.scala similarity index 94% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount_in_memory/WordCountInMemoryServer.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount_in_memory/WordCountInMemoryServer.scala index 0a4dee1dab..d0c4b6c255 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount_in_memory/WordCountInMemoryServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount_in_memory/WordCountInMemoryServer.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.wordcount_in_memory +package com.twitter.finatra.kafkastreams.integration.wordcount_in_memory import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount_in_memory/WordCountInMemoryServerFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount_in_memory/WordCountInMemoryServerFeatureTest.scala similarity index 96% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount_in_memory/WordCountInMemoryServerFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount_in_memory/WordCountInMemoryServerFeatureTest.scala index e9a1baac0a..ae3d5ec345 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/wordcount_in_memory/WordCountInMemoryServerFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/wordcount_in_memory/WordCountInMemoryServerFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.unittests.integration.wordcount_in_memory +package com.twitter.finatra.kafkastreams.integration.wordcount_in_memory import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.test.KafkaStreamsFeatureTest diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/FinatraTopologyTester.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/FinatraTopologyTester.scala similarity index 95% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/FinatraTopologyTester.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/FinatraTopologyTester.scala index c19fd2f967..d99337c4e5 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/FinatraTopologyTester.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/FinatraTopologyTester.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.tests +package com.twitter.finatra.kafkastreams.test import com.github.nscala_time.time.DurationBuilder import com.google.inject.Module @@ -7,15 +7,11 @@ import com.twitter.finagle.stats.{InMemoryStatsReceiver, StatsReceiver} import com.twitter.finatra.kafka.modules.KafkaBootstrapModule import com.twitter.finatra.kafka.test.utils.InMemoryStatsUtil import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer -import com.twitter.finatra.kafkastreams.test.TestDirectoryUtils -import com.twitter.finatra.streams.converters.time._ -import com.twitter.finatra.streams.flags.FinatraTransformerFlags -import com.twitter.finatra.streams.query.{ - QueryableFinatraKeyValueStore, - QueryableFinatraWindowStore -} -import com.twitter.finatra.streams.transformer.domain.TimeWindowed -import com.twitter.finatra.streams.transformer.internal.domain.Timer +import com.twitter.finatra.kafkastreams.config.FinatraTransformerFlags +import com.twitter.finatra.kafkastreams.query.{QueryableFinatraKeyValueStore, QueryableFinatraWindowStore} +import com.twitter.finatra.kafkastreams.transformer.aggregation.TimeWindowed +import com.twitter.finatra.kafkastreams.transformer.stores.internal.Timer +import com.twitter.finatra.kafkastreams.utils.time._ import com.twitter.inject.{AppAccessor, Injector, Logging, TwitterModule} import com.twitter.util.Duration import java.util.Properties diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/TopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/TopologyFeatureTest.scala similarity index 97% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/TopologyFeatureTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/TopologyFeatureTest.scala index c1b331f0c6..e3db22ce24 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/TopologyFeatureTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/TopologyFeatureTest.scala @@ -1,4 +1,4 @@ -package com.twitter.finatra.streams.tests +package com.twitter.finatra.kafkastreams.test import com.twitter.inject.Test diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/TopologyTesterTopic.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/TopologyTesterTopic.scala similarity index 95% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/TopologyTesterTopic.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/TopologyTesterTopic.scala index 60e4d31d0a..6ddbe5ac41 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/tests/TopologyTesterTopic.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/TopologyTesterTopic.scala @@ -1,6 +1,6 @@ -package com.twitter.finatra.streams.tests +package com.twitter.finatra.kafkastreams.test -import com.twitter.finatra.streams.converters.time._ +import com.twitter.finatra.kafkastreams.utils.time._ import org.apache.kafka.clients.producer.ProducerRecord import org.apache.kafka.common.serialization.Serde import org.apache.kafka.streams.TopologyTestDriver diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/FinatraTransformerTest.scala similarity index 91% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/FinatraTransformerTest.scala index 6a2e43868c..8d05e470f6 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/FinatraTransformerTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/FinatraTransformerTest.scala @@ -1,9 +1,11 @@ -package com.twitter.finatra.streams.transformer +package com.twitter.finatra.kafkastreams.transformer import com.twitter.conversions.DurationOps._ import com.twitter.finagle.stats.{NullStatsReceiver, StatsReceiver} import com.twitter.finatra.kafkastreams.config.KafkaStreamsConfig -import com.twitter.finatra.streams.transformer.domain.{Time, Watermark} +import com.twitter.finatra.kafkastreams.transformer.domain.Time +import com.twitter.finatra.kafkastreams.transformer.stores.CachingKeyValueStores +import com.twitter.finatra.kafkastreams.transformer.watermarks.Watermark import com.twitter.inject.Test import com.twitter.util.Duration import org.apache.kafka.common.serialization.Serdes @@ -53,7 +55,7 @@ class FinatraTransformerTest extends Test with com.twitter.inject.Mockito { test("watermark processing when forwarding from caching flush listener") { val transformer = new FinatraTransformer[String, String, String, String](NullStatsReceiver) - with CachingKeyValueStores[String, String, String, String] { + with CachingKeyValueStores[String, String, String, String] { private val cache = getCachingKeyValueStore[String, String]("mystore") override def statsReceiver: StatsReceiver = NullStatsReceiver @@ -118,9 +120,9 @@ class FinatraTransformerTest extends Test with com.twitter.inject.Mockito { .bootstrapServers("127.0.0.1:1000") class FinatraMockProcessorContext - extends InternalMockProcessorContext( - TestUtils.tempDirectory, - new StreamsConfig(config.properties)) { + extends InternalMockProcessorContext( + TestUtils.tempDirectory, + new StreamsConfig(config.properties)) { override def schedule( interval: Long, diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/TimeTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/domain/TimeTest.scala similarity index 95% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/TimeTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/domain/TimeTest.scala index 2ece7c9de7..db392346c4 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/TimeTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/domain/TimeTest.scala @@ -1,6 +1,5 @@ -package com.twitter.unittests +package com.twitter.finatra.kafkastreams.transformer.domain -import com.twitter.finatra.streams.transformer.domain.Time import com.twitter.inject.Test import com.twitter.util.Duration import java.util.concurrent.TimeUnit diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/PersistentTimerStoreTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimerStoreTest.scala similarity index 93% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/PersistentTimerStoreTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimerStoreTest.scala index 1149a40d95..5e34035ebd 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/PersistentTimerStoreTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/stores/PersistentTimerStoreTest.scala @@ -1,11 +1,11 @@ -package com.twitter.unittests +package com.twitter.finatra.kafkastreams.transformer.stores import com.twitter.finagle.stats.NullStatsReceiver import com.twitter.finatra.json.JsonDiff -import com.twitter.finatra.streams.stores.internal.FinatraKeyValueStoreImpl -import com.twitter.finatra.streams.transformer.PersistentTimerStore -import com.twitter.finatra.streams.transformer.domain.{Expire, Time, TimerMetadata, Watermark} -import com.twitter.finatra.streams.transformer.internal.domain.{Timer, TimerSerde} +import com.twitter.finatra.kafkastreams.transformer.domain.{Expire, Time, TimerMetadata} +import com.twitter.finatra.kafkastreams.transformer.stores.internal.{FinatraKeyValueStoreImpl, Timer} +import com.twitter.finatra.kafkastreams.transformer.watermarks.Watermark +import com.twitter.finatra.streams.transformer.internal.domain.TimerSerde import com.twitter.inject.Test import org.apache.kafka.common.metrics.Metrics import org.apache.kafka.common.serialization.Serdes diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/FinatraKeyValueStoreLatencyTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraKeyValueStoreLatencyTest.scala similarity index 98% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/FinatraKeyValueStoreLatencyTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraKeyValueStoreLatencyTest.scala index b33eac4d72..09a5890536 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/FinatraKeyValueStoreLatencyTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/stores/internal/FinatraKeyValueStoreLatencyTest.scala @@ -1,8 +1,7 @@ -package com.twitter.unittests +package com.twitter.finatra.kafkastreams.transformer.stores.internal import com.twitter.finagle.stats.InMemoryStatsReceiver import com.twitter.finatra.kafka.test.utils.InMemoryStatsUtil -import com.twitter.finatra.streams.stores.internal.FinatraKeyValueStoreImpl import com.twitter.inject.Test import org.apache.kafka.common.metrics.Metrics import org.apache.kafka.common.serialization.Serdes diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/MultiSpanIteratorTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/utils/MultiSpanIteratorTest.scala similarity index 93% rename from kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/MultiSpanIteratorTest.scala rename to kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/utils/MultiSpanIteratorTest.scala index c62d7d5b80..f3089c5842 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/MultiSpanIteratorTest.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/transformer/utils/MultiSpanIteratorTest.scala @@ -1,6 +1,5 @@ -package com.twitter.unittests +package com.twitter.finatra.kafkastreams.transformer.utils -import com.twitter.finatra.streams.transformer.MultiSpanIterator import com.twitter.inject.Test class MultiSpanIteratorTest extends Test { diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD deleted file mode 100644 index d00f8ba06f..0000000000 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/streams/transformer/BUILD +++ /dev/null @@ -1,12 +0,0 @@ -junit_tests( - sources = rglobs("*.scala"), - compiler_option_sets = {"fatal_warnings"}, - strict_deps = False, - dependencies = [ - "3rdparty/jvm/ch/qos/logback:logback-classic", - "finatra/kafka-streams/kafka-streams/src/main/scala", - "finatra/kafka-streams/kafka-streams/src/test/resources", - "finatra/kafka-streams/kafka-streams/src/test/scala/com/twitter:test-deps", - "finatra/kafka/src/test/scala:test-deps", - ], -) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD deleted file mode 100644 index 80f391f7ef..0000000000 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/BUILD +++ /dev/null @@ -1,26 +0,0 @@ -junit_tests( - sources = rglobs("*.scala"), - compiler_option_sets = {"fatal_warnings"}, - strict_deps = False, - dependencies = [ - "3rdparty/jvm/ch/qos/logback:logback-classic", - "3rdparty/jvm/org/apache/kafka:kafka-clients-test", - "3rdparty/jvm/org/apache/kafka:kafka-streams", - "3rdparty/jvm/org/apache/kafka:kafka-streams-test", - "3rdparty/jvm/org/apache/kafka:kafka-streams-test-utils", - "3rdparty/jvm/org/apache/kafka:kafka-test", - "3rdparty/jvm/org/apache/zookeeper:zookeeper-client", - "3rdparty/jvm/org/apache/zookeeper:zookeeper-server", - "finatra/inject/inject-app/src/main/scala", - "finatra/inject/inject-core/src/main/scala", - "finatra/inject/inject-core/src/test/scala:test-deps", - "finatra/inject/inject-server/src/main/scala", - "finatra/inject/inject-server/src/test/scala:test-deps", - "finatra/inject/inject-slf4j/src/main/scala", - "finatra/kafka-streams/kafka-streams/src/main/scala", - "finatra/kafka-streams/kafka-streams/src/test/resources", - "finatra/kafka-streams/kafka-streams/src/test/scala/com/twitter:test-deps", - "finatra/kafka/src/test/scala:test-deps", - "finatra/thrift/src/test/scala:test-deps", - ], -) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicks.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicks.scala deleted file mode 100644 index 2887e0c560..0000000000 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/unittests/integration/compositesum/UserClicks.scala +++ /dev/null @@ -1,5 +0,0 @@ -package com.twitter.unittests.integration.compositesum - -import com.twitter.unittests.integration.compositesum.UserClicksTypes.UserId - -case class UserClicks(userId: UserId, clickType: Int) From e5bda446476e98d7ccf6053a3d6cb4b404948cc1 Mon Sep 17 00:00:00 2001 From: Jing Yan Date: Mon, 4 Feb 2019 19:06:36 +0000 Subject: [PATCH 24/45] finatra-http: Integrate response CallbackConverter with Reader Problem CallbackConverter only returns AsyncStream as a streaming response. Solution Add support to return Reader in CallbackConverter. Result User is able to serve Reader as a streaming response within Finatra controller. JIRA Issues: CSL-7478 Differential Revision: https://phabricator.twitter.biz/D266863 --- CHANGELOG.rst | 3 ++ .../marshalling/CallbackConverter.scala | 32 +++++++++++-------- .../marshalling/MessageBodyManager.scala | 2 +- .../CallbackConverterIntegrationTest.scala | 26 +++++++++++---- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 83312740f3..ac369f1143 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,9 @@ Unreleased Added ~~~~~ +* finatra-http: Added support to serve `c.t.io.Reader` as a streaming response in + `c.t.finatra.http.internal.marshalling.CallbackConverter`. ``PHAB_ID=D266863`` + * finatra-kafka: Expose endOffsets() in FinagleKafkaConsumer. ``PHAB_ID=D263573`` * finatra-kafka-streams: Adding missing ScalaDocs. Adding metric for elapsed state diff --git a/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/CallbackConverter.scala b/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/CallbackConverter.scala index 8d4327b721..7912c0fca7 100644 --- a/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/CallbackConverter.scala +++ b/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/CallbackConverter.scala @@ -6,23 +6,22 @@ import com.twitter.finatra.http.internal.marshalling.CallbackConverter.url import com.twitter.finatra.http.response.{ResponseBuilder, StreamingResponse} import com.twitter.finatra.json.FinatraObjectMapper import com.twitter.finatra.json.internal.streaming.JsonStreamParser -import com.twitter.io.Buf +import com.twitter.io.{Buf, Reader} import com.twitter.util.{Future, FuturePool, Promise} import javax.inject.Inject import scala.concurrent.{ExecutionContext => ScalaExecutionContext, Future => ScalaFuture} import scala.util.{Failure, Success} private object CallbackConverter { - val url = "https://twitter.github.io/finatra/user-guide/http/controllers.html#controllers-and-routing" + val url = + "https://twitter.github.io/finatra/user-guide/http/controllers.html#controllers-and-routing" } - private[http] class CallbackConverter @Inject()( messageBodyManager: MessageBodyManager, responseBuilder: ResponseBuilder, mapper: FinatraObjectMapper, - jsonStreamParser: JsonStreamParser -) { + jsonStreamParser: JsonStreamParser) { /* Public */ @@ -76,6 +75,12 @@ private[http] class CallbackConverter @Inject()( StreamingResponse.jsonArray(toBuf = mapper.writeValueAsBuf, asyncStream = asyncStream) streamingResponse.toFutureFinagleResponse + } else if (runtimeClassEq[ResponseType, Reader[_]]) { request: Request => + val reader = requestCallback(request).asInstanceOf[Reader[_]] + val streamingResponse = StreamingResponse.jsonArray( + toBuf = mapper.writeValueAsBuf, + asyncStream = Reader.toAsyncStream(reader)) + streamingResponse.toFutureFinagleResponse } else if (runtimeClassEq[ResponseType, Future[_]]) { request: Request => requestCallback(request).asInstanceOf[Future[_]].map(createHttpResponse(request)) } else if (runtimeClassEq[ResponseType, StreamingResponse[_, _]]) { request: Request => @@ -141,8 +146,8 @@ private[http] class CallbackConverter @Inject()( contentType: String ): Response = { assert( - contentType == responseBuilder.jsonContentType || - contentType == responseBuilder.plainTextContentType + contentType == responseBuilder.jsonContentType || + contentType == responseBuilder.plainTextContentType ) val orig = Response(status) @@ -190,8 +195,11 @@ private[http] class CallbackConverter @Inject()( typeArgs.head.runtimeClass == classOf[Option[_]] } - private def toTwitterFuture[A](scalaFuture: ScalaFuture[A]) - (implicit executor: ScalaExecutionContext): Future[A] = { + private def toTwitterFuture[A]( + scalaFuture: ScalaFuture[A] + )( + implicit executor: ScalaExecutionContext + ): Future[A] = { val p = new Promise[A]() scalaFuture.onComplete { case Success(value) => p.setValue(value) @@ -209,10 +217,8 @@ private[http] class CallbackConverter @Inject()( private[this] val immediatePoolExcCtx = new ExecutionContext(FuturePool.immediatePool) /** ExecutionContext adapter using a FuturePool; see bijection/TwitterExecutionContext */ - private[this] class ExecutionContext( - pool: FuturePool, - report: Throwable => Unit - ) extends ScalaExecutionContext { + private[this] class ExecutionContext(pool: FuturePool, report: Throwable => Unit) + extends ScalaExecutionContext { def this(pool: FuturePool) = this(pool, ExecutionContext.ignore) override def execute(runnable: Runnable): Unit = { pool(runnable.run()) diff --git a/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/MessageBodyManager.scala b/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/MessageBodyManager.scala index dcd11cf5d5..75e0186dbd 100644 --- a/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/MessageBodyManager.scala +++ b/http/src/main/scala/com/twitter/finatra/http/internal/marshalling/MessageBodyManager.scala @@ -29,7 +29,7 @@ import scala.collection.mutable * [[com.twitter.finatra.http.HttpServer.messageBodyModule]]. * * When the MessageBodyManager is obtained from the injector (which is configured with the framework - * [[com.twitter.finatra.http.modules.MessageBodyModule]] the framework default implementations for + * [[com.twitter.finatra.http.modules.MessageBodyModule]]) the framework default implementations for * the reader and writer will be provided accordingly (along with the configured server injector). * * @param injector the configured [[com.twitter.inject.Injector]] for the server. diff --git a/http/src/test/scala/com/twitter/finatra/http/tests/marshalling/CallbackConverterIntegrationTest.scala b/http/src/test/scala/com/twitter/finatra/http/tests/marshalling/CallbackConverterIntegrationTest.scala index 54f3a894b5..18d25e1bcb 100644 --- a/http/src/test/scala/com/twitter/finatra/http/tests/marshalling/CallbackConverterIntegrationTest.scala +++ b/http/src/test/scala/com/twitter/finatra/http/tests/marshalling/CallbackConverterIntegrationTest.scala @@ -206,16 +206,16 @@ class CallbackConverterIntegrationTest extends IntegrationTest with Mockito { val converted = callbackConverter.convertToFutureResponse(asyncStreamRequest) - val response = Await.result(converted(request)) + val response = await(converted(request)) assertOk(response, "List(1, 2)") } test("AsyncStream response") { val converted = callbackConverter.convertToFutureResponse(asyncStreamResponse) - val response = Await.result(converted(Request())) + val response = await(converted(Request())) response.status should equal(Status.Ok) - Await.result(Reader.readAll(response.reader)).utf8str should equal("[1,2,3]") + await(Reader.readAll(response.reader)).utf8str should equal("[1,2,3]") } test("AsyncStream request and response") { @@ -228,9 +228,17 @@ class CallbackConverterIntegrationTest extends IntegrationTest with Mockito { val converted = callbackConverter.convertToFutureResponse(asyncStreamRequestAndResponse) - val response = Await.result(converted(request)) + val response = await(converted(request)) response.status should equal(Status.Ok) - Await.result(Reader.readAll(response.reader)).utf8str should equal("""["1","2"]""") + await(Reader.readAll(response.reader)).utf8str should equal("""["1","2"]""") + } + + test("Reader response") { + val converted = callbackConverter.convertToFutureResponse(readerResponse) + + val response = await(converted(Request())) + response.status should equal(Status.Ok) + await(Reader.readAll(response.reader)).utf8str should equal("[1.1,2.2,3.3]") } test("Null") { @@ -369,18 +377,22 @@ class CallbackConverterIntegrationTest extends IntegrationTest with Mockito { AsyncStream(1, 2, 3) } + def readerResponse(request: Request): Reader[Double] = { + Reader.fromSeq(Seq(1.1, 2.2, 3.3)) + } + private def assertOk(response: Response, expectedBody: String): Unit = { response.status should equal(Status.Ok) response.contentString should equal(expectedBody) } private def assertOk(convertedFunc: (Request) => Future[Response], withBody: String): Unit = { - val response = Await.result(convertedFunc(Request())) + val response = await(convertedFunc(Request())) assertOk(response, withBody) } private def assertStatus(convertedFunc: (Request) => Future[Response], expectedStatus: Status): Unit = { - val response = Await.result(convertedFunc(Request())) + val response = await(convertedFunc(Request())) response.status should equal(expectedStatus) } } From 3b93a7d7d0316d49489cf7882d0a424278d66cd7 Mon Sep 17 00:00:00 2001 From: Adam Singer Date: Mon, 4 Feb 2019 22:40:10 +0000 Subject: [PATCH 25/45] finatra-kafka-streams: Properly initialize wall clock time in tests Problem `TopologyTestDriver` creation does not include the starting wall clock time, that presents a problem when using the `advanceWallClockTime`. Wall clock is advanced in `joda` and `TopologyTestDriver` mock timer, but the two values differ at starting time. Solution Pass along `startingWallClockTime` to `TopologyTestDriver` on creation. Along with this patch we are clearing `inMemoryStatsReceiver` on test `reset` Result Expected that wallclock time modified by `TopologyTestDriver#advanceWallClockTime` should match for both system time and `TopologyTestDriver` mock timer. JIRA Issues: DINS-2609 Differential Revision: https://phabricator.twitter.biz/D269013 --- CHANGELOG.rst | 8 ++ .../punctuator/HeartBeatPunctuator.scala | 13 ++ .../punctuator/HeartBeatServer.scala | 44 ++++++ .../HeartBeatServerTopologyFeatureTest.scala | 131 ++++++++++++++++++ .../test/FinatraTopologyTester.scala | 6 +- 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatPunctuator.scala create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServerTopologyFeatureTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac369f1143..5b05672713 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -74,6 +74,14 @@ Changed Fixed ~~~~~ +* finatra-kafka-streams: `FinatraTopologyTester` did not set + `TopologyTestDriver#initialWallClockTimeMs` on initialization causing diverging wall clock time + when `TopologyTestDriver#advanceWallClockTime` advanced time. The divergence was between + system time set by `org.joda.time.DateTimeUtils.setCurrentMillisFixed` and internal mock timer + `TopologyTestDriver#mockWallClockTime`. `FinatraTopologyTester.inMemoryStatsReceiver` is reset on + `TopologyFeatureTest#beforeEach` for all test that extend `TopologyFeatureTest`. + ``PHAB_ID=D269013`` + * finatra-kafka-streams: Improve watermark assignment/propagation upon reading the first message and when caching key value stores are used. ``PHAB_ID=D262054`` diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatPunctuator.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatPunctuator.scala new file mode 100644 index 0000000000..a31310ad68 --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatPunctuator.scala @@ -0,0 +1,13 @@ +package com.twitter.finatra.kafkastreams.integration.punctuator + +import com.twitter.finagle.stats.StatsReceiver +import org.apache.kafka.streams.processor.{ProcessorContext, Punctuator} + +class HeartBeatPunctuator(processorContext: ProcessorContext, statsReceiver: StatsReceiver) extends Punctuator { + def punctuate(timestampMillis: Long): Unit = { + punctuateCounter.incr() + processorContext.forward(timestampMillis, timestampMillis) + } + + private val punctuateCounter = statsReceiver.counter("punctuate") +} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala new file mode 100644 index 0000000000..f94ba849d2 --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala @@ -0,0 +1,44 @@ +package com.twitter.finatra.kafkastreams.integration.punctuator + +import com.twitter.conversions.DurationOps._ +import com.twitter.finatra.kafka.serde.ScalaSerdes +import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer +import org.apache.kafka.streams.StreamsBuilder +import org.apache.kafka.streams.kstream.{Consumed, Produced, Transformer} +import org.apache.kafka.streams.processor.{Cancellable, ProcessorContext, PunctuationType} + +class HeartBeatServer extends KafkaStreamsTwitterServer { + override val name = "heartbeat" + + override protected def configureKafkaStreams(builder: StreamsBuilder): Unit = { + builder.asScala + .stream[Long, Long]("input-topic")(Consumed.`with`(ScalaSerdes.Long, ScalaSerdes.Long)) + .transform( + () => + new Transformer[Long, Long, (Long, Long)] { + private val transformCounter = streamsStatsReceiver.counter("transform") + + private var heartBeatPunctuatorCancellable: Cancellable = _ + + override def close(): Unit = { + if (heartBeatPunctuatorCancellable != null) { + heartBeatPunctuatorCancellable.cancel() + } + } + + override def init(processorContext: ProcessorContext): Unit = { + heartBeatPunctuatorCancellable = processorContext.schedule( + 1.second.inMillis, + PunctuationType.WALL_CLOCK_TIME, + new HeartBeatPunctuator(processorContext, streamsStatsReceiver)) + } + + override def transform(k: Long, v: Long): (Long, Long) = { + transformCounter.incr() + (k, v) + } + } + ) + .to("output-topic")(Produced.`with`(ScalaSerdes.Long, ScalaSerdes.Long)) + } +} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServerTopologyFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServerTopologyFeatureTest.scala new file mode 100644 index 0000000000..d9da9aed72 --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServerTopologyFeatureTest.scala @@ -0,0 +1,131 @@ +package com.twitter.finatra.kafkastreams.integration.punctuator + +import com.twitter.conversions.DurationOps._ +import com.twitter.finatra.kafka.serde.ScalaSerdes +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} +import org.joda.time.DateTime + +class HeartBeatServerTopologyFeatureTest extends TopologyFeatureTest { + private val startingWallClockTime = new DateTime("1970-01-01T00:00:00Z") + + override val topologyTester = FinatraTopologyTester( + kafkaApplicationId = "wordcount-prod-bob", + server = new HeartBeatServer, + startingWallClockTime = startingWallClockTime + ) + + private val inputTopic = + topologyTester.topic("input-topic", ScalaSerdes.Long, ScalaSerdes.Long) + + private val outputTopic = + topologyTester.topic("output-topic", ScalaSerdes.Long, ScalaSerdes.Long) + + private val transformStatName = "kafka/stream/transform" + private val punctuateStatName = "kafka/stream/punctuate" + + test("Publish single value from input to output") { + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 0) + inputTopic.pipeInput(1,1) + outputTopic.assertOutput(1,1) + outputTopic.readAllOutput().isEmpty should be(true) + topologyTester.stats.assertCounter(transformStatName, 1) + topologyTester.stats.assertCounter(punctuateStatName, 0) + } + + test("Publish single value and single heartbeat from input to output") { + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 0) + inputTopic.pipeInput(1,1) + outputTopic.assertOutput(1,1) + topologyTester.advanceWallClockTime(1.seconds) + val punctuatedTime = startingWallClockTime.getMillis + 1.second.inMillis + outputTopic.assertOutput(punctuatedTime, punctuatedTime) + outputTopic.readAllOutput().isEmpty should be(true) + topologyTester.stats.assertCounter(transformStatName, 1) + topologyTester.stats.assertCounter(punctuateStatName, 1) + } + + test("Publish heartbeat from advanced wall clock time to output") { + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 0) + topologyTester.advanceWallClockTime(1.seconds) + val punctuatedTime = startingWallClockTime.getMillis + 1.second.inMillis + outputTopic.assertOutput(punctuatedTime, punctuatedTime) + outputTopic.readAllOutput().isEmpty should be(true) + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 1) + } + + test("Publish multiple values from input to output") { + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 0) + + val messages = 1 to 10 + for { + message <- messages + } { + inputTopic.pipeInput(message, message) + } + + for { + message <- messages + } { + outputTopic.assertOutput(message, message) + } + + outputTopic.readAllOutput().isEmpty should be(true) + topologyTester.stats.assertCounter(transformStatName, 10) + topologyTester.stats.assertCounter(punctuateStatName, 0) + } + + test("Publish multiple heartbeat from advanced wall clock time to output") { + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 0) + + val secondsRange = 1 to 10 + for { + _ <- secondsRange + } { + topologyTester.advanceWallClockTime(1.seconds) + } + + for { + s <- secondsRange + } { + val punctuatedTime = startingWallClockTime.getMillis + s.seconds.inMillis + outputTopic.assertOutput(punctuatedTime, punctuatedTime) + } + + outputTopic.readAllOutput().isEmpty should be(true) + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 10) + } + + test("Publish multiple values and multiple heartbeats from input to output") { + topologyTester.stats.assertCounter(transformStatName, 0) + topologyTester.stats.assertCounter(punctuateStatName, 0) + inputTopic.pipeInput(1,1) + topologyTester.advanceWallClockTime(1.seconds) + inputTopic.pipeInput(2,2) + topologyTester.advanceWallClockTime(1.seconds) + inputTopic.pipeInput(3,3) + topologyTester.advanceWallClockTime(1.seconds) + + outputTopic.assertOutput(1,1) + val punctuatedTime1 = startingWallClockTime.getMillis + 1.second.inMillis + outputTopic.assertOutput(punctuatedTime1, punctuatedTime1) + + outputTopic.assertOutput(2,2) + val punctuatedTime2 = punctuatedTime1 + 1.second.inMillis + outputTopic.assertOutput(punctuatedTime2, punctuatedTime2) + + outputTopic.assertOutput(3,3) + val punctuatedTime3 = punctuatedTime2 + 1.second.inMillis + outputTopic.assertOutput(punctuatedTime3, punctuatedTime3) + + outputTopic.readAllOutput().isEmpty should be(true) + topologyTester.stats.assertCounter(transformStatName, 3) + topologyTester.stats.assertCounter(punctuateStatName, 3) + } +} diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/FinatraTopologyTester.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/FinatraTopologyTester.scala index d99337c4e5..5a10266ce5 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/FinatraTopologyTester.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/test/FinatraTopologyTester.scala @@ -208,9 +208,9 @@ case class FinatraTopologyTester private ( } def reset(): Unit = { + inMemoryStatsReceiver.clear() close() createTopologyTester() - DateTimeUtils.setCurrentMillisFixed(startingWallClockTime.getMillis) } def close(): Unit = { @@ -302,6 +302,8 @@ case class FinatraTopologyTester private ( } private def createTopologyTester(): Unit = { - _driver = new TopologyTestDriver(topology, properties) + DateTimeUtils.setCurrentMillisFixed(startingWallClockTime.getMillis) + debug(s"Creating TopologyTestDriver with wall clock ${DateTimeUtils.currentTimeMillis().iso8601Millis}") + _driver = new TopologyTestDriver(topology, properties, startingWallClockTime.getMillis) } } From ba976e14e50f036c27ff272c348c095609d9e94c Mon Sep 17 00:00:00 2001 From: Nora Howard Date: Tue, 5 Feb 2019 00:36:31 +0000 Subject: [PATCH 26/45] Fix Pants invalidation issue for Scala/Java compile Problem Pants has an issue where java source changes don't invalidate compilation units that include both Scala and Java in some circumstances (see https://github.com/pantsbuild/pants/issues/7200). Solution This patch works around that issue by adding a second target that owns the same sources and making it a dependency of the scala_library. This causes the Java files contents to affect the scala_library's compile cache key, invalidating it, and does it in a way that doesn't introduce a cycle in the target dependency graph. Result Now, when changes are made to Java sources, subsequent Pants compiles will also recompile the associated Scala sources against the Java changes. JIRA Issues: DPB-10793 Differential Revision: https://phabricator.twitter.biz/D269076 --- inject/inject-app/src/main/java/BUILD | 7 +++++++ inject/inject-app/src/main/scala/BUILD | 1 + inject/inject-core/src/test/java/BUILD | 7 +++++++ inject/inject-core/src/test/scala/BUILD | 1 + inject/inject-request-scope/src/main/java/BUILD | 7 +++++++ inject/inject-request-scope/src/main/scala/BUILD | 1 + inject/inject-thrift-client/src/main/java/BUILD | 7 +++++++ inject/inject-thrift-client/src/main/scala/BUILD | 1 + jackson/src/main/java/BUILD | 7 +++++++ jackson/src/main/scala/BUILD | 1 + .../src/main/java/BUILD | 7 +++++++ .../src/main/scala/BUILD | 1 + utils/src/main/java/BUILD | 7 +++++++ 13 files changed, 55 insertions(+) diff --git a/inject/inject-app/src/main/java/BUILD b/inject/inject-app/src/main/java/BUILD index 02c5043ee5..b4a4dbe850 100644 --- a/inject/inject-app/src/main/java/BUILD +++ b/inject/inject-app/src/main/java/BUILD @@ -11,3 +11,10 @@ java_library( "3rdparty/jvm/com/google/inject:guice", ], ) + +# TODO: Remove this and references to it, +# when a fix for https://github.com/pantsbuild/pants/issues/7200 has landed. +files( + name = "pants-workaround", + sources = rglobs("*.java"), +) diff --git a/inject/inject-app/src/main/scala/BUILD b/inject/inject-app/src/main/scala/BUILD index 5d226540c7..98bd34cfa6 100644 --- a/inject/inject-app/src/main/scala/BUILD +++ b/inject/inject-app/src/main/scala/BUILD @@ -22,6 +22,7 @@ scala_library( "3rdparty/jvm/org/slf4j:jul-to-slf4j", "3rdparty/jvm/org/slf4j:log4j-over-slf4j", "3rdparty/jvm/org/slf4j:slf4j-api", + "finatra/inject/inject-app/src/main/java:pants-workaround", "finatra/inject/inject-core/src/main/scala", "finatra/inject/inject-slf4j/src/main/scala", "util/util-app/src/main/scala", diff --git a/inject/inject-core/src/test/java/BUILD b/inject/inject-core/src/test/java/BUILD index 696e8ff3f8..7061434bc8 100644 --- a/inject/inject-core/src/test/java/BUILD +++ b/inject/inject-core/src/test/java/BUILD @@ -5,3 +5,10 @@ java_library( "3rdparty/jvm/com/google/inject:guice", ], ) + +# TODO: Remove this and references to it, +# when a fix for https://github.com/pantsbuild/pants/issues/7200 has landed. +files( + name = "pants-workaround", + sources = rglobs("*.java"), +) diff --git a/inject/inject-core/src/test/scala/BUILD b/inject/inject-core/src/test/scala/BUILD index 5a21658624..5d2a47a275 100644 --- a/inject/inject-core/src/test/scala/BUILD +++ b/inject/inject-core/src/test/scala/BUILD @@ -52,6 +52,7 @@ scala_library( "3rdparty/jvm/org/slf4j:slf4j-api", "3rdparty/jvm/org/specs2:mock", "finatra/inject/inject-core/src/main/scala", + "finatra/inject/inject-core/src/test/java:pants-workaround", "finatra/inject/inject-slf4j/src/main/scala", "util/util-core/src/main/scala", "util/util-slf4j-api/src/main/scala", diff --git a/inject/inject-request-scope/src/main/java/BUILD b/inject/inject-request-scope/src/main/java/BUILD index 5413211c51..2b40551b24 100644 --- a/inject/inject-request-scope/src/main/java/BUILD +++ b/inject/inject-request-scope/src/main/java/BUILD @@ -10,3 +10,10 @@ java_library( "3rdparty/jvm/com/google/inject:guice", ], ) + +# TODO: Remove this and references to it, +# when a fix for https://github.com/pantsbuild/pants/issues/7200 has landed. +files( + name = "pants-workaround", + sources = rglobs("*.java"), +) diff --git a/inject/inject-request-scope/src/main/scala/BUILD b/inject/inject-request-scope/src/main/scala/BUILD index 0177fdab2b..96e8d941c1 100644 --- a/inject/inject-request-scope/src/main/scala/BUILD +++ b/inject/inject-request-scope/src/main/scala/BUILD @@ -18,6 +18,7 @@ scala_library( "3rdparty/jvm/org/slf4j:slf4j-api", "finagle/finagle-core/src/main/scala", "finatra/inject/inject-core/src/main/scala", + "finatra/inject/inject-request-scope/src/main/java:pants-workaround", "finatra/inject/inject-slf4j/src/main/scala", "util/util-app/src/main/scala", "util/util-core/src/main/scala", diff --git a/inject/inject-thrift-client/src/main/java/BUILD b/inject/inject-thrift-client/src/main/java/BUILD index ab42cd3967..d69eb3ee08 100644 --- a/inject/inject-thrift-client/src/main/java/BUILD +++ b/inject/inject-thrift-client/src/main/java/BUILD @@ -10,3 +10,10 @@ java_library( "3rdparty/jvm/com/google/inject:guice", ], ) + +# TODO: Remove this and references to it, +# when a fix for https://github.com/pantsbuild/pants/issues/7200 has landed. +files( + name = "pants-workaround", + sources = rglobs("*.java"), +) diff --git a/inject/inject-thrift-client/src/main/scala/BUILD b/inject/inject-thrift-client/src/main/scala/BUILD index e0880ee409..f7d1270e22 100644 --- a/inject/inject-thrift-client/src/main/scala/BUILD +++ b/inject/inject-thrift-client/src/main/scala/BUILD @@ -25,6 +25,7 @@ scala_library( "finatra/inject/inject-app/src/main/scala", "finatra/inject/inject-core/src/main/scala", "finatra/inject/inject-slf4j/src/main/scala", + "finatra/inject/inject-thrift-client/src/main/java:pants-workaround", "finatra/inject/inject-thrift/src/main/scala", "finatra/inject/inject-utils/src/main/scala", "scrooge/scrooge-core/src/main/scala", diff --git a/jackson/src/main/java/BUILD b/jackson/src/main/java/BUILD index 7f74ff7855..d283a8ec7e 100644 --- a/jackson/src/main/java/BUILD +++ b/jackson/src/main/java/BUILD @@ -10,3 +10,10 @@ java_library( "finatra/jackson/src/main/scala", ], ) + +# TODO: Remove this and references to it, +# when a fix for https://github.com/pantsbuild/pants/issues/7200 has landed. +files( + name = "pants-workaround", + sources = rglobs("*.java"), +) diff --git a/jackson/src/main/scala/BUILD b/jackson/src/main/scala/BUILD index c0e5e9a8ac..9b31a133cd 100644 --- a/jackson/src/main/scala/BUILD +++ b/jackson/src/main/scala/BUILD @@ -33,6 +33,7 @@ scala_library( "finatra/inject/inject-core/src/main/scala", "finatra/inject/inject-slf4j/src/main/scala", "finatra/inject/inject-utils/src/main/scala", + "finatra/jackson/src/main/java:pants-workaround", "finatra/jackson/src/main/resources", "util/util-app/src/main/scala", "util/util-core/src/main/scala", diff --git a/kafka-streams/kafka-streams-queryable-thrift-client/src/main/java/BUILD b/kafka-streams/kafka-streams-queryable-thrift-client/src/main/java/BUILD index 2f44d8bcd7..ef1977867f 100644 --- a/kafka-streams/kafka-streams-queryable-thrift-client/src/main/java/BUILD +++ b/kafka-streams/kafka-streams-queryable-thrift-client/src/main/java/BUILD @@ -6,3 +6,10 @@ java_library( exports = [ ], ) + +# TODO: Remove this and references to it, +# when a fix for https://github.com/pantsbuild/pants/issues/7200 has landed. +files( + name = "pants-workaround", + sources = rglobs("*.java"), +) diff --git a/kafka-streams/kafka-streams-queryable-thrift-client/src/main/scala/BUILD b/kafka-streams/kafka-streams-queryable-thrift-client/src/main/scala/BUILD index c9e26577fa..edf9ef5564 100644 --- a/kafka-streams/kafka-streams-queryable-thrift-client/src/main/scala/BUILD +++ b/kafka-streams/kafka-streams-queryable-thrift-client/src/main/scala/BUILD @@ -14,6 +14,7 @@ scala_library( "3rdparty/jvm/com/twitter/bijection:core", "finagle/finagle-serversets", "finatra/inject/inject-thrift-client", + "finatra/kafka-streams/kafka-streams-queryable-thrift-client/src/main/java:pants-workaround", ], exports = [ "3rdparty/jvm/com/twitter/bijection:core", diff --git a/utils/src/main/java/BUILD b/utils/src/main/java/BUILD index 4888fd8297..03bae23416 100644 --- a/utils/src/main/java/BUILD +++ b/utils/src/main/java/BUILD @@ -13,3 +13,10 @@ java_library( "3rdparty/jvm/com/google/inject:guice", ], ) + +# TODO: Remove this and references to it, +# when a fix for https://github.com/pantsbuild/pants/issues/7200 has landed. +files( + name = "pants-workaround", + sources = rglobs("*.java"), +) From cdcbaa61ce42ae60159851b6d3ec422ed31544fa Mon Sep 17 00:00:00 2001 From: Yufan Gong Date: Tue, 5 Feb 2019 18:54:11 +0000 Subject: [PATCH 27/45] finatra-kafka-streams: Lift value for KStream#transform method Problem sbt complains about a type mismatch. Solution Lift the value and explicit the type. Differential Revision: https://phabricator.twitter.biz/D269592 --- .../punctuator/HeartBeatServer.scala | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala index f94ba849d2..21d69050aa 100644 --- a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/punctuator/HeartBeatServer.scala @@ -10,35 +10,35 @@ import org.apache.kafka.streams.processor.{Cancellable, ProcessorContext, Punctu class HeartBeatServer extends KafkaStreamsTwitterServer { override val name = "heartbeat" + private val transformerSupplier: () => Transformer[Long, Long, (Long, Long)] = () => + new Transformer[Long, Long, (Long, Long)] { + private val transformCounter = streamsStatsReceiver.counter("transform") + + private var heartBeatPunctuatorCancellable: Cancellable = _ + + override def close(): Unit = { + if (heartBeatPunctuatorCancellable != null) { + heartBeatPunctuatorCancellable.cancel() + } + } + + override def init(processorContext: ProcessorContext): Unit = { + heartBeatPunctuatorCancellable = processorContext.schedule( + 1.second.inMillis, + PunctuationType.WALL_CLOCK_TIME, + new HeartBeatPunctuator(processorContext, streamsStatsReceiver)) + } + + override def transform(k: Long, v: Long): (Long, Long) = { + transformCounter.incr() + (k, v) + } + } + override protected def configureKafkaStreams(builder: StreamsBuilder): Unit = { builder.asScala .stream[Long, Long]("input-topic")(Consumed.`with`(ScalaSerdes.Long, ScalaSerdes.Long)) - .transform( - () => - new Transformer[Long, Long, (Long, Long)] { - private val transformCounter = streamsStatsReceiver.counter("transform") - - private var heartBeatPunctuatorCancellable: Cancellable = _ - - override def close(): Unit = { - if (heartBeatPunctuatorCancellable != null) { - heartBeatPunctuatorCancellable.cancel() - } - } - - override def init(processorContext: ProcessorContext): Unit = { - heartBeatPunctuatorCancellable = processorContext.schedule( - 1.second.inMillis, - PunctuationType.WALL_CLOCK_TIME, - new HeartBeatPunctuator(processorContext, streamsStatsReceiver)) - } - - override def transform(k: Long, v: Long): (Long, Long) = { - transformCounter.incr() - (k, v) - } - } - ) + .transform[Long, Long](transformerSupplier) .to("output-topic")(Produced.`with`(ScalaSerdes.Long, ScalaSerdes.Long)) } } From ff1dbb5a1faa2b67de0c86e586ce4bcbef19b649 Mon Sep 17 00:00:00 2001 From: Ian Bennett Date: Thu, 7 Feb 2019 02:01:58 +0000 Subject: [PATCH 28/45] finatra-http,finatra-thrift: DRY ExternalHttpClient close and fix typos/default args in EmbeddedXServer Problem/Solution Fix typo regarding the `useSocksProxy` param and reduce code duplication in the `closeOnExit` logic of the `ExternalHttpClient`. JIRA Issues: CSL-7588 Differential Revision: https://phabricator.twitter.biz/D269654 --- .../finatra/http/EmbeddedHttpServer.scala | 2 +- .../finatra/http/ExternalHttpClient.scala | 27 ++++++++----------- .../finatra/thrift/EmbeddedThriftServer.scala | 2 +- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/http/src/test/scala/com/twitter/finatra/http/EmbeddedHttpServer.scala b/http/src/test/scala/com/twitter/finatra/http/EmbeddedHttpServer.scala index cad431fad6..b8e4101687 100644 --- a/http/src/test/scala/com/twitter/finatra/http/EmbeddedHttpServer.scala +++ b/http/src/test/scala/com/twitter/finatra/http/EmbeddedHttpServer.scala @@ -24,7 +24,7 @@ import scala.collection.JavaConverters._ * we default to Stage.DEVELOPMENT. This makes it possible to only mock objects that are used in a given test, * at the expense of not checking that the entire object graph is valid. As such, you should always have at * least one Stage.PRODUCTION test for your service (which eagerly creates all classes at startup) - * @param useSocksProxy Use a tunneled socks proxy for external service discovery/calls (useful for manually run external + * @param useSocksProxy Use a tunneled socks proxy for external service discovery/calls (useful for manually running external * integration tests that connect to external services). * @param defaultRequestHeaders Headers to always send to the embedded server. * @param defaultHttpSecure Default all requests to the server to be HTTPS. diff --git a/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala b/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala index 692d33203d..1bd22a0beb 100644 --- a/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala +++ b/http/src/test/scala/com/twitter/finatra/http/ExternalHttpClient.scala @@ -111,14 +111,7 @@ private[twitter] trait ExternalHttpClient { self: EmbeddedTwitterServer => mapper, disableLogging = self.disableLogging ) - closeOnExit { - if (isStarted) { - Closable.make { deadline => - info(s"Closing embedded http client: ${client.label}", disableLogging) - client.close(deadline) - } - } else Closable.nop - } + closeOnExit(client) client } @@ -133,14 +126,16 @@ private[twitter] trait ExternalHttpClient { self: EmbeddedTwitterServer => mapper, disableLogging = self.disableLogging ) - closeOnExit { - if (isStarted) { - Closable.make { deadline => - info(s"Closing embedded http client: ${client.label}", disableLogging) - client.close(deadline) - } - } else Closable.nop - } + closeOnExit(client) client } + + final def closeOnExit(client: JsonAwareEmbeddedHttpClient): Unit = closeOnExit { + if (isStarted) { + Closable.make { deadline => + info(s"Closing embedded http client: ${client.label}", disableLogging) + client.close(deadline) + } + } else Closable.nop + } } diff --git a/thrift/src/test/scala/com/twitter/finatra/thrift/EmbeddedThriftServer.scala b/thrift/src/test/scala/com/twitter/finatra/thrift/EmbeddedThriftServer.scala index ea5f700d47..d788b97d56 100644 --- a/thrift/src/test/scala/com/twitter/finatra/thrift/EmbeddedThriftServer.scala +++ b/thrift/src/test/scala/com/twitter/finatra/thrift/EmbeddedThriftServer.scala @@ -18,7 +18,7 @@ import scala.collection.JavaConverters._ * we default to Stage.DEVELOPMENT. This makes it possible to only mock objects that are used in a given test, * at the expense of not checking that the entire object graph is valid. As such, you should always have at * least one Stage.PRODUCTION test for your service (which eagerly creates all classes at startup). - * @param useSocksProxy Use a tunneled socks proxy for external service discovery/calls (useful for manually run external + * @param useSocksProxy Use a tunneled socks proxy for external service discovery/calls (useful for manually running external * integration tests that connect to external services). * @param thriftPortFlag Name of the flag that defines the external thrift port for the server. * @param verbose Enable verbose logging during test runs. From 15c5bc01c12cacd4542139a72102b11a35172514 Mon Sep 17 00:00:00 2001 From: Shane Delmore Date: Thu, 7 Feb 2019 23:41:07 +0000 Subject: [PATCH 29/45] finatra: Remove Existentials Imports in Favor of Compiler Flags Problem / Solution The scala.language.existentials import is not needed in 2.12 and existentials can be used via the '-language:existentials' compiler flag in 2.11. Let's remove the imports and depend on the compiler flag instead. Differential Revision: https://phabricator.twitter.biz/D270118 --- .../scala/com/twitter/finatra/http/internal/routing/Route.scala | 1 - .../internal/caseclass/exceptions/JsonInjectException.scala | 1 - .../exceptions/JsonInjectionNotSupportedException.scala | 2 -- .../json/internal/caseclass/jackson/CaseClassField.scala | 1 - 4 files changed, 5 deletions(-) diff --git a/http/src/main/scala/com/twitter/finatra/http/internal/routing/Route.scala b/http/src/main/scala/com/twitter/finatra/http/internal/routing/Route.scala index c9a2d09cdb..cdbc3806b6 100644 --- a/http/src/main/scala/com/twitter/finatra/http/internal/routing/Route.scala +++ b/http/src/main/scala/com/twitter/finatra/http/internal/routing/Route.scala @@ -7,7 +7,6 @@ import com.twitter.finatra.http.internal.request.RequestWithRouteParams import com.twitter.finatra.http.internal.routing.Route._ import com.twitter.util.Future import java.lang.annotation.Annotation -import scala.language.existentials import scala.reflect.ClassTag private[http] object Route { diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectException.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectException.scala index d15da84e87..862f6363c9 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectException.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectException.scala @@ -1,7 +1,6 @@ package com.twitter.finatra.json.internal.caseclass.exceptions import com.google.inject.Key -import scala.language.existentials import scala.util.control.NoStackTrace case class JsonInjectException( diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectionNotSupportedException.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectionNotSupportedException.scala index 30bc90fe8d..17d61c0574 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectionNotSupportedException.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/exceptions/JsonInjectionNotSupportedException.scala @@ -1,7 +1,5 @@ package com.twitter.finatra.json.internal.caseclass.exceptions -import scala.language.existentials - case class JsonInjectionNotSupportedException(parentClass: Class[_], fieldName: String) extends Exception( "Injection of fields (e.g. @Inject, @QueryParam, @Header) not " + diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala index fb4a87c6b8..526f753873 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/caseclass/jackson/CaseClassField.scala @@ -25,7 +25,6 @@ import com.twitter.inject.Logging import com.twitter.inject.conversions.string._ import java.lang.annotation.Annotation import scala.annotation.tailrec -import scala.language.existentials import scala.reflect.NameTransformer private[finatra] object CaseClassField { From 495998d52281bca3350b736455632f6965e0939d Mon Sep 17 00:00:00 2001 From: Moses Nakamura Date: Fri, 8 Feb 2019 20:05:22 +0000 Subject: [PATCH 30/45] util-collection: Delete it Problem util-collection does not have very much stuff in it anymore, and of those things they don't seem to be very valuable. Solution Let's delete it. Result Never have to worry about util-collection ever again. JIRA Issues: CSL-6493 Differential Revision: https://phabricator.twitter.biz/D270548 --- http/src/main/scala/BUILD | 1 - 1 file changed, 1 deletion(-) diff --git a/http/src/main/scala/BUILD b/http/src/main/scala/BUILD index 246d9ce2d8..933d524076 100644 --- a/http/src/main/scala/BUILD +++ b/http/src/main/scala/BUILD @@ -47,7 +47,6 @@ scala_library( "finatra/utils/src/main/scala", "twitter-server/server/src/main/scala", "util/util-app/src/main/scala", - "util/util-collection/src/main/scala", "util/util-core/src/main/scala", "util/util-lint/src/main/scala", "util/util-logging/src/main/scala", From df29f6aeab3bce3d26cbc6a8a320c5f52fd8d32a Mon Sep 17 00:00:00 2001 From: Yufan Gong Date: Fri, 8 Feb 2019 21:49:24 +0000 Subject: [PATCH 31/45] finatra-jackson: Replace JsonArrayChunker to AsyncJsonParser Problem We are now on Jackson 2.9.x which supports for async parsing. Solution Adopt Jackson JSON Parser and integrate it with Util Reader, replace the old JsonArrayChunker. JIRA Issues: CSL-7151 Differential Revision: https://phabricator.twitter.biz/D260653 --- .../internal/streaming/AsyncJsonParser.scala | 128 +++++++++ .../internal/streaming/JsonArrayChunker.scala | 107 ------- .../internal/streaming/JsonStreamParser.scala | 16 +- .../internal/streaming/ParsingState.scala | 13 - .../streaming/AsyncJsonParserTest.scala | 270 ++++++++++++++++++ .../streaming/JsonObjectDecoderTest.scala | 101 ------- .../internal/streaming/StreamingTest.scala | 178 ++++++++++-- 7 files changed, 562 insertions(+), 251 deletions(-) create mode 100644 jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/AsyncJsonParser.scala delete mode 100644 jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonArrayChunker.scala delete mode 100644 jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/ParsingState.scala create mode 100644 jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/AsyncJsonParserTest.scala delete mode 100644 jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/JsonObjectDecoderTest.scala diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/AsyncJsonParser.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/AsyncJsonParser.scala new file mode 100644 index 0000000000..bc89653d36 --- /dev/null +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/AsyncJsonParser.scala @@ -0,0 +1,128 @@ +package com.twitter.finatra.json.internal.streaming + +import com.fasterxml.jackson.core.async.ByteArrayFeeder +import com.fasterxml.jackson.core.json.async.NonBlockingJsonParser +import com.fasterxml.jackson.core.{JsonFactory, JsonParser, JsonToken} +import com.twitter.io.Buf +import java.nio.ByteBuffer +import scala.collection.mutable.ListBuffer + +/** + * Integrate Jackson JSON Parser to parse JSON using non-blocking input source. + * + * Jackson JsonParser would validate the input JSON and throw + * [[com.fasterxml.jackson.core.JsonParseException]] if unexpected token occurs. + * Finatra AsyncJsonParser would decide where to split up the input Buf, return whole + * serialized JSON objects and keep the unfinished bytes in the ByteBuffer. + */ +private[finatra] class AsyncJsonParser { + private[this] val parser: JsonParser = new JsonFactory().createNonBlockingByteArrayParser() + private[this] val feeder: ByteArrayFeeder = + parser.asInstanceOf[NonBlockingJsonParser].getNonBlockingInputFeeder() + + // All mutable state is synchronized on `this` + + // we can only parse the whole object/array/primitive type, + // and keep unfinished bytes in this buffer. + private[this] var remaining = ByteBuffer.allocate(0) + // keep track the object's starting position in remaining byteBuffer + private[this] var position: Int = 0 + // offset that records the length of parsed bytes in parser, accumulated every chunk + private[this] var offset: Int = 0 + // offset of a sliced buf, this offset should not be accumulated + private[this] var slicedBufOffset = 0 + private[this] var depth: Int = 0 + + /** + * Parse the Buf to slice it into a List of objects/arrays/primitive types, + * keep the unparsed part and wait for the next feed + */ + private[finatra] def feedAndParse(buf: Buf): Seq[Buf] = synchronized { + assertState() + + buf match { + case Buf.ByteArray.Owned((bytes, begin, end)) => + feeder.feedInput(bytes, begin, end) + slicedBufOffset = begin + case Buf.ByteArray.Shared(bytes) => + feeder.feedInput(bytes, 0, bytes.length) + case b => + val bytes = new Array[Byte](b.length) + b.write(bytes, 0) + feeder.feedInput(bytes, 0, bytes.length) + } + + remaining = ByteBufferUtils.append(remaining, buf, position) + val result: ListBuffer[Buf] = ListBuffer.empty + + while (parser.nextToken() != JsonToken.NOT_AVAILABLE) { + updateOpenBrace() + if (startInitialArray) { + // exclude the initial `[` + position = parser.getCurrentLocation.getByteOffset.toInt - slicedBufOffset - offset + } else if (startObjectArray) { + // include the starting token of the object or array + position = parser.getCurrentLocation.getByteOffset.toInt - slicedBufOffset - offset - 1 + } else if (endObjectArray) { + result += getSlicedBuf() + } else if (endInitialArray) { + depth = -1 + } else if (depth == 1) { + result += getSlicedBuf() + } else { + // fall through expected; a valid JsonToken, such as: FIELD_NAME, VALUE_NUMBER_INT, etc. + } + } + + result + } + + private def getSlicedBuf(): Buf = { + + remaining.position(position) + val newPosition = parser.getCurrentLocation.getByteOffset.toInt - slicedBufOffset - offset + val buf = remaining.slice() + buf.limit(newPosition - position) + + remaining.position(newPosition) + remaining = remaining.slice() + + offset = offset + newPosition + position = 1 + + Buf.ByteBuffer.Shared(buf) + } + + private def startInitialArray: Boolean = + parser.currentToken() == JsonToken.START_ARRAY && depth == 1 + + private def startObjectArray: Boolean = + depth == 2 && (parser.currentToken() == JsonToken.START_ARRAY || + parser.currentToken() == JsonToken.START_OBJECT) + + private def endObjectArray: Boolean = + depth == 1 && (parser.currentToken() == JsonToken.END_ARRAY || + parser.currentToken() == JsonToken.END_OBJECT) + + private def updateOpenBrace(): Unit = { + if (parser.currentToken() == JsonToken.START_ARRAY || + parser.currentToken() == JsonToken.START_OBJECT) + depth += 1 + else if (parser.currentToken() == JsonToken.END_ARRAY || + parser.currentToken() == JsonToken.END_OBJECT) + depth -= 1 + } + + private def endInitialArray: Boolean = + depth == 0 && parser.currentToken() == JsonToken.END_ARRAY + + private def assertState(): Unit = { + if (depth == -1) { + throw new IllegalStateException("End of the JSON object (`]`) already found") + } + } + + // expose for tests + private[json] def copiedByteBuffer: ByteBuffer = synchronized { remaining.duplicate() } + private[json] def getParsingDepth: Int = synchronized { depth } +} diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonArrayChunker.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonArrayChunker.scala deleted file mode 100644 index c02fc5bd79..0000000000 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonArrayChunker.scala +++ /dev/null @@ -1,107 +0,0 @@ -package com.twitter.finatra.json.internal.streaming - -import com.twitter.finatra.json.internal.streaming.ParsingState._ -import com.twitter.inject.Logging -import com.twitter.inject.conversions.buf._ -import com.twitter.io.Buf -import java.nio.ByteBuffer -import scala.collection.mutable.ArrayBuffer - -// General logic copied from: -// https://github.com/netty/netty/blob/master/codec/src/main/java/io/netty/handler/codec/json/JsonObjectDecoder.java -private[finatra] class JsonArrayChunker extends Logging { - - private[finatra] var parsingState: ParsingState = Normal - private[finatra] var done = false - private[finatra] var openBraces = 0 - private[finatra] var position = 0 - private var byteBuffer = ByteBuffer.allocate(0) - - /* Public */ - - def decode(inputBuf: Buf): Seq[Buf] = { - assertDecode(inputBuf) - byteBuffer = ByteBufferUtils.append(byteBuffer, inputBuf, position) - - val result = ArrayBuffer[Buf]() - - while (byteBuffer.hasRemaining) { - ByteBufferUtils.debugBuffer(byteBuffer) - val currByte = byteBuffer.get - position = byteBuffer.position - - if (!arrayFound && currByte == '[' && openBraces == 0) { - debug("ArrayFound. Openbraces = 1") - parsingState = InsideArray - openBraces += 1 - byteBuffer = byteBuffer.slice() - position = 0 - } else if (!arrayFound && Character.isWhitespace(currByte.toChar)) { - debug("Skip space") - } else { - decodeByteAndUpdateState(currByte, byteBuffer) - if (!insideString && (openBraces == 1 && currByte == ',' || openBraces == 0 && currByte == ']')) { - result += extractBuf() - - if (currByte == ']') { - debug("Done") - done = true - } - } - } - } - - result - } - - /* Private */ - - private def decodeByteAndUpdateState(c: Byte, in: ByteBuffer): Unit = { - debug("decode '" + c.toChar + "'") - if ((c == '{' || c == '[') && !insideString) { - openBraces += 1 - debug("openBraces = " + openBraces) - } else if ((c == '}' || c == ']') && !insideString) { - openBraces -= 1 - debug("openBraces = " + openBraces) - } else if (c == '"') { - // start of a new JSON string. It's necessary to detect strings as they may - // also contain braces/brackets and that could lead to incorrect results. - if (!insideString) { - debug("State = InsideString") - parsingState = InsideString - } - // If the double quote wasn't escaped then this is the end of a string. - else if (in.get(in.position() - 2) != '\\') { - debug("State = Parsing") - parsingState = Normal - } - } - } - - //TODO: Optimize - private def extractBuf(): Buf = { - val copy = byteBuffer.duplicate() - copy.position(0) - copy.limit(byteBuffer.position() - 1) - val copyBuf = Buf.ByteBuffer.Shared(copy) - - byteBuffer = byteBuffer.slice() - position = 0 - debug("Extract result " + copyBuf.utf8str) - copyBuf - } - - private def assertDecode(inputBuf: Buf): Unit = { - debug("Decode called with \"" + inputBuf.utf8str + "\"") - if (done) { - throw new scala.Exception("End array already found") - } - } - - private def insideString = parsingState == InsideString - - private def arrayFound = parsingState == InsideArray - - private[finatra] def copiedByteBuffer = byteBuffer.duplicate() -} diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonStreamParser.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonStreamParser.scala index bba1e461f8..f527254dda 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonStreamParser.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/JsonStreamParser.scala @@ -14,11 +14,15 @@ private[finatra] class JsonStreamParser @Inject()(mapper: FinatraObjectMapper) { } def parseArray[T: Manifest](bufs: AsyncStream[Buf]): AsyncStream[T] = { - val jsonDecoder = new JsonArrayChunker() - for { - buf <- bufs - jsonArrayDelimitedBuf <- AsyncStream.fromSeq(jsonDecoder.decode(buf)) - parsedElem = mapper.parse[T](jsonArrayDelimitedBuf) - } yield parsedElem + Reader.toAsyncStream(parseJson(Reader.fromAsyncStream(bufs))) + } + + def parseJson[T: Manifest](reader: Reader[Buf]): Reader[T] = { + val asyncJsonParser = new AsyncJsonParser + reader.flatMap { buf => + val bufs: Seq[Buf] = asyncJsonParser.feedAndParse(buf) + val values: Seq[T] = bufs.map(mapper.parse[T]) + Reader.fromSeq(values) + } } } diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/ParsingState.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/ParsingState.scala deleted file mode 100644 index ea2a7f6462..0000000000 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/streaming/ParsingState.scala +++ /dev/null @@ -1,13 +0,0 @@ -package com.twitter.finatra.json.internal.streaming - -private[finatra] sealed trait ParsingState - -private[finatra] object ParsingState { - - case object Normal extends ParsingState - - case object InsideString extends ParsingState - - case object InsideArray extends ParsingState - -} \ No newline at end of file diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/AsyncJsonParserTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/AsyncJsonParserTest.scala new file mode 100644 index 0000000000..1ace3e41f9 --- /dev/null +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/AsyncJsonParserTest.scala @@ -0,0 +1,270 @@ +package com.twitter.finatra.json.tests.internal.streaming + +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.databind.JsonNode +import com.twitter.finatra.json.{FinatraObjectMapper, JsonDiff} +import com.twitter.finatra.json.internal.streaming.AsyncJsonParser +import com.twitter.inject.Test +import com.twitter.io.Buf +import com.twitter.inject.conversions.buf._ +import org.scalacheck.{Gen, Shrink} +import org.scalatest.prop.GeneratorDrivenPropertyChecks + +class AsyncJsonParserTest extends Test with GeneratorDrivenPropertyChecks { + + test("decode") { + val parser = new AsyncJsonParser() + + assertParsing( + parser, + input = "[1", + output = Seq(), + remainder = "[1", + pos = 0, + depth = 1 + ) + + assertParsing( + parser, + input = ",2", + output = Seq("1"), + remainder = ",2", + pos = 0, + depth = 1 + ) + + assertParsing( + parser, + input = ",3", + output = Seq("2"), + remainder = ",3", + pos = 0, + depth = 1 + ) + + assertParsing( + parser, + input = "]", + output = Seq("3"), + remainder = "]", + pos = 0, + depth = -1 + ) + } + + test("decode with empty json") { + val jsonObj = "" + assertJsonParse(jsonObj, 0) + } + + test("decode with nested arrays") { + val jsonObj = """[1, 2, 3]""" + assertJsonParse(jsonObj, 1) + } + + test("decode with nested objects") { + val jsonObj = """ + { + "sub_object": { + "msg": "hi" + } + } + """ + + assertJsonParse(jsonObj, 1) + } + + test("decode with nested objects in array") { + val jsonObj = """ + { + "sub_object1": { + "nested_item": { + "msg": "hi" + } + } + }, + {"sub_object2": { + "msg": "hi" + } + } + """ + assertJsonParse(jsonObj, 2) + } + + test("invalid json field name") { + val jsonObj = """ + { + "sub_object1": { + {"this" : "shouldnot}: "work" + } + }, + {"sub_object2": { + "msg": "hi" + } + } + """ + intercept[JsonParseException] { + assertJsonParse(jsonObj, 2) + } + } + + test("miss use of curlies and squares ") { + val jsonObj = """ + [ + { + "msg": "hi" + ], + } + """ + intercept[JsonParseException] { + assertJsonParse(jsonObj, 1) + } + } + + test("decode json inside a string") { + val jsonObj = """{"foo": "bar"}""" + assertJsonParse(jsonObj, 1) + } + + test("Calling decode when already finished") { + val parser = new AsyncJsonParser() + parser.feedAndParse(Buf.Utf8("[]")) + intercept[Exception] { + parser.feedAndParse(Buf.Utf8("{}")) + } + } + + test("other Buf types") { + val parser = new AsyncJsonParser() + val jsonObj = """{"foo": "bar"}""" + val result = parser.feedAndParse( + Buf.ByteBuffer.Owned(java.nio.ByteBuffer + .wrap("[".getBytes("UTF-8") ++ jsonObj.getBytes("UTF-8") ++ "]".getBytes("UTF-8")))) + val mapper = FinatraObjectMapper.create() + val nodes = result.map(mapper.parse[JsonNode]) + nodes.size should be(1) + JsonDiff.jsonDiff(nodes.head, jsonObj) + } + + test("decode json split up over multiple chunks") { + + implicit val noShrink: Shrink[Seq[Buf]] = Shrink.shrinkAny + + forAll(genJsonObj) { json => + forAll(chunk(Buf.Utf8(json))) { bufs => + testChunks(json, bufs) + } + } + } + + private def genJsonObj: Gen[String] = { + implicit val noShrink: Shrink[Seq[Buf]] = Shrink.shrinkAny + val jsonObjs = """ + [ + { + "id": "John" + }, + { + "id": "Tom" + }, + { + "id": "An" + }, + { + "id": "Jean" + } + ]""" + + val nestedObjs = """ + [ + { + "sub_object1": { + "nested_item": { + "msg": "hi" + } + } + }, + {"sub_object2": { + "msg": "hi" + } + } + ]""" + + val nestedLists = """ + [ + [1, 2, 3], + [4, 5, 6], + [7, 8, [1, 2, 3]] + ]""" + + val listAndObjs = """ + [ + { + "id": "John" + }, + { + "id": "Tom" + }, + [1,2,3,4], + { + "id": "An" + } + ]""" + + Gen.oneOf(Seq(jsonObjs, nestedObjs, nestedLists, listAndObjs)) + } + + private def chunk(buf: Buf): Gen[Seq[Buf]] = + if (buf.isEmpty) Gen.const(Seq.empty[Buf]) + else { + Gen.choose(1, buf.length).flatMap { n => + val taken = math.min(buf.length, n) + val a = buf.slice(0, taken) + val b = buf.slice(taken, buf.length) + + chunk(b).map(rest => a +: rest) + } + } + + private def testChunks(jsonObj: String, chunks: Seq[Buf]): Unit = { + val mapper = FinatraObjectMapper.create() + val parser = new AsyncJsonParser() + + val nodes: Seq[JsonNode] = chunks.flatMap { buf => + val bufs: Seq[Buf] = parser.feedAndParse(buf) + bufs.map(mapper.parse[JsonNode]) + } + JsonDiff.jsonDiff(nodes, jsonObj) + } + + private def assertParsing( + parser: AsyncJsonParser, + input: String, + output: Seq[String], + remainder: String, + pos: Int, + depth: Int = 0 + ): Unit = { + + val result: Seq[Buf] = parser.feedAndParse(Buf.Utf8(input)) + result.map { _.utf8str } should equal(output) + + val copiedByteBuffer = parser.copiedByteBuffer.duplicate() + copiedByteBuffer.position(0) + val recvBuf = Buf.ByteBuffer.Shared(copiedByteBuffer) + + recvBuf.utf8str should equal(remainder) + parser.copiedByteBuffer.position() should equal(pos) + parser.getParsingDepth should equal(depth) + } + + private def assertJsonParse(jsonObj: String, size: Int): Unit = { + val mapper = FinatraObjectMapper.create() + + val parser = new AsyncJsonParser() + val result: Seq[Buf] = parser.feedAndParse(Buf.Utf8("[" + jsonObj + "]")) + val nodes: Seq[JsonNode] = result.map(mapper.parse[JsonNode]) + nodes.size should be(size) + if (size > 0) JsonDiff.jsonDiff(nodes.head, jsonObj) + } +} diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/JsonObjectDecoderTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/JsonObjectDecoderTest.scala deleted file mode 100644 index a6c15af988..0000000000 --- a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/JsonObjectDecoderTest.scala +++ /dev/null @@ -1,101 +0,0 @@ -package com.twitter.finatra.json.tests.internal.streaming - -import com.fasterxml.jackson.databind.JsonNode -import com.twitter.inject.conversions.buf._ -import com.twitter.finatra.json.internal.streaming.{JsonArrayChunker, ParsingState} -import com.twitter.finatra.json.{FinatraObjectMapper, JsonDiff} -import com.twitter.finatra.json.internal.streaming.ParsingState._ -import com.twitter.inject.Test -import com.twitter.io.Buf - -class JsonObjectDecoderTest extends Test { - - test("decode") { - val decoder = new JsonArrayChunker() - - assertDecode( - decoder, - input = "[1", - output = Seq(), - remainder = "1", - pos = 1, - openBraces = 1, - parsingState = InsideArray - ) - - assertDecode(decoder, input = ",2", output = Seq("1"), remainder = "2", pos = 1) - - assertDecode(decoder, input = ",3", output = Seq("2"), remainder = "3", pos = 1) - - assertDecode( - decoder, - input = "]", - output = Seq("3"), - remainder = "", - pos = 0, - openBraces = 0, - done = true - ) - } - - val mapper = FinatraObjectMapper.create() - - test("decode with nested objects") { - val jsonObj = """ - { - "sub_object": { - "msg": "hi" - } - } - """ - - assertSingleJsonParse(jsonObj) - } - - test("decode json inside a string") { - val jsonObj = """{"foo": "bar"}""" - assertSingleJsonParse(jsonObj) - } - - test("Caling decode when already finished") { - val decoder = new JsonArrayChunker() - decoder.decode(Buf.Utf8("[]")) - intercept[Exception] { - decoder.decode(Buf.Utf8("{}")) - } - } - - private def assertDecode( - decoder: JsonArrayChunker, - input: String, - output: Seq[String], - remainder: String, - pos: Int, - openBraces: Int = 1, - parsingState: ParsingState = InsideArray, - done: Boolean = false - ): Unit = { - - val result = decoder.decode(Buf.Utf8(input)) - result map { _.utf8str } should equal(output) - - val copiedByteBuffer = decoder.copiedByteBuffer.duplicate() - copiedByteBuffer.position(0) - val recvBuf = Buf.ByteBuffer.Shared(copiedByteBuffer) - println("Result remainder: " + recvBuf.utf8str) - - recvBuf.utf8str should equal(remainder) - decoder.copiedByteBuffer.position() should equal(pos) - decoder.openBraces should equal(openBraces) - decoder.parsingState should equal(parsingState) - decoder.done should equal(done) - } - - def assertSingleJsonParse(jsonObj: String): Unit = { - val decoder = new JsonArrayChunker() - val result = decoder.decode(Buf.Utf8("[" + jsonObj + "]")) - val nodes = result map mapper.parse[JsonNode] - nodes.size should be(1) - JsonDiff.jsonDiff(nodes.head, jsonObj) - } -} diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/StreamingTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/StreamingTest.scala index 9c248b78d7..9102b9864b 100644 --- a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/StreamingTest.scala +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/streaming/StreamingTest.scala @@ -6,8 +6,9 @@ import com.twitter.finatra.json.FinatraObjectMapper import com.twitter.finatra.json.internal.streaming.JsonStreamParser import com.twitter.finatra.json.tests.internal.{CaseClassWithSeqBooleans, FooClass} import com.twitter.inject.Test -import com.twitter.io.Buf -import com.twitter.util.Await +import com.twitter.io.{Buf, Reader} +import com.twitter.util.{Await, Future} +import scala.collection.mutable.ArrayBuffer class StreamingTest extends Test { @@ -15,7 +16,7 @@ class StreamingTest extends Test { val jsonStr = "[1,2,3]" val expected123 = AsyncStream(1, 2, 3) - test("bufs to json") { + test("bufs to json - AsyncStream") { assertParsed( AsyncStream( Buf.Utf8(jsonStr.substring(0, 1)), @@ -27,7 +28,7 @@ class StreamingTest extends Test { ) } - test("bufs to json 2") { + test("bufs to json 2 - AsyncStream") { assertParsed( AsyncStream(Buf.Utf8("[1"), Buf.Utf8(",2"), Buf.Utf8(",3"), Buf.Utf8("]")), expectedInputStr = jsonStr, @@ -35,7 +36,7 @@ class StreamingTest extends Test { ) } - test("bufs to json 3") { + test("bufs to json 3 - AsyncStream") { assertParsed( AsyncStream(Buf.Utf8("[1"), Buf.Utf8(",2,3,44"), Buf.Utf8("4,5]")), expectedInputStr = "[1,2,3,444,5]", @@ -43,30 +44,47 @@ class StreamingTest extends Test { ) } - test("bufs to json with some bigger objects") { + test("bufs to json with some bigger objects - AsyncStream") { assertParsedSimpleObjects( - AsyncStream(Buf.Utf8("""[{ "id":"John" }"""), - Buf.Utf8(""",{ "id":"Tom" },{ "id":"An" },"""), - Buf.Utf8("""{ "id":"Jean" }]""")), + AsyncStream( + Buf.Utf8("""[{ "id":"John" }"""), + Buf.Utf8(""",{ "id":"Tom" },{ "id":"An" },"""), + Buf.Utf8("""{ "id":"Jean" }]""")), expectedInputStr = """[{ "id":"John" },{ "id":"Tom" },{ "id":"An" },{ "id":"Jean" }]""", expected = AsyncStream(FooClass("John"), FooClass("Tom"), FooClass("An"), FooClass("Jean")) ) } - test("bufs to json with some complex objects") { + test("bufs to json with some complex objects - AsyncStream") { assertParsedComplexObjects( - AsyncStream(Buf.Utf8("""[{ "foos":[true,false,true] }"""), - Buf.Utf8(""",{ "foos": [true,false,true] },"""), - Buf.Utf8("""{ "foos":[false] }]""")), - expectedInputStr = """[{ "foos":[true,false,true] },{ "foos": [true,false,true] },{ "foos":[false] }]""", - expected = AsyncStream(CaseClassWithSeqBooleans(Seq(true,false,true)), - CaseClassWithSeqBooleans(Seq(true,false,true)), CaseClassWithSeqBooleans(Seq(false))) + AsyncStream( + Buf.Utf8("""[{ "foos":[true,false,true] }"""), + Buf.Utf8(""",{ "foos": [true,false,true] },"""), + Buf.Utf8("""{ "foos":[false] }]""")), + expectedInputStr = + """[{ "foos":[true,false,true] },{ "foos": [true,false,true] },{ "foos":[false] }]""", + expected = AsyncStream( + CaseClassWithSeqBooleans(Seq(true, false, true)), + CaseClassWithSeqBooleans(Seq(true, false, true)), + CaseClassWithSeqBooleans(Seq(false))) ) } - test("bufs to json with some bigger objects and an escape character") { + test("bufs to json with some bigger objects and an escape character - AsyncStream") { assertParsedSimpleObjects( - AsyncStream(Buf.Utf8("""[{ "id":"John" }"""), + AsyncStream( + Buf.Utf8("""[{ "id":"John" }"""), + Buf.Utf8(""",{ "id":"\\Tom" },{ "id""""), + Buf.Utf8(""":"An" },{ "id":"Jean" }]""")), + expectedInputStr = """[{ "id":"John" },{ "id":"\\Tom" },{ "id":"An" },{ "id":"Jean" }]""", + expected = AsyncStream(FooClass("John"), FooClass("\\Tom"), FooClass("An"), FooClass("Jean")) + ) + } + + test("bufs to json with some bigger objects but with json object split over multiple bufs - AsyncStream") { + assertParsedSimpleObjects( + AsyncStream( + Buf.Utf8("""[{ "id":"John" }"""), Buf.Utf8(""",{ "id":"Tom" },{ "id""""), Buf.Utf8(""":"An" },{ "id":"Jean" }]""")), expectedInputStr = """[{ "id":"John" },{ "id":"Tom" },{ "id":"An" },{ "id":"Jean" }]""", @@ -74,17 +92,97 @@ class StreamingTest extends Test { ) } - test("bufs to json with some bigger objects but with json object split over 2 bufs") { + test("parse request - AsyncStream") { + val jsonStr = "[1,2]" + val request = Request(Method.Post, "/") + request.setChunked(true) + + val parser = new JsonStreamParser(FinatraObjectMapper.create()) + + request.writer.write(Buf.Utf8(jsonStr)) ensure { + request.writer.close() + } + + assertFutureValue(parser.parseArray[Int](request.reader).toSeq(), Seq(1, 2)) + } + + test("bufs to json - Reader") { + assertParsed( + Reader.fromSeq( + Seq( + Buf.Utf8(jsonStr.substring(0, 1)), + Buf.Utf8(jsonStr.substring(1, 4)), + Buf.Utf8(jsonStr.substring(4)))), + expected = Reader.fromSeq(Seq(1, 2, 3)) + ) + } + + test("bufs to json 2 - Reader") { + assertParsed( + Reader.fromSeq(Seq(Buf.Utf8("[1"), Buf.Utf8(",2"), Buf.Utf8(",3"), Buf.Utf8("]"))), + expected = Reader.fromSeq(Seq(1, 2, 3)) + ) + } + + test("bufs to json 3 - Reader") { + assertParsed( + Reader.fromSeq(Seq(Buf.Utf8("[1"), Buf.Utf8(",2,3,44"), Buf.Utf8("4,5]"))), + expected = Reader.fromSeq(Seq(1, 2, 3, 444, 5)) + ) + } + + test("bufs to json with some bigger objects - Reader") { assertParsedSimpleObjects( - AsyncStream(Buf.Utf8("""[{ "id":"John" }"""), + Reader.fromSeq( + Seq( + Buf.Utf8("""[{ "id":"John" }"""), + Buf.Utf8(""",{ "id":"Tom" },{ "id":"An" },"""), + Buf.Utf8("""{ "id":"Jean" }]"""))), + expected = + Reader.fromSeq(Seq(FooClass("John"), FooClass("Tom"), FooClass("An"), FooClass("Jean"))) + ) + } + + test("bufs to json with some complex objects - Reader") { + assertParsedComplexObjects( + Reader.fromSeq( + Seq( + Buf.Utf8("""[{ "foos":[true,false,true] }"""), + Buf.Utf8(""",{ "foos": [true,false,true] },"""), + Buf.Utf8("""{ "foos":[false] }]"""))), + expected = Reader.fromSeq( + Seq( + CaseClassWithSeqBooleans(Seq(true, false, true)), + CaseClassWithSeqBooleans(Seq(true, false, true)), + CaseClassWithSeqBooleans(Seq(false)))) + ) + } + + test("bufs to json with some bigger objects and an escape character - Reader") { + assertParsedSimpleObjects( + Reader.fromSeq( + Seq( + Buf.Utf8("""[{ "id":"John" }"""), + Buf.Utf8(""",{ "id":"\\Tom" },{ "id""""), + Buf.Utf8(""":"An" },{ "id":"Jean" }]"""))), + expected = + Reader.fromSeq(Seq(FooClass("John"), FooClass("\\Tom"), FooClass("An"), FooClass("Jean"))) + ) + } + + test("bufs to json with some bigger objects but with json object split over multiple bufs - Reader") { + assertParsedSimpleObjects( + Reader.fromSeq( + Seq( + Buf.Utf8("""[{ "id":"John" }"""), Buf.Utf8(""",{ "id":"Tom" },{ "id""""), - Buf.Utf8(""":"An" },{ "id":"Jean" }]""")), - expectedInputStr = """[{ "id":"John" },{ "id":"Tom" },{ "id":"An" },{ "id":"Jean" }]""", - expected = AsyncStream(FooClass("John"), FooClass("Tom"), FooClass("An"), FooClass("Jean")) + Buf.Utf8(""":"An" },{ "id":"Jean" }]"""))), + expected = + Reader.fromSeq(Seq(FooClass("John"), FooClass("Tom"), FooClass("An"), FooClass("Jean"))) ) } - test("parse request") { + test("parse request - Reader") { val jsonStr = "[1,2]" val request = Request(Method.Post, "/") request.setChunked(true) @@ -139,4 +237,36 @@ class StreamingTest extends Test { assertFuture(parser.parseArray[CaseClassWithSeqBooleans](bufs).toSeq(), expected.toSeq()) } + private def assertParsed(reader: Reader[Buf], expected: Reader[Int]): Unit = { + val parser = new JsonStreamParser(FinatraObjectMapper.create()) + assertReader(parser.parseJson[Int](reader), expected) + } + + private def assertParsedSimpleObjects(reader: Reader[Buf], expected: Reader[FooClass]): Unit = { + val parser = new JsonStreamParser(FinatraObjectMapper.create()) + assertReader(parser.parseJson[FooClass](reader), expected) + } + + private def assertParsedComplexObjects( + reader: Reader[Buf], + expected: Reader[CaseClassWithSeqBooleans] + ): Unit = { + + val parser = new JsonStreamParser(FinatraObjectMapper.create()) + assertReader(parser.parseJson[CaseClassWithSeqBooleans](reader), expected) + } + + protected def assertReader[A](r1: Reader[A], r2: Reader[A]): Unit = { + assertFuture(readAll(r1), readAll(r2)) + } + + private def readAll[A](r: Reader[A]): Future[List[A]] = { + def loop(left: ArrayBuffer[A]): Future[List[A]] = + r.read().flatMap { + case Some(right) => loop(left += right) + case _ => Future.value(left.toList) + } + + loop(ArrayBuffer.empty[A]) + } } From ef071e546966d21b70d085a654810b158ae49169 Mon Sep 17 00:00:00 2001 From: Russell Teabeault Date: Fri, 8 Feb 2019 23:15:30 +0000 Subject: [PATCH 32/45] finatra-kafka: Implicit implementation of `Flaggable[SeekStrategy]` and `Flaggable[OffsetResetStrategy]` Problem When creating a flag for type `c.t.finatra.kafka.domain.SeekStrategy` or `org.apache.kafka.clients.consumer.OffsetResetStrategy` it is not possible to be parsed into an instance without a defined `Flaggable` for the type. Solution Create an implicit implementation of `Flaggable[SeekStrategy]` and `Flaggable[OffsetResetStrategy]` Result Users can now simply define a flag for a `c.t.finatra.kafka.domain.SeekStrategy` as ``` private val seekStrategyFlag = flag[SeekStrategy]( "seek.strategy.flag", SeekStrategy.RESUME, "This is the seek strategy flag" ) ``` The flag accepts the values `resume`, `beginning`, `rewind`, and `end`. or for an `org.apache.kafka.clients.consumer.OffsetResetStrategy` ``` private val offsetResetStrategyFlag = flag[OffsetResetStrategy]( "offset.reset.strategy.flag", OffsetResetStrategy.LATEST, "This is the offset reset strategy flag" ) ``` The flag accepts the values "latest", "earliest", and "none". JIRA Issues: DHIS-2963 Differential Revision: https://phabricator.twitter.biz/D271098 --- CHANGELOG.rst | 5 ++ .../finatra/kafka/consumers/Flaggables.scala | 59 +++++++++++++++++++ .../kafka/consumers/FlaggablesTest.scala | 24 ++++++++ 3 files changed, 88 insertions(+) create mode 100644 kafka/src/main/scala/com/twitter/finatra/kafka/consumers/Flaggables.scala create mode 100644 kafka/src/test/scala/com/twitter/finatra/kafka/consumers/FlaggablesTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5b05672713..4113a7defd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,11 @@ Unreleased Added ~~~~~ +* finatra-kafka: Adding an implicit implementation of + `c.t.app.Flaggable[c.t.finatra.kafka.domain.SeekStrategy]` + and `c.t.app.Flaggable[org.apache.kafka.clients.consumer.OffsetResetStrategy]`. + ``PHAB_ID=D271098`` + * finatra-http: Added support to serve `c.t.io.Reader` as a streaming response in `c.t.finatra.http.internal.marshalling.CallbackConverter`. ``PHAB_ID=D266863`` diff --git a/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/Flaggables.scala b/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/Flaggables.scala new file mode 100644 index 0000000000..328c90edf5 --- /dev/null +++ b/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/Flaggables.scala @@ -0,0 +1,59 @@ +package com.twitter.finatra.kafka.consumers + +import com.twitter.app.Flaggable +import com.twitter.finatra.kafka.domain.SeekStrategy +import org.apache.kafka.clients.consumer.OffsetResetStrategy + +/** + * Contains implicit Flaggable implementations for various kafka configuration types. + */ +object Flaggables { + + /** + * Allows you to create a flag which will convert the flag's input String into a + * [[com.twitter.finatra.kafka.domain.SeekStrategy]] + * + * {{{ + * import com.twitter.fanatra.kafka.consumers.Flaggables.seekStrategyFlaggable + * + * private val seekStrategyFlag = flag[SeekStrategy]( + * "seek.strategy.flag", + * SeekStrategy.RESUME, + * "This is the seek strategy flag" + * ) + * }}} + */ + implicit val seekStrategyFlaggable: Flaggable[SeekStrategy] = new Flaggable[SeekStrategy] { + override def parse(s: String): SeekStrategy = s match { + case "beginning" => SeekStrategy.BEGINNING + case "end" => SeekStrategy.END + case "resume" => SeekStrategy.RESUME + case "rewind" => SeekStrategy.REWIND + case _ => throw new IllegalArgumentException(s"$s is not a valid seek strategy.") + } + } + + /** + * Allows you to create a flag which will convert the flag's input String into a + * [[org.apache.kafka.clients.consumer.OffsetResetStrategy]] + * + * {{{ + * import org.apache.kafka.clients.consumer.OffsetResetStrategy + * + * private val offsetResetStrategyFlag = flag[OffsetResetStrategy]( + * "offset.reset.strategy.flag", + * OffsetResetStrategy.LATEST, + * "This is the offset reset strategy flag" + * ) + * }}} + */ + implicit val offsetResetStrategyFlaggable: Flaggable[OffsetResetStrategy] = + new Flaggable[OffsetResetStrategy] { + override def parse(s: String): OffsetResetStrategy = s match { + case "latest" => OffsetResetStrategy.LATEST + case "earliest" => OffsetResetStrategy.EARLIEST + case "none" => OffsetResetStrategy.NONE + case _ => throw new IllegalArgumentException(s"$s is not a valid offset reset strategy") + } + } +} diff --git a/kafka/src/test/scala/com/twitter/finatra/kafka/consumers/FlaggablesTest.scala b/kafka/src/test/scala/com/twitter/finatra/kafka/consumers/FlaggablesTest.scala new file mode 100644 index 0000000000..867c41fab1 --- /dev/null +++ b/kafka/src/test/scala/com/twitter/finatra/kafka/consumers/FlaggablesTest.scala @@ -0,0 +1,24 @@ +package com.twitter.finatra.kafka.consumers + +import com.twitter.finatra.kafka.consumers.Flaggables.{offsetResetStrategyFlaggable, seekStrategyFlaggable} +import com.twitter.finatra.kafka.domain.SeekStrategy +import com.twitter.inject.Test +import org.apache.kafka.clients.consumer.OffsetResetStrategy + +class FlaggablesTest extends Test { + + test("Flaggables#seekStrategyFlaggable") { + seekStrategyFlaggable.parse("beginning") should equal(SeekStrategy.BEGINNING) + seekStrategyFlaggable.parse("resume") should equal(SeekStrategy.RESUME) + seekStrategyFlaggable.parse("rewind") should equal(SeekStrategy.REWIND) + seekStrategyFlaggable.parse("end") should equal(SeekStrategy.END) + an [IllegalArgumentException] should be thrownBy seekStrategyFlaggable.parse("unknown") + } + + test("Flaggables#offsetResetStrategyFlaggable ") { + offsetResetStrategyFlaggable.parse("latest") should equal(OffsetResetStrategy.LATEST) + offsetResetStrategyFlaggable.parse("earliest") should equal(OffsetResetStrategy.EARLIEST) + offsetResetStrategyFlaggable.parse("none") should equal(OffsetResetStrategy.NONE) + an [IllegalArgumentException] should be thrownBy offsetResetStrategyFlaggable.parse("unknown") + } +} From e7609f94fdbbd6f7e2c6ff61518c174a031a2115 Mon Sep 17 00:00:00 2001 From: Moses Nakamura Date: Mon, 11 Feb 2019 15:54:15 +0000 Subject: [PATCH 33/45] finatra: Made language more inclusive Problem / Solution Remove non-inclusive language from finatra JIRA Issues: CSL-7049 Differential Revision: https://phabricator.twitter.biz/D271730 --- doc/src/sphinx/user-guide/logging/asyncappender.rst | 4 ++-- doc/src/sphinx/user-guide/thrift/filters.rst | 2 +- .../twitter/calculator/CalculatorServerFeatureTest.scala | 4 ++-- .../tests/DoEverythingThriftServerFeatureTest.scala | 8 ++++---- .../tests/EmbeddedThriftServerControllerFeatureTest.scala | 6 +++--- .../tests/LegacyDoEverythingThriftServerFeatureTest.scala | 8 ++++---- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/doc/src/sphinx/user-guide/logging/asyncappender.rst b/doc/src/sphinx/user-guide/logging/asyncappender.rst index 123f8ff229..3fba9a0a56 100644 --- a/doc/src/sphinx/user-guide/logging/asyncappender.rst +++ b/doc/src/sphinx/user-guide/logging/asyncappender.rst @@ -55,8 +55,8 @@ basics.html#verbosity-levels>`__ of the metrics via a Finagle `Tunable `__. Users need to create a JSON file and place it in the `src/main/resources` folder in -`com/twitter/tunables/finagle/instances.json` to whitelist the Logback metrics. -To whitelist all Logback metrics the JSON file could contain the following: +`com/twitter/tunables/finagle/instances.json` to acceptlist the Logback metrics. +To acceptlist all Logback metrics the JSON file could contain the following: .. code-block:: json diff --git a/doc/src/sphinx/user-guide/thrift/filters.rst b/doc/src/sphinx/user-guide/thrift/filters.rst index c57b89fcb9..c70691d9c2 100644 --- a/doc/src/sphinx/user-guide/thrift/filters.rst +++ b/doc/src/sphinx/user-guide/thrift/filters.rst @@ -142,7 +142,7 @@ E.g., for the `UserFilter` defined above (shown with common filters in a recomme .filter[AccessLoggingFilter] .filter[StatsFilter] .filter[ExceptionMappingFilter] - .filter[ClientIdWhitelistFilter] + .filter[ClientIdAcceptlistFilter] .filter[FinagleRequestScopeFilter] .filter[UserFilter] .exceptionMapper[FinatraThriftExceptionMapper] diff --git a/examples/thrift-server/thrift-example-server/src/test/scala/com/twitter/calculator/CalculatorServerFeatureTest.scala b/examples/thrift-server/thrift-example-server/src/test/scala/com/twitter/calculator/CalculatorServerFeatureTest.scala index fe7ede9564..932902abc1 100644 --- a/examples/thrift-server/thrift-example-server/src/test/scala/com/twitter/calculator/CalculatorServerFeatureTest.scala +++ b/examples/thrift-server/thrift-example-server/src/test/scala/com/twitter/calculator/CalculatorServerFeatureTest.scala @@ -12,13 +12,13 @@ class CalculatorServerFeatureTest extends FeatureTest { val client = server.thriftClient[Calculator[Future]](clientId = "client123") - test("whitelist#clients allowed") { + test("acceptlist#clients allowed") { await(client.increment(1)) should equal(2) await(client.addNumbers(1, 2)) should equal(3) await(client.addStrings("1", "2")) should equal("3") } - test("blacklist#clients blocked with UnknownClientIdException") { + test("denylist#clients blocked with UnknownClientIdException") { val clientWithUnknownId = server.thriftClient[Calculator[Future]](clientId = "unlisted-client") intercept[UnknownClientIdError] { await(clientWithUnknownId.increment(2)) diff --git a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/DoEverythingThriftServerFeatureTest.scala b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/DoEverythingThriftServerFeatureTest.scala index 56981343f8..6aea92e0ce 100644 --- a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/DoEverythingThriftServerFeatureTest.scala +++ b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/DoEverythingThriftServerFeatureTest.scala @@ -117,11 +117,11 @@ class DoEverythingThriftServerFeatureTest extends FeatureTest { await(client123.magicNum()) should equal("57") } - test("blacklist") { - val notWhitelistClient = - server.thriftClient[DoEverything[Future]](clientId = "not_on_whitelist") + test("denylist") { + val notAcceptlistClient = + server.thriftClient[DoEverything[Future]](clientId = "not_on_acceptlist") assertFailedFuture[UnknownClientIdError] { - notWhitelistClient.echo("Hi") + notAcceptlistClient.echo("Hi") } } diff --git a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/EmbeddedThriftServerControllerFeatureTest.scala b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/EmbeddedThriftServerControllerFeatureTest.scala index 57dc587eed..77e9483c6b 100644 --- a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/EmbeddedThriftServerControllerFeatureTest.scala +++ b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/EmbeddedThriftServerControllerFeatureTest.scala @@ -133,10 +133,10 @@ class EmbeddedThriftServerControllerFeatureTest extends FeatureTest { e.getMessage should include("oops") } - test("blacklist") { - val notWhitelistClient = server.thriftClient[Converter[Future]](clientId = "not_on_whitelist") + test("denylist") { + val notAcceptlistClient = server.thriftClient[Converter[Future]](clientId = "not_on_acceptlist") assertFailedFuture[UnknownClientIdError] { - notWhitelistClient.uppercase("Hi") + notAcceptlistClient.uppercase("Hi") } } diff --git a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala index 02b2b3cc15..92d970ec4d 100644 --- a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala +++ b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala @@ -118,11 +118,11 @@ class LegacyDoEverythingThriftServerFeatureTest extends FeatureTest { await(client123.magicNum()) should equal("57") } - test("blacklist") { - val notWhitelistClient = - server.thriftClient[DoEverything[Future]](clientId = "not_on_whitelist") + test("denylist") { + val notAcceptlistClient = + server.thriftClient[DoEverything[Future]](clientId = "not_on_acceptlist") assertFailedFuture[UnknownClientIdError] { - notWhitelistClient.echo("Hi") + notAcceptlistClient.echo("Hi") } } From 899205ae4461769013a47e696fe8ad3e716eebfe Mon Sep 17 00:00:00 2001 From: Neuman Vong Date: Mon, 11 Feb 2019 19:03:15 +0000 Subject: [PATCH 34/45] finagle: remove BackupRequestLost Problem / Solution BackupRequestLost has been deprecated since v17.12.0. Time to remove it. JIRA Issues: CSL-5585 Differential Revision: https://phabricator.twitter.biz/D270833 --- .../inject/thrift/internal/filters/LatencyFilter.scala | 10 ++-------- .../twitter/inject/exceptions/PossiblyRetryable.scala | 9 ++++----- .../tests/exceptions/PossiblyRetryableTest.scala | 3 --- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/internal/filters/LatencyFilter.scala b/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/internal/filters/LatencyFilter.scala index eef0aecb7e..328686bcff 100644 --- a/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/internal/filters/LatencyFilter.scala +++ b/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/internal/filters/LatencyFilter.scala @@ -1,6 +1,6 @@ package com.twitter.inject.thrift.internal.filters -import com.twitter.finagle.{WriteException, BackupRequestLost, Service, SimpleFilter} +import com.twitter.finagle.{FailureFlags, Service, SimpleFilter} import com.twitter.finagle.stats.StatsReceiver import com.twitter.util.{Throw, Try, Stopwatch, Future} import java.util.concurrent.TimeUnit @@ -27,14 +27,8 @@ private[thrift] class LatencyFilter[Req, Rep]( // Based on `c.t.finagle.service.StatsFilter#isBlackholeResponse` private def isBlackHoleResponse(rep: Try[Rep]): Boolean = rep match { - case Throw(BackupRequestLost) | Throw(WriteException(BackupRequestLost)) => + case Throw(f: FailureFlags[_]) if f.isFlagged(FailureFlags.Ignorable) => // We blackhole this request. It doesn't count for anything. - // After the Failure() patch, this should no longer need to - // be a special case. - // - // In theory, we should probably unwind the whole cause - // chain to look for a BackupRequestLost, but in practice it - // is wrapped only once. true case _ => false diff --git a/inject/inject-thrift/src/main/scala/com/twitter/inject/exceptions/PossiblyRetryable.scala b/inject/inject-thrift/src/main/scala/com/twitter/inject/exceptions/PossiblyRetryable.scala index 7d9643066c..54faaedee8 100644 --- a/inject/inject-thrift/src/main/scala/com/twitter/inject/exceptions/PossiblyRetryable.scala +++ b/inject/inject-thrift/src/main/scala/com/twitter/inject/exceptions/PossiblyRetryable.scala @@ -1,7 +1,7 @@ package com.twitter.inject.exceptions import com.twitter.finagle.mux.ClientDiscardedRequestException -import com.twitter.finagle.{BackupRequestLost, CancelledConnectionException, CancelledRequestException, Failure, FailureFlags, service => ctfs} +import com.twitter.finagle.{CancelledConnectionException, CancelledRequestException, Failure, FailureFlags, service => ctfs} import com.twitter.finagle.service.{ReqRep, ResponseClass} import com.twitter.util.{Return, Throw, Try} import scala.util.control.NonFatal @@ -64,19 +64,18 @@ object PossiblyRetryable { } private[inject] def isCancellation(t: Throwable): Boolean = t match { - case BackupRequestLost => true case _: CancelledRequestException => true case _: CancelledConnectionException => true case _: ClientDiscardedRequestException => true - case f: Failure if f.isFlagged(FailureFlags.Interrupted) => true + case f: FailureFlags[_] if f.isFlagged(FailureFlags.Ignorable) => true + case f: FailureFlags[_] if f.isFlagged(FailureFlags.Interrupted) => true case f: Failure if f.cause.isDefined => isCancellation(f.cause.get) case _ => false } private[inject] def isNonRetryable(t: Throwable) : Boolean = t match { - case BackupRequestLost => true case _: NonRetryableException => true - case f: Failure if f.isFlagged(FailureFlags.Ignorable) => true + case f: FailureFlags[_] if f.isFlagged(FailureFlags.Ignorable) => true case f: Failure if f.cause.isDefined => isNonRetryable(f.cause.get) case _ => false } diff --git a/inject/inject-thrift/src/test/scala/com/twitter/inject/tests/exceptions/PossiblyRetryableTest.scala b/inject/inject-thrift/src/test/scala/com/twitter/inject/tests/exceptions/PossiblyRetryableTest.scala index d5c80846dc..d28e1a405e 100644 --- a/inject/inject-thrift/src/test/scala/com/twitter/inject/tests/exceptions/PossiblyRetryableTest.scala +++ b/inject/inject-thrift/src/test/scala/com/twitter/inject/tests/exceptions/PossiblyRetryableTest.scala @@ -2,7 +2,6 @@ package com.twitter.inject.tests.exceptions import com.twitter.finagle.mux.ClientDiscardedRequestException import com.twitter.finagle.{ - BackupRequestLost, CancelledConnectionException, CancelledRequestException, Failure, @@ -21,7 +20,6 @@ class PossiblyRetryableTest extends Test { with com.twitter.inject.exceptions.NonRetryableException test("test isCancellation") { - assertIsCancellation(BackupRequestLost) assertIsCancellation(new CancelledRequestException) assertIsCancellation(new CancelledConnectionException(new Exception("cause"))) assertIsCancellation(new ClientDiscardedRequestException("cause")) @@ -30,7 +28,6 @@ class PossiblyRetryableTest extends Test { } test("test isNonRetryable") { - assertIsNonRetryable(BackupRequestLost) assertIsNonRetryable(Failure("int", FailureFlags.Ignorable)) } From 3b0931e399877ce51d8b1e861ee7fc4e537932d2 Mon Sep 17 00:00:00 2001 From: Lixuan Tang Date: Mon, 11 Feb 2019 19:32:29 +0000 Subject: [PATCH 35/45] finatra-kafka-streams: Added two RocksDB flags related to block cache Problem We need to expose two of the RocksDB's tuning options, namely `cache_index_and_filter_blocks` and `pin_l0_filter_and_index_blocks_in_cache`, to be settable via flags. Solution Exposed the options via flags following the existing examples. Result The two options are now settable via flags, e.g. ``` '-rocksdb.cache.index.and.filter.blocks=TRUE', '-rocksdb.cache.pin.l0.index.and.filter.blocks=TRUE'``` JIRA Issues: ADMT-5530 Differential Revision: https://phabricator.twitter.biz/D269516 --- CHANGELOG.rst | 4 +++ .../config/FinatraRocksDBConfig.scala | 21 +++++++++++++++ .../kafkastreams/config/RocksDbFlags.scala | 27 +++++++++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4113a7defd..7bd3bdc2a9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,10 @@ Unreleased Added ~~~~~ +* finatra-kafka-streams: Added two RocksDB flags related to block cache tuning, + `cache_index_and_filter_blocks` and `pin_l0_filter_and_index_blocks_in_cache`. + ``PHAB_ID=D269516`` + * finatra-kafka: Adding an implicit implementation of `c.t.app.Flaggable[c.t.finatra.kafka.domain.SeekStrategy]` and `c.t.app.Flaggable[org.apache.kafka.clients.consumer.OffsetResetStrategy]`. diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala index 43bc8e8da4..7e66a7037f 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala @@ -32,6 +32,8 @@ object FinatraRocksDBConfig { val RocksDbInfoLogLevel = "rocksdb.log.info.level" val RocksDbMaxLogFileSize = "rocksdb.log.max.file.size" val RocksDbKeepLogFileNum = "rocksdb.log.keep.file.num" + val RocksDbCacheIndexAndFilterBlocks = "rocksdb.cache.index.and.filter.blocks" + val RocksDbCachePinL0IndexAndFilterBlocks = "rocksdb.cache.pin.l0.index.and.filter.blocks" // BlockCache to be shared by all RocksDB instances created on this instance. Note: That a single Kafka Streams instance may get multiple tasks assigned to it // and each stateful task will have a separate RocksDB instance created. This cache will be shared across all the tasks. @@ -67,6 +69,12 @@ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { tableConfig.setBlockSize(16 * 1024) tableConfig.setBlockCache(FinatraRocksDBConfig.SharedBlockCache) tableConfig.setFilter(new BloomFilter(10)) + tableConfig.setCacheIndexAndFilterBlocks( + getBooleanOrDefault(configs, FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocks, true)) + + tableConfig.setPinL0FilterAndIndexBlocksInCache( + getBooleanOrDefault(configs, FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocks, true)) + options .setTableFormatConfig(tableConfig) @@ -162,4 +170,17 @@ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { default } } + + private def getBooleanOrDefault( + configs: util.Map[String, AnyRef], + key: String, + default: Boolean + ): Boolean = { + val valueString = configs.get(key) + if (valueString != null) { + valueString.toString.toBoolean + } else { + default + } + } } diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala index d5ef013e54..e7b530b5c2 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala @@ -10,7 +10,8 @@ trait RocksDbFlags extends TwitterServer { name = FinatraRocksDBConfig.RocksDbBlockCacheSizeConfig, default = 200.megabytes, help = - "Size of the rocksdb block cache per task. We recommend that this should be about 1/3 of your total memory budget. The remaining free memory can be left for the OS page cache" + """Size of the rocksdb block cache per task. We recommend that this should be about 1/3 of + |your total memory budget. The remaining free memory can be left for the OS page cache""".stripMargin ) protected val rocksDbEnableStatistics = @@ -18,7 +19,8 @@ trait RocksDbFlags extends TwitterServer { name = FinatraRocksDBConfig.RocksDbEnableStatistics, default = false, help = - "Enable RocksDB statistics. Note: RocksDB Statistics could add 5-10% degradation in performance (see https://github.com/facebook/rocksdb/wiki/Statistics)" + """Enable RocksDB statistics. Note: RocksDB Statistics could add 5-10% degradation in performance + |(See https://github.com/facebook/rocksdb/wiki/Statistics)""".stripMargin ) protected val rocksDbStatCollectionPeriodMs = @@ -60,4 +62,25 @@ trait RocksDbFlags extends TwitterServer { default = 10, help = "Maximal info log files to be kept." ) + + protected val rocksDbCacheIndexAndFilterBlocks = + flag( + name = FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocks, + default = true, + help = + """Store index and filter blocks into the block cache. This bounds the memory usage, + | which is desirable when running in a container. + |(See https://github.com/facebook/rocksdb/wiki/Memory-usage-in-RocksDB#indexes-and-filter-blocks)""".stripMargin + ) + + protected val rocksDbCachePinL0IndexAndFilterBlocks = + flag( + name = FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocks, + default = true, + help = + """Pin level-0 file's index and filter blocks in block cache, to avoid them from being evicted. + | This setting is generally recommended to be turned on along to minimize the negative + | performance impact resulted by turning on RocksDbCacheIndexAndFilterBlocks. + |(See https://github.com/facebook/rocksdb/wiki/Block-Cache#caching-index-and-filter-blocks)""".stripMargin + ) } From bb2c6ab9f51246236461cc4300fa9cb260b4c83e Mon Sep 17 00:00:00 2001 From: Kevin Oliver Date: Tue, 12 Feb 2019 22:05:08 +0000 Subject: [PATCH 36/45] util-core: Remove deprecated conversions Problem / Solution It's time for the deprecated `com.twitter.conversions` to be removed. They all have Ops-named analogs. JIRA Issues: CSL-7356 Differential Revision: https://phabricator.twitter.biz/D272206 --- .../kafkastreams/dsl/FinatraDslWindowedAggregations.scala | 4 ++-- .../twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala | 2 +- .../tests/LegacyDoEverythingThriftServerFeatureTest.scala | 2 +- .../controllers/LegacyDoEverythingThriftController.scala | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala index b96d26d466..819c56589f 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/dsl/FinatraDslWindowedAggregations.scala @@ -1,8 +1,8 @@ package com.twitter.finatra.kafkastreams.dsl import com.twitter.app.Flag -import com.twitter.conversions.storage._ -import com.twitter.conversions.time._ +import com.twitter.conversions.StorageUnitOps._ +import com.twitter.conversions.DurationOps._ import com.twitter.finagle.stats.StatsReceiver import com.twitter.finatra.kafka.serde.ScalaSerdes import com.twitter.finatra.kafkastreams.config.FinatraTransformerFlags diff --git a/kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala b/kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala index 0a2da3d2d5..d488732136 100644 --- a/kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala +++ b/kafka/src/test/scala/com/twitter/finatra/kafka/test/BootstrapServerUtilsTest.scala @@ -1,6 +1,6 @@ package com.twitter.finatra.kafka.test -import com.twitter.conversions.time._ +import com.twitter.conversions.DurationOps._ import com.twitter.finagle.{Addr, Dtab, Name, NameTree, Path} import com.twitter.finatra.kafka.{utils => KafkaUtils} import com.twitter.inject.Test diff --git a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala index 92d970ec4d..720128a1f1 100644 --- a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala +++ b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/LegacyDoEverythingThriftServerFeatureTest.scala @@ -1,6 +1,6 @@ package com.twitter.finatra.thrift.tests -import com.twitter.conversions.time._ +import com.twitter.conversions.DurationOps._ import com.twitter.doeverything.thriftscala.{Answer, DoEverything, Question} import com.twitter.finagle.http.Status import com.twitter.finagle.tracing.Trace diff --git a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/doeverything/controllers/LegacyDoEverythingThriftController.scala b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/doeverything/controllers/LegacyDoEverythingThriftController.scala index a1d82a179c..0a6fc95664 100644 --- a/thrift/src/test/scala/com/twitter/finatra/thrift/tests/doeverything/controllers/LegacyDoEverythingThriftController.scala +++ b/thrift/src/test/scala/com/twitter/finatra/thrift/tests/doeverything/controllers/LegacyDoEverythingThriftController.scala @@ -1,6 +1,6 @@ package com.twitter.finatra.thrift.tests.doeverything.controllers -import com.twitter.conversions.time._ +import com.twitter.conversions.DurationOps._ import com.twitter.doeverything.thriftscala.{Answer, DoEverything, DoEverythingException} import com.twitter.doeverything.thriftscala.DoEverything.{Ask, Echo, Echo2, MagicNum, MoreThanTwentyTwoArgs, Uppercase} import com.twitter.finagle.{ChannelException, RequestException, RequestTimeoutException} From 7a86d6a66219c9544fc21e2d1960b494ecb2e968 Mon Sep 17 00:00:00 2001 From: Christopher Coco Date: Tue, 12 Feb 2019 22:18:30 +0000 Subject: [PATCH 37/45] finatra-kafka|streams: Remove unneeded BUILD excludes from test-deps targets Problem/Solution The "test-deps" target for both projects specifies unnecessary excludes. Remove them. Differential Revision: https://phabricator.twitter.biz/D272914 --- kafka-streams/kafka-streams/src/test/scala/BUILD | 14 -------------- kafka/src/test/scala/BUILD | 14 -------------- 2 files changed, 28 deletions(-) diff --git a/kafka-streams/kafka-streams/src/test/scala/BUILD b/kafka-streams/kafka-streams/src/test/scala/BUILD index 19cfa417bc..edf5c0e1df 100644 --- a/kafka-streams/kafka-streams/src/test/scala/BUILD +++ b/kafka-streams/kafka-streams/src/test/scala/BUILD @@ -30,20 +30,6 @@ scala_library( "finatra/kafka/src/test/scala:test-deps", "util/util-slf4j-api/src/main/scala", ], - excludes = [ - exclude( - org = "com.twitter", - name = "twitter-server-internal-naming_2.11", - ), - exclude( - org = "com.twitter", - name = "loglens-log4j-logging_2.11", - ), - exclude( - org = "log4j", - name = "log4j", - ), - ], exports = [ "3rdparty/jvm/com/google/inject:guice", "3rdparty/jvm/junit", diff --git a/kafka/src/test/scala/BUILD b/kafka/src/test/scala/BUILD index 084f3a53ce..e62ddc2012 100644 --- a/kafka/src/test/scala/BUILD +++ b/kafka/src/test/scala/BUILD @@ -31,20 +31,6 @@ scala_library( "finatra/kafka/src/test/thrift:thrift-scala", "util/util-slf4j-api/src/main/scala", ], - excludes = [ - exclude( - org = "com.twitter", - name = "twitter-server-internal-naming_2.11", - ), - exclude( - org = "com.twitter", - name = "loglens-log4j-logging_2.11", - ), - exclude( - org = "log4j", - name = "log4j", - ), - ], exports = [ "3rdparty/jvm/com/google/inject:guice", "3rdparty/jvm/junit", From dac75ac3bd2a80f03d78e1cd2508023c0a28376c Mon Sep 17 00:00:00 2001 From: Dan Bress Date: Tue, 12 Feb 2019 23:14:00 +0000 Subject: [PATCH 38/45] finatra-kafka|streams: Add CSL to owners **Problem** CSL is not listed as an owner/watcher for the finatra/kafka and finatra/kafka-streams libraries, so they cannot quickly make changes to them. **Solution** Add CSL as an owner of these libraries. **Result** CSL can quickly make changes to these libraries. Differential Revision: https://phabricator.twitter.biz/D272949 --- kafka-streams/PROJECT | 2 ++ kafka/PROJECT | 2 ++ 2 files changed, 4 insertions(+) diff --git a/kafka-streams/PROJECT b/kafka-streams/PROJECT index c57fb17484..878b3f9f1e 100644 --- a/kafka-streams/PROJECT +++ b/kafka-streams/PROJECT @@ -1,7 +1,9 @@ owners: + - csl-team:ldap - messaging-group:ldap - scosenza - dbress - adams watchers: + - csl-team@twitter.com - ds-messaging@twitter.com diff --git a/kafka/PROJECT b/kafka/PROJECT index c57fb17484..878b3f9f1e 100644 --- a/kafka/PROJECT +++ b/kafka/PROJECT @@ -1,7 +1,9 @@ owners: + - csl-team:ldap - messaging-group:ldap - scosenza - dbress - adams watchers: + - csl-team@twitter.com - ds-messaging@twitter.com From 32ad860b3993acec138e3b58abfd985f53a628f9 Mon Sep 17 00:00:00 2001 From: Yufan Gong Date: Fri, 15 Feb 2019 03:23:02 +0000 Subject: [PATCH 39/45] finagle-thrift/scrooge: Clean up c.t.f.thrift.ThriftServiceIface Problem ThriftServiceIface#Filterable was deprecated since Nov 2017. ThriftServiceIface should upgrade its name and location align with new scrooge generated APIs. Solution Removed Filterable, renamed ThriftServiceIface to ThriftServicePerEndpoint and moved to c.t.f.thrift.serivce package. JIRA Issues: CSL-7648 Differential Revision: https://phabricator.twitter.biz/D272427 --- .../thrift/filters/ThriftClientFilterChain.scala | 4 ++-- .../thrift/modules/FilteredThriftClientModule.scala | 11 +++-------- ...rythingFilteredThriftClientModuleFeatureTest.scala | 2 +- ...epThriftMethodBuilderClientModuleFeatureTest.scala | 6 +++--- ...ngThriftMethodBuilderClientModuleFeatureTest.scala | 8 ++++---- ...ceThriftMethodBuilderClientModuleFeatureTest.scala | 4 ++-- 6 files changed, 15 insertions(+), 20 deletions(-) diff --git a/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/filters/ThriftClientFilterChain.scala b/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/filters/ThriftClientFilterChain.scala index 00642e0c64..2f4e9a0352 100644 --- a/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/filters/ThriftClientFilterChain.scala +++ b/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/filters/ThriftClientFilterChain.scala @@ -66,7 +66,7 @@ import org.joda.time.Duration * @tparam Req Request type for this filter chain * @tparam Rep Response type for this filter chain * @see [[com.twitter.inject.thrift.filters.ThriftClientFilterBuilder]] - * @see [[com.twitter.finagle.thrift.ThriftServiceIface]] + * @see [[com.twitter.finagle.thrift.service.ThriftServicePerEndpoint]] */ @deprecated("Use ThriftMethodBuilderClientModule and ThriftMethodBuilder", "2018-01-12") class ThriftClientFilterChain[Req <: ThriftStruct, Rep]( @@ -90,7 +90,7 @@ class ThriftClientFilterChain[Req <: ThriftStruct, Rep]( private val scopedStatsReceiver = scopeStatsReceiver() - /** @see [[com.twitter.finagle.thrift.ThriftServiceIface#statsFilter]] */ + /** @see [[com.twitter.finagle.thrift.service.ThriftServicePerEndpoint#statsFilter]] */ // method invocations - incremented every time we call/invoke the method. /** Example scope: clnt/thrift/Adder/add1String/method/invocations */ private val invocationsCounter = scopedStatsReceiver.counter("invocations") diff --git a/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/modules/FilteredThriftClientModule.scala b/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/modules/FilteredThriftClientModule.scala index bc8d3b66c4..5c66895f11 100644 --- a/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/modules/FilteredThriftClientModule.scala +++ b/inject/inject-thrift-client/src/main/scala/com/twitter/inject/thrift/modules/FilteredThriftClientModule.scala @@ -6,13 +6,8 @@ import com.google.inject.Provides import com.twitter.finagle._ import com.twitter.finagle.service.Retries.Budget import com.twitter.finagle.stats.StatsReceiver -import com.twitter.finagle.thrift.{ - ClientId, - MethodIfaceBuilder, - ServiceIfaceBuilder, - ThriftService, - ThriftServiceIface -} +import com.twitter.finagle.thrift.service.Filterable +import com.twitter.finagle.thrift.{ClientId, MethodIfaceBuilder, ServiceIfaceBuilder, ThriftService} import com.twitter.inject.annotations.Flag import com.twitter.inject.conversions.duration._ import com.twitter.inject.exceptions.PossiblyRetryable @@ -66,7 +61,7 @@ object FilteredThriftClientModule { @deprecated("Use the com.twitter.inject.thrift.modules.ThriftMethodBuilderClientModule", "2018-01-08") abstract class FilteredThriftClientModule[ FutureIface <: ThriftService: ClassTag, - ServiceIface <: ThriftServiceIface.Filterable[ServiceIface]: ClassTag + ServiceIface <: Filterable[ServiceIface]: ClassTag ]( implicit serviceBuilder: ServiceIfaceBuilder[ServiceIface], methodBuilder: MethodIfaceBuilder[ServiceIface, FutureIface] diff --git a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingFilteredThriftClientModuleFeatureTest.scala b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingFilteredThriftClientModuleFeatureTest.scala index 123c1444e1..aa22b133c2 100644 --- a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingFilteredThriftClientModuleFeatureTest.scala +++ b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingFilteredThriftClientModuleFeatureTest.scala @@ -44,7 +44,7 @@ class DoEverythingFilteredThriftClientModuleFeatureTest // per-method -- all the requests in this test were to the same method server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/invocations", 1) - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/requests", 4) server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/success", 2) server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/failures", 2) diff --git a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingReqRepThriftMethodBuilderClientModuleFeatureTest.scala b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingReqRepThriftMethodBuilderClientModuleFeatureTest.scala index 0f470cec94..a5fb556f1b 100644 --- a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingReqRepThriftMethodBuilderClientModuleFeatureTest.scala +++ b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingReqRepThriftMethodBuilderClientModuleFeatureTest.scala @@ -64,7 +64,7 @@ class DoEverythingReqRepThriftMethodBuilderClientModuleFeatureTest } // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/requests", 3) server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/success", 1) server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/failures", 2) @@ -83,7 +83,7 @@ class DoEverythingReqRepThriftMethodBuilderClientModuleFeatureTest assert(!response.headers.empty) // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/greeter-thrift-client/Greeter/hello/requests", 3) server.assertCounter("clnt/greeter-thrift-client/Greeter/hello/success", 1) server.assertCounter("clnt/greeter-thrift-client/Greeter/hello/failures", 2) @@ -103,7 +103,7 @@ class DoEverythingReqRepThriftMethodBuilderClientModuleFeatureTest ) // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/greeter-thrift-client/Greeter/bye/requests", 3) server.assertCounter("clnt/greeter-thrift-client/Greeter/bye/success", 1) server.assertCounter("clnt/greeter-thrift-client/Greeter/bye/failures", 2) diff --git a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingThriftMethodBuilderClientModuleFeatureTest.scala b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingThriftMethodBuilderClientModuleFeatureTest.scala index e861cf46b7..6fb69da04c 100644 --- a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingThriftMethodBuilderClientModuleFeatureTest.scala +++ b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/DoEverythingThriftMethodBuilderClientModuleFeatureTest.scala @@ -82,7 +82,7 @@ class DoEverythingThriftMethodBuilderClientModuleFeatureTest extends FeatureTest server.assertStat("route/hi/GET/response_size", Seq(5)) // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/requests", 3) server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/success", 1) server.assertCounter("clnt/greeter-thrift-client/Greeter/hi/failures", 2) @@ -100,7 +100,7 @@ class DoEverythingThriftMethodBuilderClientModuleFeatureTest extends FeatureTest server.assertStat("route/hello/GET/response_size", Seq(9)) // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/greeter-thrift-client/Greeter/hello/requests", 3) server.assertCounter("clnt/greeter-thrift-client/Greeter/hello/success", 1) server.assertCounter("clnt/greeter-thrift-client/Greeter/hello/failures", 2) @@ -122,7 +122,7 @@ class DoEverythingThriftMethodBuilderClientModuleFeatureTest extends FeatureTest server.assertStat("route/bye/GET/response_size", Seq(20)) // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/greeter-thrift-client/Greeter/bye/requests", 3) server.assertCounter("clnt/greeter-thrift-client/Greeter/bye/success", 1) server.assertCounter("clnt/greeter-thrift-client/Greeter/bye/failures", 2) @@ -155,7 +155,7 @@ class DoEverythingThriftMethodBuilderClientModuleFeatureTest extends FeatureTest server.assertStat("route/echo/GET/response_size", Seq(9)) // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/echo-thrift-client/EchoService/echo/requests", 1) server.assertCounter("clnt/echo-thrift-client/EchoService/echo/success", 1) server.assertCounter("clnt/echo-thrift-client/EchoService/echo/failures", 0) diff --git a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/InheritanceThriftMethodBuilderClientModuleFeatureTest.scala b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/InheritanceThriftMethodBuilderClientModuleFeatureTest.scala index 20e699dd61..c007501467 100644 --- a/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/InheritanceThriftMethodBuilderClientModuleFeatureTest.scala +++ b/inject/inject-thrift-client/src/test/scala/com/twitter/inject/thrift/InheritanceThriftMethodBuilderClientModuleFeatureTest.scala @@ -47,7 +47,7 @@ class InheritanceThriftMethodBuilderClientModuleFeatureTest server.httpGet(path = "/echo?msg=Hello!", andExpect = Ok, withBody = "Hello!") // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/serviceB-thrift-client/ServiceA/echo/requests", 1) server.assertCounter("clnt/serviceB-thrift-client/ServiceA/echo/success", 1) server.assertCounter("clnt/serviceB-thrift-client/ServiceA/echo/failures", 0) @@ -63,7 +63,7 @@ class InheritanceThriftMethodBuilderClientModuleFeatureTest server.httpGet(path = "/ping", andExpect = Ok, withBody = "pong") // per-method -- all the requests in this test were to the same method - /* assert counters added by ThriftServiceIface#statsFilter */ + /* assert counters added by ThriftServicePerEndpoint#statsFilter */ server.assertCounter("clnt/serviceB-thrift-client/ServiceB/ping/requests", 1) server.assertCounter("clnt/serviceB-thrift-client/ServiceB/ping/success", 1) server.assertCounter("clnt/serviceB-thrift-client/ServiceB/ping/failures", 0) From abd68ddfc6393e816c97079a28eb9ab10cb3a3d3 Mon Sep 17 00:00:00 2001 From: Ming Liu Date: Tue, 19 Feb 2019 18:48:18 +0000 Subject: [PATCH 40/45] finatra-kafka: Expose timeout duration in FinagleKafkaConsumerBuilder dest() Differential Revision: https://phabricator.twitter.biz/D269701 --- CHANGELOG.rst | 1 + .../kafka/consumers/KafkaConsumerConfig.scala | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7bd3bdc2a9..ac943b2850 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ Unreleased Added ~~~~~ +* finatra-kafka: Expose timeout duration in FinagleKafkaConsumerBuilder dest(). ``PHAB_ID=D269701`` * finatra-kafka-streams: Added two RocksDB flags related to block cache tuning, `cache_index_and_filter_blocks` and `pin_l0_filter_and_index_blocks_in_cache`. diff --git a/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/KafkaConsumerConfig.scala b/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/KafkaConsumerConfig.scala index 8fc558ccee..ed8526060e 100644 --- a/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/KafkaConsumerConfig.scala +++ b/kafka/src/main/scala/com/twitter/finatra/kafka/consumers/KafkaConsumerConfig.scala @@ -21,7 +21,25 @@ object KafkaConsumerConfig { } trait KafkaConsumerConfigMethods[Self] extends KafkaConfigMethods[Self] with Logging { - def dest(dest: String): This = bootstrapServers(BootstrapServerUtils.lookupBootstrapServers(dest)) + /** + * Configure the Kafka server the consumer will connect to. + * + * @param dest the Kafka server address + * @return the [[KafkaConsumerConfigMethods]] instance. + */ + def dest(dest: String): This = + bootstrapServers(BootstrapServerUtils.lookupBootstrapServers(dest)) + + + /** + * Configure the Kafka server the consumer will connect to. + * + * @param dest the Kafka server address + * @param timeout the timeout duration when trying to resolve the [[dest]] server. + * @return the [[KafkaConsumerConfigMethods]] instance. + */ + def dest(dest: String, timeout: Duration): This = + bootstrapServers(BootstrapServerUtils.lookupBootstrapServers(dest, timeout)) def autoCommitInterval(duration: Duration): This = withConfig(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, duration) From a9db4a4e5d129bf5af0584d5ac733cbffe5675f2 Mon Sep 17 00:00:00 2001 From: Vladimir Kostyukov Date: Tue, 19 Feb 2019 20:16:35 +0000 Subject: [PATCH 41/45] finagle-base-http: Remove deprecated c.t.f.h.Methods Problem / Solution `c.t.f.http.Methods` has been deprecated for about two years. We're removing them as our Scala API works just fine in Java too. JIRA Issues: CSL-7475 Differential Revision: https://phabricator.twitter.biz/D273235 --- http/src/test/java/BUILD | 1 - 1 file changed, 1 deletion(-) diff --git a/http/src/test/java/BUILD b/http/src/test/java/BUILD index 2acce68fc1..6087959358 100644 --- a/http/src/test/java/BUILD +++ b/http/src/test/java/BUILD @@ -9,7 +9,6 @@ junit_tests( "3rdparty/jvm/junit", "3rdparty/jvm/org/scalatest", "3rdparty/jvm/org/slf4j:slf4j-api", - "finagle/finagle-base-http/src/main/java", "finagle/finagle-base-http/src/main/scala", "finagle/finagle-core/src/main/scala", "finagle/finagle-http/src/main/scala", From 4c8c3d9781d3b8d75ea1dbd7e287db179fdfaa0b Mon Sep 17 00:00:00 2001 From: Christopher Coco Date: Wed, 20 Feb 2019 00:12:38 +0000 Subject: [PATCH 42/45] finatra: Update documentation around GlobalFlags and testing Problem/Solution We'd like some documentation around `GlobalFlag` and specifically around using `Flag.let` and `Flag.letClear` when testing with a `GlobalFlag`. JIRA Issues: CSL-7649 Differential Revision: https://phabricator.twitter.biz/D275495 --- .../user-guide/getting-started/flags.rst | 13 ++++- .../sphinx/user-guide/testing/embedded.rst | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/doc/src/sphinx/user-guide/getting-started/flags.rst b/doc/src/sphinx/user-guide/getting-started/flags.rst index a23ac71271..7a6a89cef1 100644 --- a/doc/src/sphinx/user-guide/getting-started/flags.rst +++ b/doc/src/sphinx/user-guide/getting-started/flags.rst @@ -17,12 +17,23 @@ of "*environment*\ " string within code, e.g. .. code:: bash - if (env == "production") { ... } + if (env == "production") { ... } It is generally good practice to make Flags *granular* controls that are fully orthogonal to one another. They can then be independently managed for each deploy and this scales consistently as the number of supported "environments" scales. +Global Flags +------------ + +`TwitterUtil `__ `Flags `__ +also has the concept of a "global" flag. That is, a flag that is "global" to the JVM process (as it is +generally defined as a Scala object). In the discussion of Flags with Finatra we **do not** mean +"global" flags unless it is explicitly stated. + +See the `scaladoc `__ for +`c.t.app.GlobalFlag` for more information. + But I have a lot of Flags ------------------------- diff --git a/doc/src/sphinx/user-guide/testing/embedded.rst b/doc/src/sphinx/user-guide/testing/embedded.rst index 69179c3452..1b62bba5c8 100644 --- a/doc/src/sphinx/user-guide/testing/embedded.rst +++ b/doc/src/sphinx/user-guide/testing/embedded.rst @@ -29,6 +29,46 @@ You'll notice that this hierarchy generally follows the server trait hierarchy a and |c.t.finatra.thrift.ThriftServer|_ extend from |c.t.server.TwitterServer|_ which extends from |c.t.app.App|_. +Testing With `Global Flags` +--------------------------- + +The embedded servers and the embedded app allow for passing `TwitterUtil `__ `Flags `__ +to the server under test via the `flags `__ +constructor argument (a map of flag name to flag value) which is meant to mimic setting flag values +via the command line. + +However it is **not recommended** that users set any |GlobalFlag|_ value in this manner. In normal +usage, the value of a |GlobalFlag|_ is **only read once during the initialization of the JVM process**. + +If you wish to test with toggled values of a |GlobalFlag|_ you should prefer using +|FlagLet|_ or |FlagLetClear|_ in tests instead of passing the |GlobalFlag|_ value via the `flags `__ +arg of an embedded server or embedded app. For example, + +.. code:: scala + + import com.twitter.finatra.http.EmbeddedHttpServer + import com.twitter.finagle.http.Status + import com.twitter.inject.server.FeatureTest + + class ExampleServerFeatureTest extends FeatureTest { + override val server = new EmbeddedHttpServer(new ExampleServer) + + test("ExampleServer#perform feature") { + + someGlobalFlag.let("a value") { + // any read of the `someGlobalFlag` value in this closure will be "a value" + server.httpGet( + path = "/", + andExpect = Status.Ok) + + ??? + } + } + } + +See the `scaladoc `_ for `c.t.app.Flag` +for more information on using |FlagLet|_ or |FlagLetClear|_. + InMemoryStatsReceiver --------------------- @@ -82,3 +122,12 @@ More Information .. |EmbeddedThriftServer| replace:: `EmbeddedThriftServer` .. _EmbeddedThriftServer: https://github.com/twitter/finatra/blob/develop/thrift/src/test/scala/com/twitter/finatra/thrift/EmbeddedThriftServer.scala + +.. |GlobalFlag| replace:: `GlobalFlag` +.. _GlobalFlag: https://github.com/twitter/util/blob/f2a05474ec41f34146d710bdc2a789efd6da9d21/util-app/src/main/scala/com/twitter/app/GlobalFlag.scala + +.. |FlagLet| replace:: `Flag.let` +.. _FlagLet: http://twitter.github.io/util/docs/com/twitter/app/Flag.html#let[R](t:T)(f:=%3ER):R + +.. |FlagLetClear| replace:: `Flag.letClear` +.. _FlagLetClear: http://twitter.github.io/util/docs/com/twitter/app/Flag.html#letClear[R](f:=%3ER):R From 1454867ca2b2b4e59fecdfcb8f97d57d8853ada4 Mon Sep 17 00:00:00 2001 From: Adam Singer Date: Wed, 20 Feb 2019 07:21:34 +0000 Subject: [PATCH 43/45] finatra-kafka-streams: Expose all existing RocksDb configuration Problem `FinatraRocksDBConfig` configuration contains default values that are not accessible from external configuration points. Exposing these configurations will provide more flexibility for tuning a finatra-kafka-streams application. Solution `FinatraRocksDBConfig` has been updated to expose configurations that can be used from `RocksDbFlags` and accessed in `KafkaStreamsTwitterServer#streamsProperties`. Doc strings, flag names, default values have all been centrally co-located into `FinatraRocksDBConfig`. Tests have been added to ensure configuration parameters reach internal `Properties` objects used for passing properties to Kafka state stores. Result All existing flags are now available for client applications to modify. It is still required by the application to use `KafkaStreamsTwitterServer#streamsProperties` for passing the values to `KafkaStreamsConfig`, the existing default functionality. JIRA Issues: DINS-2639 Differential Revision: https://phabricator.twitter.biz/D272068 --- CHANGELOG.rst | 4 + .../config/FinatraRocksDBConfig.scala | 442 ++++++++++++++++-- .../kafkastreams/config/RocksDbFlags.scala | 213 +++++++-- .../FinatraRocksDBConfigFeatureTest.scala | 174 +++++++ .../finatra/kafka/config/KafkaConfig.scala | 6 + 5 files changed, 754 insertions(+), 85 deletions(-) create mode 100644 kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/config/FinatraRocksDBConfigFeatureTest.scala diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac943b2850..60d7235a0d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,10 @@ Added ~~~~~ * finatra-kafka: Expose timeout duration in FinagleKafkaConsumerBuilder dest(). ``PHAB_ID=D269701`` +* finatra-kafka-streams: Expose all existing RocksDb configurations. See + `c.t.f.k.config.FinatraRocksDBConfig` for details on flag names, + descriptions and default values. ``PHAB_ID=D272068`` + * finatra-kafka-streams: Added two RocksDB flags related to block cache tuning, `cache_index_and_filter_blocks` and `pin_l0_filter_and_index_blocks_in_cache`. ``PHAB_ID=D269516`` diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala index 7e66a7037f..9696f04322 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/FinatraRocksDBConfig.scala @@ -11,6 +11,7 @@ import org.apache.kafka.streams.state.RocksDBConfigSetter import org.rocksdb.{ BlockBasedTableConfig, BloomFilter, + ColumnFamilyOptionsInterface, CompactionStyle, CompressionType, InfoLogLevel, @@ -25,18 +26,199 @@ import org.rocksdb.{ object FinatraRocksDBConfig { val RocksDbBlockCacheSizeConfig = "rocksdb.block.cache.size" + val RocksDbBlockCacheSizeConfigDefault: StorageUnit = 200.megabytes + val RocksDbBlockCacheSizeConfigDoc = + """Size of the rocksdb block cache per task. We recommend that this should be about 1/3 of + |your total memory budget. The remaining free memory can be left for the OS page cache""".stripMargin + val RocksDbBlockCacheShardBitsConfig = "rocksdb.block.cache.shard.bits" + val RocksDbBlockCacheShardBitsConfigDefault: Int = 1 + val RocksDbBlockCacheShardBitsConfigDoc = + """Cache is is sharded 2^bits shards by hash of the key. Setting the value to -1 will + |cause auto determine the size with starting size of 512KB. Shard bits will not exceed 6. + |If mutex locking is frequent and database size is smaller then RAM, increasing this value + |will improve locking as more shards will be available. + """.stripMargin + val RocksDbLZ4Config = "rocksdb.lz4" + val RocksDbLZ4ConfigDefault: Boolean = false + val RocksDbLZ4ConfigDoc = + "Enable RocksDB LZ4 compression. (See https://github.com/facebook/rocksdb/wiki/Compression)" + val RocksDbEnableStatistics = "rocksdb.statistics" + val RocksDbEnableStatisticsDefault: Boolean = false + val RocksDbEnableStatisticsDoc = + """Enable RocksDB statistics. Note: RocksDB Statistics could add 5-10% degradation in performance + |(See https://github.com/facebook/rocksdb/wiki/Statistics)""".stripMargin + val RocksDbStatCollectionPeriodMs = "rocksdb.statistics.collection.period.ms" + val RocksDbStatCollectionPeriodMsDefault: Int = 60000 + val RocksDbStatCollectionPeriodMsDoc = "Set the period in milliseconds for stats collection." + val RocksDbInfoLogLevel = "rocksdb.log.info.level" + val RocksDbInfoLogLevelDefault = "DEBUG_LEVEL" + val RocksDbInfoLogLevelDoc = + """Level of logging for rocksdb LOG file. + |DEBUG_LEVEL, INFO_LEVEL, WARN_LEVEL, ERROR_LEVEL, FATAL_LEVEL, HEADER_LEVEL""".stripMargin + val RocksDbMaxLogFileSize = "rocksdb.log.max.file.size" + val RocksDbMaxLogFileSizeDefault: StorageUnit = 50.megabytes + val RocksDbMaxLogFileSizeDoc = + s"""Specify the maximal size of the info log file. If the log file is larger then + |"rocksdb.log.keep.file.num" a new log file will be created.""".stripMargin + val RocksDbKeepLogFileNum = "rocksdb.log.keep.file.num" + val RocksDbKeepLogFileNumDefault: Int = 10 + val RocksDbKeepLogFileNumDoc = "Maximal info log files to be kept." + val RocksDbCacheIndexAndFilterBlocks = "rocksdb.cache.index.and.filter.blocks" + val RocksDbCacheIndexAndFilterBlocksDefault: Boolean = true + val RocksDbCacheIndexAndFilterBlocksDoc = + """Store index and filter blocks into the block cache. This bounds the memory usage, + | which is desirable when running in a container. + |(See https://github.com/facebook/rocksdb/wiki/Memory-usage-in-RocksDB#indexes-and-filter-blocks)""".stripMargin + val RocksDbCachePinL0IndexAndFilterBlocks = "rocksdb.cache.pin.l0.index.and.filter.blocks" + val RocksDbCachePinL0IndexAndFilterBlocksDefault: Boolean = true + val RocksDbCachePinL0IndexAndFilterBlocksDoc = + """Pin level-0 file's index and filter blocks in block cache, to avoid them from being evicted. + | This setting is generally recommended to be turned on along to minimize the negative + | performance impact resulted by turning on RocksDbCacheIndexAndFilterBlocks. + |(See https://github.com/facebook/rocksdb/wiki/Block-Cache#caching-index-and-filter-blocks)""".stripMargin + + val RocksDbTableConfigBlockSize = "rocksdb.tableconfig.block.size" + val RocksDbTableConfigBlockSizeDefault: StorageUnit = (16 * 1024).bytes + val RocksDbTableConfigBlockSizeDoc = + s"""Approximate size of user data packed per block. This is the uncompressed size and on disk + |size will differ due to compression. Increasing block_size decreases memory usage and space + |amplification, but increases read amplification.""".stripMargin + + val RocksDbTableConfigBoomFilterKeyBits = "rocksdb.tableconfig.bloomfilter.key.bits" + val RocksDbTableConfigBoomFilterKeyBitsDefault: Int = 10 + val RocksDbTableConfigBoomFilterKeyBitsDoc = + """ + |Bits per key in bloom filter. A bits_per_key if 10, yields a filter with ~ 1% false positive + |rate. + |(See https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide#bloom-filters)""".stripMargin + + val RocksDbTableConfigBoomFilterMode = "rocksdb.tableconfig.bloomfilter.mode" + val RocksDbTableConfigBoomFilterModeDefault: Boolean = true + val RocksDbTableConfigBoomFilterModeDoc = + s"""Toggle the mode of the bloom filer between Block-based filter (true) and Full filter (false). + |Block-based filter is a filter for each block where Full filter is a filter per file. + |If multiple keys are contained in the same file, the Block-based filter will serve best. + |If keys are same among database files then Full filter is best.""".stripMargin + + val RocksDbDatabaseWriteBufferSize = "rocksdb.db.write.buffer.size" + val RocksDbDatabaseWriteBufferSizeDefault: StorageUnit = 0.bytes + val RocksDbDatabaseWriteBufferSizeDoc = + """Data stored in memtables across all column families before writing to disk. Disabled by + |specifying a 0 value, can be enabled by setting positive value in bytes. This value can be + |used to control the total memtable sizes.""".stripMargin + + val RocksDbWriteBufferSize = "rocksdb.write.buffer.size" + val RocksDbWriteBufferSizeDefault: StorageUnit = 1.gigabyte + val RocksDbWriteBufferSizeDoc = + """Data stored in memory (stored in unsorted log on disk) before writing tto sorted on-disk + |file. Larger values will increase performance, especially on bulk loads up to + |max_write_buffer_number write buffers available. This value can be used to adjust the control + |of memory usage. Larger write buffers will cause longer recovery on file open.""".stripMargin + + val RocksDbMinWriteBufferNumberToMerge = "rocksdb.min.write.buffer.num.merge" + val RocksDbMinWriteBufferNumberToMergeDefault: Int = 1 + val RocksDbMinWriteBufferNumberToMergeDoc = + """Minimum number of write buffers that will be merged together before flushing to storage. + |Setting of 1 will cause L0 flushed as individual files and increase read amplification + |as all files will be scanned.""".stripMargin + + val RocksDbMaxWriteBufferNumber = "rocksdb.max.write.buffer.num" + val RocksDbMaxWriteBufferNumberDefault: Int = 2 + val RocksDbMaxWriteBufferNumberDoc = + """Maximum number of write buffers that will be stored in memory. While 1 buffer is flushed to disk + |other buffers can be written.""".stripMargin + + val RocksDbBytesPerSync = "rocksdb.bytes.per.sync" + val RocksDbBytesPerSyncDefault: StorageUnit = 1048576.bytes + val RocksDbBytesPerSyncDoc = + "Setting for OS to sync files to disk in the background while they are written." + + val RocksDbMaxBackgroundCompactions = "rocksdb.max.background.compactions" + val RocksDbMaxBackgroundCompactionsDefault: Int = 4 + val RocksDbMaxBackgroundCompactionsDoc = + """Maximum background compactions, increased values will fully utilize CPU and storage for + |compaction routines. If stats indication higher latency due to compaction, this value could + |be adjusted. + |(https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide#parallelism-options)""".stripMargin + + val RocksDbMaxBackgroundFlushes = "rocksdb.max.background.flushes" + val RocksDbMaxBackgroundFlushesDefault: Int = 2 + val RocksDbMaxBackgroundFlushesDoc = + """Maximum number of concurrent background flushes. + |(https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide#parallelism-options) + """.stripMargin + + val RocksDbIncreaseParallelism = "rocksdb.parallelism" + def RocksDbIncreaseParallelismDefault(): Int = numProcs().toInt + val RocksDbIncreaseParallelismDoc = + """Increases the total number of threads used for flushes and compaction. If rocks seems to be + |an indication of bottleneck, this is a value you want to increase for addressing that.""".stripMargin + + val RocksDbInplaceUpdateSupport = "rocksdb.inplace.update.support" + val RocksDbInplaceUpdateSupportDefault: Boolean = true + val RocksDbInplaceUpdateSupportDoc = + """Enables thread safe updates in place. If true point-in-time consistency using snapshot/iterator + |will not be possible. Set this to true if not using snapshot iterators, otherwise false.""".stripMargin + + val RocksDbAllowConcurrentMemtableWrite = "rocksdb.allow.concurrent.memtable.write" + val RocksDbAllowConcurrentMemtableWriteDefault: Boolean = false + val RocksDbAllowConcurrentMemtableWriteDoc = + """Set true if multiple writers to modify memtables in parallel. This flag is not compatible + |with inplace update support or filter deletes, default should be false unless memtable used + |supports it.""".stripMargin + + val RocksDbEnableWriteThreadAdaptiveYield = "rocksdb.enable.write.thread.adaptive.yield" + val RocksDbEnableWriteThreadAdaptiveYieldDefault: Boolean = false + val RocksDbEnableWriteThreadAdaptiveYieldDoc = + """Set true to enable thread synchronizing with write batch group leader. Concurrent workloads + |can be improved by setting to true.""".stripMargin + + val RocksDbCompactionStyle = "rocksdb.compaction.style" + val RocksDbCompactionStyleDefault = "UNIVERSAL" + val RocksDbCompactionStyleDoc = + """Set compaction style for database. + |UNIVERSAL, LEVEL, FIFO.""".stripMargin + + val RocksDbCompactionStyleOptimize = "rocksdb.compaction.style.optimize" + val RocksDbCompactionStyleOptimizeDefault = true + val RocksDbCompactionStyleOptimizeDoc = + s"""Heavy workloads and big datasets are not the default mode of operation for rocksdb databases. + |Enabling optimization will use rocksdb internal configuration for a range of values calculated. + |The values calculated are based on the flag value "rocksdb.compaction.style.memtable.budget" + |for memory given to optimize performance. Generally this should be true but means other settings + |values might be different from values specified on the commandline. + |(See https://github.com/facebook/rocksdb/blob/master/options/options.cc)""".stripMargin + + val RocksDbMaxBytesForLevelBase = "rocksdb.max.bytes.for.level.base" + val RocksDbMaxBytesForLevelBaseDefault: StorageUnit = 1.gigabyte + val RocksDbMaxBytesForLevelBaseDoc = + """Total size of level 1, should be about the same size as level 0. Lowering this value + |can help control memory usage.""".stripMargin + + val RocksDbLevelCompactionDynamicLevelBytes = "rocksdb.level.compaction.dynamic.level.bytes" + val RocksDbLevelCompactionDynamicLevelBytesDefault: Boolean = true + val RocksDbLevelCompactionDynamicLevelBytesDoc = + """If true, enables rockdb to pick target size for each level dynamically.""".stripMargin + + val RocksDbCompactionStyleMemtableBudget = "rocksdb.compaction.style.memtable.budget" + val RocksDbCompactionStyleMemtableBudgetDefault: StorageUnit = + ColumnFamilyOptionsInterface.DEFAULT_COMPACTION_MEMTABLE_MEMORY_BUDGET.bytes + val RocksDbCompactionStyleMemtableBudgetDoc = + s"""Memory budget in bytes used when "rocksdb.compaction.style.optimize" is true.""" - // BlockCache to be shared by all RocksDB instances created on this instance. Note: That a single Kafka Streams instance may get multiple tasks assigned to it - // and each stateful task will have a separate RocksDB instance created. This cache will be shared across all the tasks. + // BlockCache to be shared by all RocksDB instances created on this instance. + // Note: That a single Kafka Streams instance may get multiple tasks assigned to it + // and each stateful task will have a separate RocksDB instance created. + // This cache will be shared across all the tasks. // See: https://github.com/facebook/rocksdb/wiki/Block-Cache private var SharedBlockCache: LRUCache = _ @@ -58,79 +240,254 @@ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { options: Options, configs: util.Map[String, AnyRef] ): Unit = { + setTableConfiguration(options, configs) + setWriteBufferConfiguration(options, configs) + setOperatingSystemProcessConfiguration(options, configs) + setDatabaseConcurrency(options, configs) + setCompactionConfiguration(options, configs) + setCompression(options, configs) + setInformationLoggingLevel(options, configs) + setStatisticsOptions(options, configs) + } + + private def setWriteBufferConfiguration( + options: Options, + configs: util.Map[String, AnyRef] + ): Unit = { + val dbWriteBufferSize = getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbDatabaseWriteBufferSize, + FinatraRocksDBConfig.RocksDbDatabaseWriteBufferSizeDefault) + + val writeBufferSize = getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbWriteBufferSize, + FinatraRocksDBConfig.RocksDbWriteBufferSizeDefault) + + val minWriteBufferNumberToMerge = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbMinWriteBufferNumberToMerge, + FinatraRocksDBConfig.RocksDbMinWriteBufferNumberToMergeDefault) + + val maxWriteBufferNumber = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbMaxWriteBufferNumber, + FinatraRocksDBConfig.RocksDbMaxWriteBufferNumberDefault) + + options + .setDbWriteBufferSize(dbWriteBufferSize) + .setWriteBufferSize(writeBufferSize) + .setMinWriteBufferNumberToMerge(minWriteBufferNumberToMerge) + .setMaxWriteBufferNumber(maxWriteBufferNumber) + } + + private def setTableConfiguration(options: Options, configs: util.Map[String, AnyRef]): Unit = { if (FinatraRocksDBConfig.SharedBlockCache == null) { val blockCacheSize = - getBytesOrDefault(configs, FinatraRocksDBConfig.RocksDbBlockCacheSizeConfig, 100.megabytes) - val numShardBits = getIntOrDefault(configs, FinatraRocksDBConfig.RocksDbBlockCacheShardBitsConfig, 1) + getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbBlockCacheSizeConfig, + FinatraRocksDBConfig.RocksDbBlockCacheSizeConfigDefault) + val numShardBits = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbBlockCacheShardBitsConfig, + FinatraRocksDBConfig.RocksDbBlockCacheShardBitsConfigDefault) FinatraRocksDBConfig.SharedBlockCache = new LRUCache(blockCacheSize, numShardBits) } val tableConfig = new BlockBasedTableConfig - tableConfig.setBlockSize(16 * 1024) - tableConfig.setBlockCache(FinatraRocksDBConfig.SharedBlockCache) - tableConfig.setFilter(new BloomFilter(10)) - tableConfig.setCacheIndexAndFilterBlocks( - getBooleanOrDefault(configs, FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocks, true)) - tableConfig.setPinL0FilterAndIndexBlocksInCache( - getBooleanOrDefault(configs, FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocks, true)) + val blockSize = getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbTableConfigBlockSize, + FinatraRocksDBConfig.RocksDbTableConfigBlockSizeDefault) + + val bitsPerKey = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbTableConfigBoomFilterKeyBits, + FinatraRocksDBConfig.RocksDbTableConfigBoomFilterKeyBitsDefault) + + val useBlockBasedMode = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbTableConfigBoomFilterMode, + FinatraRocksDBConfig.RocksDbTableConfigBoomFilterModeDefault) + + val cacheIndexAndFilterBlocks = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocks, + FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocksDefault) + + val cachePinL0IndexAndFilterBlocks = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocks, + FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocksDefault) + + tableConfig.setBlockSize(blockSize) + tableConfig.setBlockCache(FinatraRocksDBConfig.SharedBlockCache) + tableConfig.setFilter(new BloomFilter(bitsPerKey, useBlockBasedMode)) + tableConfig.setCacheIndexAndFilterBlocks(cacheIndexAndFilterBlocks) + tableConfig.setPinL0FilterAndIndexBlocksInCache(cachePinL0IndexAndFilterBlocks) options .setTableFormatConfig(tableConfig) + } - options - .setDbWriteBufferSize(0) - .setWriteBufferSize(1.gigabyte.inBytes) //TODO: Make configurable with default value equal to RocksDB default (which is much lower than 1 GB!) - .setMinWriteBufferNumberToMerge(1) - .setMaxWriteBufferNumber(2) + private def setOperatingSystemProcessConfiguration( + options: Options, + configs: util.Map[String, AnyRef] + ): Unit = { + + val bytesPerSync = getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbBytesPerSync, + FinatraRocksDBConfig.RocksDbBytesPerSyncDefault) + + val maxBackgroundCompactions = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbMaxBackgroundCompactions, + FinatraRocksDBConfig.RocksDbMaxBackgroundCompactionsDefault) + + val maxBackgroundFlushes = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbMaxBackgroundFlushes, + FinatraRocksDBConfig.RocksDbMaxBackgroundFlushesDefault) + + val increaseParallelism = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbIncreaseParallelism, + FinatraRocksDBConfig.RocksDbIncreaseParallelismDefault()) options - .setBytesPerSync(1048576) //See: https://github.com/facebook/rocksdb/wiki/Setup-Options-and-Basic-Tuning#other-general-options - .setMaxBackgroundCompactions(4) - .setMaxBackgroundFlushes(2) - .setIncreaseParallelism(Math.max(numProcs().toInt, 2)) - - /* From the docs: "Allows thread-safe inplace updates. If this is true, there is no way to - achieve point-in-time consistency using snapshot or iterator (assuming concurrent updates). - Hence iterator and multi-get will return results which are not consistent as of any point-in-time." */ + .setBytesPerSync(bytesPerSync) + .setMaxBackgroundCompactions(maxBackgroundCompactions) + .setMaxBackgroundFlushes(maxBackgroundFlushes) + .setIncreaseParallelism(Math.max(increaseParallelism, 2)) + } + + private def setDatabaseConcurrency(options: Options, configs: util.Map[String, AnyRef]): Unit = { + val inplaceUpdateSupport = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbInplaceUpdateSupport, + FinatraRocksDBConfig.RocksDbInplaceUpdateSupportDefault) + + val allowConcurrentMemtableWrite = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbAllowConcurrentMemtableWrite, + FinatraRocksDBConfig.RocksDbAllowConcurrentMemtableWriteDefault) + + val enableWriteThreadAdaptiveYield = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbEnableWriteThreadAdaptiveYield, + FinatraRocksDBConfig.RocksDbEnableWriteThreadAdaptiveYieldDefault) + options - .setInplaceUpdateSupport(true) //We set to true since we never have concurrent updates - .setAllowConcurrentMemtableWrite(false) - .setEnableWriteThreadAdaptiveYield(false) + .setInplaceUpdateSupport(inplaceUpdateSupport) + .setAllowConcurrentMemtableWrite(allowConcurrentMemtableWrite) + .setEnableWriteThreadAdaptiveYield(enableWriteThreadAdaptiveYield) + } + + private def setCompactionConfiguration( + options: Options, + configs: util.Map[String, AnyRef] + ): Unit = { + val compactionStyle = CompactionStyle.valueOf(getStringOrDefault( + configs, + FinatraRocksDBConfig.RocksDbCompactionStyle, + FinatraRocksDBConfig.RocksDbCompactionStyleDefault).toUpperCase) + + val compactionStyleOptimize = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbCompactionStyleOptimize, + FinatraRocksDBConfig.RocksDbCompactionStyleOptimizeDefault) + + val maxBytesForLevelBase = getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbMaxBytesForLevelBase, + FinatraRocksDBConfig.RocksDbMaxBytesForLevelBaseDefault) + + val levelCompactionDynamicLevelBytes = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbLevelCompactionDynamicLevelBytes, + FinatraRocksDBConfig.RocksDbLevelCompactionDynamicLevelBytesDefault) + + val optimizeWithMemtableMemoryBudget = getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbCompactionStyleMemtableBudget, + FinatraRocksDBConfig.RocksDbCompactionStyleMemtableBudgetDefault) options - .setCompactionStyle(CompactionStyle.UNIVERSAL) - .setMaxBytesForLevelBase(1.gigabyte.inBytes) - .setLevelCompactionDynamicLevelBytes(true) - .optimizeUniversalStyleCompaction() + .setCompactionStyle(compactionStyle) + .setMaxBytesForLevelBase(maxBytesForLevelBase) + .setLevelCompactionDynamicLevelBytes(levelCompactionDynamicLevelBytes) + + compactionStyle match { + case CompactionStyle.UNIVERSAL if compactionStyleOptimize => + options + .optimizeUniversalStyleCompaction(optimizeWithMemtableMemoryBudget) + case CompactionStyle.LEVEL if compactionStyleOptimize => + options + .optimizeLevelStyleCompaction(optimizeWithMemtableMemoryBudget) + case _ => + } + } - if (configs.get(FinatraRocksDBConfig.RocksDbLZ4Config) == "true") { + private def setCompression(options: Options, configs: util.Map[String, AnyRef]): Unit = { + val lz4Config = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbLZ4Config, + FinatraRocksDBConfig.RocksDbLZ4ConfigDefault + ) + + if (lz4Config) { options.setCompressionType(CompressionType.LZ4_COMPRESSION) } + } + private def setInformationLoggingLevel( + options: Options, + configs: util.Map[String, AnyRef] + ): Unit = { val infoLogLevel = InfoLogLevel.valueOf(getStringOrDefault( configs, - FinatraRocksDBConfig.RocksDbInfoLogLevel, "DEBUG_LEVEL").toUpperCase) + FinatraRocksDBConfig.RocksDbInfoLogLevel, + FinatraRocksDBConfig.RocksDbInfoLogLevelDefault).toUpperCase) + options .setInfoLogLevel(infoLogLevel) - val maxLogFileSize = - getBytesOrDefault(configs, FinatraRocksDBConfig.RocksDbMaxLogFileSize, 50.megabytes) + } + + private def setStatisticsOptions(options: Options, configs: util.Map[String, AnyRef]): Unit = { + val maxLogFileSize = getBytesOrDefault( + configs, + FinatraRocksDBConfig.RocksDbMaxLogFileSize, + FinatraRocksDBConfig.RocksDbMaxLogFileSizeDefault) + options.setMaxLogFileSize(maxLogFileSize) - val keepLogFileNum = - getIntOrDefault(configs, FinatraRocksDBConfig.RocksDbKeepLogFileNum, 10) + val keepLogFileNum = getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbKeepLogFileNum, + FinatraRocksDBConfig.RocksDbKeepLogFileNumDefault) options.setKeepLogFileNum(keepLogFileNum) - if (configs.get(FinatraRocksDBConfig.RocksDbEnableStatistics) == "true") { + val enableStatistics = getBooleanOrDefault( + configs, + FinatraRocksDBConfig.RocksDbEnableStatistics, + FinatraRocksDBConfig.RocksDbEnableStatisticsDefault) + + if (enableStatistics) { val statistics = new Statistics val statsCallback = new RocksDBStatsCallback(FinatraRocksDBConfig.globalStatsReceiver) val statsCollectorInput = new StatsCollectorInput(statistics, statsCallback) val statsCollector = new StatisticsCollector( util.Arrays.asList(statsCollectorInput), - getIntOrDefault(configs, FinatraRocksDBConfig.RocksDbStatCollectionPeriodMs, 60000) - ) + getIntOrDefault( + configs, + FinatraRocksDBConfig.RocksDbStatCollectionPeriodMs, + FinatraRocksDBConfig.RocksDbStatCollectionPeriodMsDefault)) + statsCollector.start() statistics.setStatsLevel(StatsLevel.ALL) @@ -162,7 +519,10 @@ class FinatraRocksDBConfig extends RocksDBConfigSetter with Logging { } } - private def getStringOrDefault(configs: util.Map[String, AnyRef], key: String, default: String): String = { + private def getStringOrDefault( + configs: util.Map[String, AnyRef], + key: String, default: String + ): String = { val valueString = configs.get(key) if (valueString != null) { valueString.toString diff --git a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala index e7b530b5c2..54a0decc14 100644 --- a/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala +++ b/kafka-streams/kafka-streams/src/main/scala/com/twitter/finatra/kafkastreams/config/RocksDbFlags.scala @@ -1,86 +1,211 @@ package com.twitter.finatra.kafkastreams.config -import com.twitter.conversions.StorageUnitOps._ +import com.twitter.app.Flag import com.twitter.inject.server.TwitterServer +import com.twitter.util.StorageUnit trait RocksDbFlags extends TwitterServer { - protected val rocksDbCountsStoreBlockCacheSize = + protected val rocksDbCountsStoreBlockCacheSize: Flag[StorageUnit] = flag( name = FinatraRocksDBConfig.RocksDbBlockCacheSizeConfig, - default = 200.megabytes, - help = - """Size of the rocksdb block cache per task. We recommend that this should be about 1/3 of - |your total memory budget. The remaining free memory can be left for the OS page cache""".stripMargin + default = FinatraRocksDBConfig.RocksDbBlockCacheSizeConfigDefault, + help = FinatraRocksDBConfig.RocksDbBlockCacheSizeConfigDoc ) - protected val rocksDbEnableStatistics = + protected val rocksDbBlockCacheShardBitsConfig: Flag[Int] = + flag( + name = FinatraRocksDBConfig.RocksDbBlockCacheShardBitsConfig, + default = FinatraRocksDBConfig.RocksDbBlockCacheShardBitsConfigDefault, + help = FinatraRocksDBConfig.RocksDbBlockCacheShardBitsConfigDoc + ) + + protected val rocksDbEnableStatistics: Flag[Boolean] = flag( name = FinatraRocksDBConfig.RocksDbEnableStatistics, - default = false, - help = - """Enable RocksDB statistics. Note: RocksDB Statistics could add 5-10% degradation in performance - |(See https://github.com/facebook/rocksdb/wiki/Statistics)""".stripMargin + default = FinatraRocksDBConfig.RocksDbEnableStatisticsDefault, + help = FinatraRocksDBConfig.RocksDbEnableStatisticsDoc ) - protected val rocksDbStatCollectionPeriodMs = + protected val rocksDbStatCollectionPeriodMs: Flag[Int] = flag( name = FinatraRocksDBConfig.RocksDbStatCollectionPeriodMs, - default = 60000, - help = "Set the period in milliseconds for stats collection." + default = FinatraRocksDBConfig.RocksDbStatCollectionPeriodMsDefault, + help = FinatraRocksDBConfig.RocksDbStatCollectionPeriodMsDoc ) - protected val rocksDbEnableLZ4 = + protected val rocksDbEnableLZ4: Flag[Boolean] = flag( name = FinatraRocksDBConfig.RocksDbLZ4Config, - default = false, - help = - "Enable RocksDB LZ4 compression. (See https://github.com/facebook/rocksdb/wiki/Compression)" + default = FinatraRocksDBConfig.RocksDbLZ4ConfigDefault, + help = FinatraRocksDBConfig.RocksDbLZ4ConfigDoc ) - protected val rocksDbInfoLogLevel = + protected val rocksDbInfoLogLevel: Flag[String] = flag( name = FinatraRocksDBConfig.RocksDbInfoLogLevel, - default = "INFO_LEVEL", - help = - """Level of logging for rocksdb LOG file. - |DEBUG_LEVEL, INFO_LEVEL, WARN_LEVEL, ERROR_LEVEL, FATAL_LEVEL, HEADER_LEVEL""".stripMargin + default = FinatraRocksDBConfig.RocksDbInfoLogLevelDefault, + help = FinatraRocksDBConfig.RocksDbInfoLogLevelDoc ) - protected val rocksDbMaxLogFileSize = + protected val rocksDbMaxLogFileSize: Flag[StorageUnit] = flag( name = FinatraRocksDBConfig.RocksDbMaxLogFileSize, - default = 50.megabytes, - help = - s"""Specify the maximal size of the info log file. If the log file is larger then - |${FinatraRocksDBConfig.RocksDbKeepLogFileNum} a new log file will be created.""".stripMargin + default = FinatraRocksDBConfig.RocksDbMaxLogFileSizeDefault, + help = FinatraRocksDBConfig.RocksDbMaxLogFileSizeDoc ) - protected val rocksDbKeepLogFileNum = + protected val rocksDbKeepLogFileNum: Flag[Int] = flag( name = FinatraRocksDBConfig.RocksDbKeepLogFileNum, - default = 10, - help = "Maximal info log files to be kept." + default = FinatraRocksDBConfig.RocksDbKeepLogFileNumDefault, + help = FinatraRocksDBConfig.RocksDbKeepLogFileNumDoc ) - protected val rocksDbCacheIndexAndFilterBlocks = + protected val rocksDbCacheIndexAndFilterBlocks: Flag[Boolean] = flag( name = FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocks, - default = true, - help = - """Store index and filter blocks into the block cache. This bounds the memory usage, - | which is desirable when running in a container. - |(See https://github.com/facebook/rocksdb/wiki/Memory-usage-in-RocksDB#indexes-and-filter-blocks)""".stripMargin + default = FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocksDefault, + help = FinatraRocksDBConfig.RocksDbCacheIndexAndFilterBlocksDoc ) - protected val rocksDbCachePinL0IndexAndFilterBlocks = + protected val rocksDbCachePinL0IndexAndFilterBlocks: Flag[Boolean] = flag( name = FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocks, - default = true, - help = - """Pin level-0 file's index and filter blocks in block cache, to avoid them from being evicted. - | This setting is generally recommended to be turned on along to minimize the negative - | performance impact resulted by turning on RocksDbCacheIndexAndFilterBlocks. - |(See https://github.com/facebook/rocksdb/wiki/Block-Cache#caching-index-and-filter-blocks)""".stripMargin + default = FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocksDefault, + help = FinatraRocksDBConfig.RocksDbCachePinL0IndexAndFilterBlocksDoc + ) + + protected val rocksDbTableConfigBlockSize: Flag[StorageUnit] = + flag( + name = FinatraRocksDBConfig.RocksDbTableConfigBlockSize, + default = FinatraRocksDBConfig.RocksDbTableConfigBlockSizeDefault, + help = FinatraRocksDBConfig.RocksDbTableConfigBlockSizeDoc + ) + + protected val rocksDbTableConfigBoomFilterKeyBits: Flag[Int] = + flag( + name = FinatraRocksDBConfig.RocksDbTableConfigBoomFilterKeyBits, + default = FinatraRocksDBConfig.RocksDbTableConfigBoomFilterKeyBitsDefault, + help = FinatraRocksDBConfig.RocksDbTableConfigBoomFilterKeyBitsDoc + ) + + protected val rocksDbTableConfigBoomFilterMode: Flag[Boolean] = + flag( + name = FinatraRocksDBConfig.RocksDbTableConfigBoomFilterMode, + default = FinatraRocksDBConfig.RocksDbTableConfigBoomFilterModeDefault, + help = FinatraRocksDBConfig.RocksDbTableConfigBoomFilterModeDoc + ) + + protected val rocksDbDatabaseWriteBufferSize: Flag[StorageUnit] = + flag( + name = FinatraRocksDBConfig.RocksDbDatabaseWriteBufferSize, + default = FinatraRocksDBConfig.RocksDbDatabaseWriteBufferSizeDefault, + help = FinatraRocksDBConfig.RocksDbDatabaseWriteBufferSizeDoc + ) + + protected val rocksDbWriteBufferSize: Flag[StorageUnit] = + flag( + name = FinatraRocksDBConfig.RocksDbWriteBufferSize, + default = FinatraRocksDBConfig.RocksDbWriteBufferSizeDefault, + help = FinatraRocksDBConfig.RocksDbWriteBufferSizeDoc + ) + + protected val rocksDbMinWriteBufferNumberToMerge: Flag[Int] = + flag( + name = FinatraRocksDBConfig.RocksDbMinWriteBufferNumberToMerge, + default = FinatraRocksDBConfig.RocksDbMinWriteBufferNumberToMergeDefault, + help = FinatraRocksDBConfig.RocksDbMinWriteBufferNumberToMergeDoc + ) + + protected val rocksDbMaxWriteBufferNumber: Flag[Int] = + flag( + name = FinatraRocksDBConfig.RocksDbMaxWriteBufferNumber, + default = FinatraRocksDBConfig.RocksDbMaxWriteBufferNumberDefault, + help = FinatraRocksDBConfig.RocksDbMaxWriteBufferNumberDoc + ) + + protected val rocksDbBytesPerSync: Flag[StorageUnit] = + flag( + name = FinatraRocksDBConfig.RocksDbBytesPerSync, + default = FinatraRocksDBConfig.RocksDbBytesPerSyncDefault, + help = FinatraRocksDBConfig.RocksDbBytesPerSyncDoc + ) + + protected val rocksDbMaxBackgroundCompactions: Flag[Int] = + flag( + name = FinatraRocksDBConfig.RocksDbMaxBackgroundCompactions, + default = FinatraRocksDBConfig.RocksDbMaxBackgroundCompactionsDefault, + help = FinatraRocksDBConfig.RocksDbMaxBackgroundCompactionsDoc + ) + + protected val rocksDbMaxBackgroundFlushes: Flag[Int] = + flag( + name = FinatraRocksDBConfig.RocksDbMaxBackgroundFlushes, + default = FinatraRocksDBConfig.RocksDbMaxBackgroundFlushesDefault, + help = FinatraRocksDBConfig.RocksDbMaxBackgroundFlushesDoc + ) + + protected val rocksDbIncreaseParallelism: Flag[Int] = + flag( + name = FinatraRocksDBConfig.RocksDbIncreaseParallelism, + default = FinatraRocksDBConfig.RocksDbIncreaseParallelismDefault(), + help = FinatraRocksDBConfig.RocksDbIncreaseParallelismDoc + ) + + protected val rocksDbInplaceUpdateSupport: Flag[Boolean] = + flag( + name = FinatraRocksDBConfig.RocksDbInplaceUpdateSupport, + default = FinatraRocksDBConfig.RocksDbInplaceUpdateSupportDefault, + help = FinatraRocksDBConfig.RocksDbInplaceUpdateSupportDoc + ) + + protected val rocksDbAllowConcurrentMemtableWrite: Flag[Boolean] = + flag( + name = FinatraRocksDBConfig.RocksDbAllowConcurrentMemtableWrite, + default = FinatraRocksDBConfig.RocksDbAllowConcurrentMemtableWriteDefault, + help = FinatraRocksDBConfig.RocksDbAllowConcurrentMemtableWriteDoc + ) + + protected val rocksDbEnableWriteThreadAdaptiveYield: Flag[Boolean] = + flag( + name = FinatraRocksDBConfig.RocksDbEnableWriteThreadAdaptiveYield, + default = FinatraRocksDBConfig.RocksDbEnableWriteThreadAdaptiveYieldDefault, + help = FinatraRocksDBConfig.RocksDbEnableWriteThreadAdaptiveYieldDoc + ) + + protected val rocksDbCompactionStyle: Flag[String] = + flag( + name = FinatraRocksDBConfig.RocksDbCompactionStyle, + default = FinatraRocksDBConfig.RocksDbCompactionStyleDefault, + help = FinatraRocksDBConfig.RocksDbCompactionStyleDoc + ) + + protected val rocksDbCompactionStyleOptimize: Flag[Boolean] = + flag( + name = FinatraRocksDBConfig.RocksDbCompactionStyleOptimize, + default = FinatraRocksDBConfig.RocksDbCompactionStyleOptimizeDefault, + help = FinatraRocksDBConfig.RocksDbCompactionStyleOptimizeDoc + ) + + protected val rocksDbMaxBytesForLevelBase: Flag[StorageUnit] = + flag( + name = FinatraRocksDBConfig.RocksDbMaxBytesForLevelBase, + default = FinatraRocksDBConfig.RocksDbMaxBytesForLevelBaseDefault, + help = FinatraRocksDBConfig.RocksDbMaxBytesForLevelBaseDoc + ) + + protected val rocksDbLevelCompactionDynamicLevelBytes: Flag[Boolean] = + flag( + name = FinatraRocksDBConfig.RocksDbLevelCompactionDynamicLevelBytes, + default = FinatraRocksDBConfig.RocksDbLevelCompactionDynamicLevelBytesDefault, + help = FinatraRocksDBConfig.RocksDbLevelCompactionDynamicLevelBytesDoc + ) + + protected val rocksDbCompactionStyleMemtableBudget: Flag[StorageUnit] = + flag( + name = FinatraRocksDBConfig.RocksDbCompactionStyleMemtableBudget, + default = FinatraRocksDBConfig.RocksDbCompactionStyleMemtableBudgetDefault, + help = FinatraRocksDBConfig.RocksDbCompactionStyleMemtableBudgetDoc ) } diff --git a/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/config/FinatraRocksDBConfigFeatureTest.scala b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/config/FinatraRocksDBConfigFeatureTest.scala new file mode 100644 index 0000000000..db37939980 --- /dev/null +++ b/kafka-streams/kafka-streams/src/test/scala/com/twitter/finatra/kafkastreams/integration/config/FinatraRocksDBConfigFeatureTest.scala @@ -0,0 +1,174 @@ +package com.twitter.finatra.kafkastreams.integration.config + +import com.twitter.finatra.kafka.serde.{UnKeyed, UnKeyedSerde} +import com.twitter.finatra.kafkastreams.KafkaStreamsTwitterServer +import com.twitter.finatra.kafkastreams.config.FinatraRocksDBConfig._ +import com.twitter.finatra.kafkastreams.config.{DefaultTopicConfig, FinatraRocksDBConfig, KafkaStreamsConfig, RocksDbFlags} +import com.twitter.finatra.kafkastreams.test.{FinatraTopologyTester, TopologyFeatureTest} +import com.twitter.finatra.kafkastreams.transformer.FinatraTransformer +import com.twitter.finatra.kafkastreams.transformer.domain.Time +import org.apache.kafka.common.serialization.Serdes +import org.apache.kafka.streams.StreamsBuilder +import org.apache.kafka.streams.kstream.{Consumed, Produced} +import org.apache.kafka.streams.state.Stores +import org.joda.time.DateTime + +class FinatraRocksDBConfigFeatureTest extends TopologyFeatureTest { + private val appId = "no-op" + private val stateStoreName = "test-state-store" + + private val kafkaStreamsTwitterServer: KafkaStreamsTwitterServer = new KafkaStreamsTwitterServer with RocksDbFlags { + override val name: String = appId + override protected def configureKafkaStreams(builder: StreamsBuilder): Unit = { + builder.addStateStore( + Stores + .keyValueStoreBuilder( + Stores.persistentKeyValueStore(stateStoreName), + UnKeyedSerde, + Serdes.String + ).withLoggingEnabled(DefaultTopicConfig.FinatraChangelogConfig) + ) + + val finatraTransformerSupplier = () => + new FinatraTransformer[UnKeyed, String, UnKeyed, String](statsReceiver = statsReceiver) { + override def onInit(): Unit = { } + override def onClose(): Unit = { } + override def onMessage(messageTime: Time, unKeyed: UnKeyed, string: String): Unit = { } + } + + builder.asScala + .stream("source")(Consumed.`with`(UnKeyedSerde, Serdes.String)) + .transform(finatraTransformerSupplier, stateStoreName) + .to("sink")(Produced.`with`(UnKeyedSerde, Serdes.String)) + } + + override def streamsProperties(config: KafkaStreamsConfig): KafkaStreamsConfig = { + super + .streamsProperties(config) + .rocksDbConfigSetter[FinatraRocksDBConfig] + .withConfig(RocksDbBlockCacheSizeConfig, rocksDbCountsStoreBlockCacheSize()) + .withConfig(RocksDbBlockCacheShardBitsConfig, rocksDbBlockCacheShardBitsConfig()) + .withConfig(RocksDbLZ4Config, rocksDbEnableLZ4().toString) + .withConfig(RocksDbEnableStatistics, rocksDbEnableStatistics().toString) + .withConfig(RocksDbStatCollectionPeriodMs, rocksDbStatCollectionPeriodMs()) + .withConfig(RocksDbInfoLogLevel, rocksDbInfoLogLevel()) + .withConfig(RocksDbMaxLogFileSize, rocksDbMaxLogFileSize()) + .withConfig(RocksDbKeepLogFileNum, rocksDbKeepLogFileNum()) + .withConfig(RocksDbCacheIndexAndFilterBlocks, rocksDbCacheIndexAndFilterBlocks()) + .withConfig(RocksDbCachePinL0IndexAndFilterBlocks, rocksDbCachePinL0IndexAndFilterBlocks()) + .withConfig(RocksDbTableConfigBlockSize, rocksDbTableConfigBlockSize()) + .withConfig(RocksDbTableConfigBoomFilterKeyBits, rocksDbTableConfigBoomFilterKeyBits()) + .withConfig(RocksDbTableConfigBoomFilterMode, rocksDbTableConfigBoomFilterMode()) + .withConfig(RocksDbDatabaseWriteBufferSize, rocksDbDatabaseWriteBufferSize()) + .withConfig(RocksDbWriteBufferSize, rocksDbWriteBufferSize()) + .withConfig(RocksDbMinWriteBufferNumberToMerge, rocksDbMinWriteBufferNumberToMerge()) + .withConfig(RocksDbMaxWriteBufferNumber, rocksDbMaxWriteBufferNumber()) + .withConfig(RocksDbBytesPerSync, rocksDbBytesPerSync()) + .withConfig(RocksDbMaxBackgroundCompactions, rocksDbMaxBackgroundCompactions()) + .withConfig(RocksDbMaxBackgroundFlushes, rocksDbMaxBackgroundFlushes()) + .withConfig(RocksDbIncreaseParallelism, rocksDbIncreaseParallelism()) + .withConfig(RocksDbInplaceUpdateSupport, rocksDbInplaceUpdateSupport()) + .withConfig(RocksDbAllowConcurrentMemtableWrite, rocksDbAllowConcurrentMemtableWrite()) + .withConfig(RocksDbEnableWriteThreadAdaptiveYield, rocksDbEnableWriteThreadAdaptiveYield()) + .withConfig(RocksDbCompactionStyle, rocksDbCompactionStyle()) + .withConfig(RocksDbCompactionStyleOptimize, rocksDbCompactionStyleOptimize()) + .withConfig(RocksDbMaxBytesForLevelBase, rocksDbMaxBytesForLevelBase()) + .withConfig(RocksDbLevelCompactionDynamicLevelBytes, rocksDbLevelCompactionDynamicLevelBytes()) + .withConfig(RocksDbCompactionStyleMemtableBudget, rocksDbCompactionStyleMemtableBudget()) + } + } + + private val _topologyTester = FinatraTopologyTester( + kafkaApplicationId = appId, + server = kafkaStreamsTwitterServer, + startingWallClockTime = DateTime.now, + flags = Map( + "rocksdb.block.cache.size" -> "1.byte", + "rocksdb.block.cache.shard.bits" -> "2", + "rocksdb.lz4" -> "true", + "rocksdb.statistics" -> "true", + "rocksdb.statistics.collection.period.ms" -> "60001", + "rocksdb.log.info.level" -> "INFO_LEVEL", + "rocksdb.log.max.file.size" -> "2.bytes", + "rocksdb.log.keep.file.num" -> "3", + "rocksdb.cache.index.and.filter.blocks" -> "false", + "rocksdb.cache.pin.l0.index.and.filter.blocks" -> "false", + "rocksdb.tableconfig.block.size" -> "4.bytes", + "rocksdb.tableconfig.bloomfilter.key.bits" -> "5", + "rocksdb.tableconfig.bloomfilter.mode" -> "false", + "rocksdb.db.write.buffer.size" -> "6.bytes", + "rocksdb.write.buffer.size" -> "7.bytes", + "rocksdb.min.write.buffer.num.merge" -> "8", + "rocksdb.max.write.buffer.num" -> "9", + "rocksdb.bytes.per.sync" -> "10.bytes", + "rocksdb.max.background.compactions" -> "11", + "rocksdb.max.background.flushes" -> "12", + "rocksdb.parallelism" -> "2", + "rocksdb.inplace.update.support" -> "false", + "rocksdb.allow.concurrent.memtable.write" -> "true", + "rocksdb.enable.write.thread.adaptive.yield" -> "true", + "rocksdb.compaction.style" -> "UNIVERSAL", + "rocksdb.compaction.style.optimize" -> "false", + "rocksdb.max.bytes.for.level.base" -> "13.bytes", + "rocksdb.level.compaction.dynamic.level.bytes" -> "false", + "rocksdb.compaction.style.memtable.budget" -> "14.bytes" + ) + ) + + override protected def topologyTester: FinatraTopologyTester = { + _topologyTester + } + + override def beforeEach(): Unit = { + super.beforeEach() + topologyTester.reset() + topologyTester.topic( + "source", + UnKeyedSerde, + Serdes.String + ) + topologyTester.topic( + "sink", + UnKeyedSerde, + Serdes.String + ) + } + + test("rocksdb properties") { + val properties = topologyTester.properties + properties.getProperty("rocksdb.config.setter") should be( + "com.twitter.finatra.kafkastreams.config.FinatraRocksDBConfig") + properties.getProperty("rocksdb.block.cache.size") should be("1") + properties.getProperty("rocksdb.block.cache.shard.bits") should be("2") + properties.getProperty("rocksdb.lz4") should be("true") + properties.getProperty("rocksdb.statistics") should be("true") + properties.getProperty("rocksdb.statistics.collection.period.ms") should be("60001") + properties.getProperty("rocksdb.log.info.level") should be("INFO_LEVEL") + properties.getProperty("rocksdb.log.max.file.size") should be("2") + properties.getProperty("rocksdb.log.keep.file.num") should be("3") + properties.getProperty("rocksdb.cache.index.and.filter.blocks") should be("false") + properties.getProperty("rocksdb.cache.pin.l0.index.and.filter.blocks") should be("false") + properties.getProperty("rocksdb.tableconfig.block.size") should be("4") + properties.getProperty("rocksdb.tableconfig.bloomfilter.key.bits") should be("5") + properties.getProperty("rocksdb.tableconfig.bloomfilter.mode") should be("false") + properties.getProperty("rocksdb.db.write.buffer.size") should be("6") + properties.getProperty("rocksdb.write.buffer.size") should be("7") + properties.getProperty("rocksdb.min.write.buffer.num.merge") should be("8") + properties.getProperty("rocksdb.max.write.buffer.num") should be("9") + properties.getProperty("rocksdb.bytes.per.sync") should be("10") + properties.getProperty("rocksdb.max.background.compactions") should be("11") + properties.getProperty("rocksdb.max.background.flushes") should be("12") + properties.getProperty("rocksdb.parallelism") should be("2") + properties.getProperty("rocksdb.inplace.update.support") should be("false") + properties.getProperty("rocksdb.allow.concurrent.memtable.write") should be("true") + properties.getProperty("rocksdb.enable.write.thread.adaptive.yield") should be("true") + properties.getProperty("rocksdb.compaction.style") should be("UNIVERSAL") + properties.getProperty("rocksdb.compaction.style.optimize") should be("false") + properties.getProperty("rocksdb.max.bytes.for.level.base") should be("13") + properties.getProperty("rocksdb.level.compaction.dynamic.level.bytes") should be("false") + properties.getProperty("rocksdb.compaction.style.memtable.budget") should be("14") + + topologyTester.driver + .getKeyValueStore[UnKeyed, String](stateStoreName) shouldNot be(null) + } +} diff --git a/kafka/src/main/scala/com/twitter/finatra/kafka/config/KafkaConfig.scala b/kafka/src/main/scala/com/twitter/finatra/kafka/config/KafkaConfig.scala index 24ec5d7a20..59fef3a5da 100644 --- a/kafka/src/main/scala/com/twitter/finatra/kafka/config/KafkaConfig.scala +++ b/kafka/src/main/scala/com/twitter/finatra/kafka/config/KafkaConfig.scala @@ -44,6 +44,12 @@ trait KafkaConfigMethods[Self] extends KafkaConfig { def withConfig(key: String, value: String): This = fromConfigMap(configMap + (key -> value)) + def withConfig(key: String, value: Int): This = + fromConfigMap(configMap + (key -> value.toString)) + + def withConfig(key: String, value: Boolean): This = + fromConfigMap(configMap + (key -> value.toString)) + def withConfig(key: String, value: Duration): This = { fromConfigMap(configMap + (key -> value.inMilliseconds.toString)) } From 69b07f850246faae9fce184d3af31c2eaad66fc8 Mon Sep 17 00:00:00 2001 From: Christopher Coco Date: Thu, 21 Feb 2019 18:25:34 +0000 Subject: [PATCH 44/45] finatra: Update lifecycle documentation image Problem/Solution The lifecycle diagram has a step out of order. Update the image and also include the source xml file for editing in the future. JIRA Issues: CSL-7679 Differential Revision: https://phabricator.twitter.biz/D276461 --- doc/README.md | 25 ------------------- doc/src/sphinx/FinatraLifecycle.svg | 2 ++ doc/src/sphinx/_static/FinatraLifecycle.png | Bin 138414 -> 69861 bytes doc/src/sphinx/_static/finatra_logo_text.png | Bin 125392 -> 0 bytes doc/src/sphinx/_static/test-classes.png | Bin 18559 -> 0 bytes 5 files changed, 2 insertions(+), 25 deletions(-) delete mode 100644 doc/README.md create mode 100644 doc/src/sphinx/FinatraLifecycle.svg delete mode 100644 doc/src/sphinx/_static/finatra_logo_text.png delete mode 100644 doc/src/sphinx/_static/test-classes.png diff --git a/doc/README.md b/doc/README.md deleted file mode 100644 index edb6f7574f..0000000000 --- a/doc/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Finatra Documentation using [Github Pages](https://pages.github.com/) - -Fast, testable, Scala services built on [TwitterServer][twitter-server] and [Finagle][finagle]. - -Deploy using the `pushsite.bash` script ---------------------------------------- - -``` -$ ./pushsite.bash -``` - -* Changes should be visible at [https://twitter.github.io/finatra](https://twitter.github.io/finatra). - -
-
-
-
-
- -#### Copyright 2013-2018 Twitter, Inc. - -Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - -[twitter-server]: https://github.com/twitter/twitter-server -[finagle]: https://github.com/twitter/finagle diff --git a/doc/src/sphinx/FinatraLifecycle.svg b/doc/src/sphinx/FinatraLifecycle.svg new file mode 100644 index 0000000000..6dd3784d9f --- /dev/null +++ b/doc/src/sphinx/FinatraLifecycle.svg @@ -0,0 +1,2 @@ + +
Add admin http server routes
<span>Add admin http server routes</span>
AdminHttpServer#premain
<span>AdminHttpServer#premain</span>
Start server
<span>Start server</span>
TwitterServer#main
TwitterServer#main
TwitterModule#singletonStartup
TwitterModule#singletonStartup
Load modules
Load modules
Modules post injector startup
Modules post injector startup
Warmup
Warmup
Before post warmup
Before post warmup
Post Warmup
Post Warmup
1. announce admin interface
2. add user defined admin routes
3. bind external port(s)
4. announce external interface(s)
[Not supported by viewer]
After Post Warmup
After Post Warmup
enable health endpoint
[Not supported by viewer]
Set app started
Set app started
App#run
App#run<br>
Server#start
Server#start<br>
bind admin port
bind admin port
trigger GC for 
object promotion
[Not supported by viewer]
Post injector startup
Post injector startup
1. resolve Finagle clients
2. configure Server routing
[Not supported by viewer]
install Guice modules
and create injector
[Not supported by viewer]
\ No newline at end of file diff --git a/doc/src/sphinx/_static/FinatraLifecycle.png b/doc/src/sphinx/_static/FinatraLifecycle.png index eb09d0a47c2eeb5ce5403e7017cda5bb81d94e46..e483fc610e00278bb80bc8f3ad3939d25ad97282 100644 GIT binary patch literal 69861 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 literal 138414 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

g3l$Bq%p^^WA_gQ7g9qfx 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 diff --git a/doc/src/sphinx/_static/finatra_logo_text.png b/doc/src/sphinx/_static/finatra_logo_text.png deleted file mode 100644 index be14e04e04546890fbf9c368d42868532b8f4596..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 125392 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 diff --git a/doc/src/sphinx/_static/test-classes.png b/doc/src/sphinx/_static/test-classes.png deleted file mode 100644 index c556d3277876b3da58714984aa175131b8602c7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18559 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 Date: Thu, 21 Feb 2019 18:48:01 +0000 Subject: [PATCH 45/45] twitter-oss: Prepare OSS libraries for release 19.2.0 Problem We want to release the next versions of our Twitter OSS libraries 19.2.0 - util - scrooge - finagle - twitter-server - finatra Solution Prepare libraries for their next releases. Differential Revision: https://phabricator.twitter.biz/D276537 --- CHANGELOG.rst | 3 +++ README.md | 4 ++-- build.sbt | 2 +- project/plugins.sbt | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 60d7235a0d..adef48448d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,9 @@ Note that ``RB_ID=#`` and ``PHAB_ID=#`` correspond to associated message in comm Unreleased ---------- +19.2.0 +------- + Added ~~~~~ * finatra-kafka: Expose timeout duration in FinagleKafkaConsumerBuilder dest(). ``PHAB_ID=D269701`` diff --git a/README.md b/README.md index 567bab45f3..3925f3af9c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Finatra -[![Build Status](https://secure.travis-ci.org/twitter/finatra.png?branch=develop)](https://travis-ci.org/twitter/finatra?branch=develop) -[![Test Coverage](https://codecov.io/github/twitter/finatra/coverage.svg?branch=develop)](https://codecov.io/github/twitter/finatra?branch=develop) +[![Build Status](https://secure.travis-ci.org/twitter/finatra.png?branch=master)](https://travis-ci.org/twitter/finatra?branch=master) +[![Test Coverage](https://codecov.io/github/twitter/finatra/coverage.svg?branch=master)](https://codecov.io/github/twitter/finatra?branch=master) [![Project status](https://img.shields.io/badge/status-active-brightgreen.svg)](#status) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.twitter/finatra-http_2.12/badge.svg)][maven-central] [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/twitter/finatra) diff --git a/build.sbt b/build.sbt index 388187d13b..4eb0413774 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ import scoverage.ScoverageKeys concurrentRestrictions in Global += Tags.limit(Tags.Test, 1) // All Twitter library releases are date versioned as YY.MM.patch -val releaseVersion = "19.2.0-SNAPSHOT" +val releaseVersion = "19.2.0" lazy val buildSettings = Seq( version := releaseVersion, diff --git a/project/plugins.sbt b/project/plugins.sbt index 677a099bb0..ff035bf2db 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,7 +3,7 @@ resolvers ++= Seq( Resolver.sonatypeRepo("snapshots") ) -val releaseVersion = "19.2.0-SNAPSHOT" +val releaseVersion = "19.2.0" addSbtPlugin("com.twitter" % "scrooge-sbt-plugin" % releaseVersion)

1;VhiV5z$+M&={>Dy81XSyuVYL^gVweb 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