From be3e26200d6985f16154fb0b9df2c01f0e63e097 Mon Sep 17 00:00:00 2001 From: nikhilsimha Date: Wed, 21 Feb 2024 12:33:07 -0800 Subject: [PATCH 01/17] prep for renaming master branch to main --- CONTRIBUTE.md | 36 +++++++++---------- README.md | 14 ++++---- .../aggregator/base/SimpleAggregators.scala | 2 +- .../TwoStackLiteAggregationBuffer.scala | 2 +- airflow/helpers.py | 2 +- api/py/ai/chronon/group_by.py | 2 +- api/py/setup.py | 2 +- build.sbt | 4 +-- build.sh | 4 +-- devnotes.md | 8 ++--- docs/source/Code_Guidelines.md | 2 +- .../authoring_features/ChainingFeatures.md | 4 +-- docs/source/authoring_features/GroupBy.md | 12 +++---- docs/source/authoring_features/Join.md | 2 +- docs/source/authoring_features/Source.md | 4 +-- .../source/authoring_features/StagingQuery.md | 4 +-- docs/source/getting_started/Tutorial.md | 14 ++++---- docs/source/setup/Data_Integration.md | 6 ++-- docs/source/setup/Developer_Setup.md | 2 +- docs/source/setup/Online_Integration.md | 16 ++++----- docs/source/setup/Orchestration.md | 16 ++++----- docs/source/test_deploy_serve/Serve.md | 12 +++---- proposals/CHIP-1.md | 6 ++-- 23 files changed, 88 insertions(+), 88 deletions(-) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index d353c9d11..d7e483511 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -183,37 +183,37 @@ Below is a list of resources that can be useful for development and debugging. ## Docs (Docsite)[https://chronon.ai] -(doc directory)[https://github.com/airbnb/chronon/tree/master/docs/source] +(doc directory)[https://github.com/airbnb/chronon/tree/main/docs/source] (Code of conduct)[TODO] ## Links: (pip project)[https://pypi.org/project/chronon-ai/] -(maven central)[https://mvnrepository.com/artifact/ai.chronon/]: (publishing)[https://github.com/airbnb/chronon/blob/master/devnotes.md#publishing-all-the-artifacts-of-chronon] -(Docsite: publishing)[https://github.com/airbnb/chronon/blob/master/devnotes.md#chronon-artifacts-publish-process] +(maven central)[https://mvnrepository.com/artifact/ai.chronon/]: (publishing)[https://github.com/airbnb/chronon/blob/main/devnotes.md#publishing-all-the-artifacts-of-chronon] +(Docsite: publishing)[https://github.com/airbnb/chronon/blob/main/devnotes.md#chronon-artifacts-publish-process] ## Code Pointers -Api - (Thrift)[https://github.com/airbnb/chronon/blob/master/api/thrift/api.thrift#L180], (Python)[https://github.com/airbnb/chronon/blob/master/api/py/ai/chronon/group_by.py] -(CLI driver entry point for job launching.)[https://github.com/airbnb/chronon/blob/master/spark/src/main/scala/ai/chronon/spark/Driver.scala] +Api - (Thrift)[https://github.com/airbnb/chronon/blob/main/api/thrift/api.thrift#L180], (Python)[https://github.com/airbnb/chronon/blob/main/api/py/ai/chronon/group_by.py] +(CLI driver entry point for job launching.)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/Driver.scala] **Offline flows that produce hive tables or file output** -(GroupBy)[https://github.com/airbnb/chronon/blob/master/spark/src/main/scala/ai/chronon/spark/GroupBy.scala] -(Staging Query)[https://github.com/airbnb/chronon/blob/master/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala] -(Join backfills)[https://github.com/airbnb/chronon/blob/master/spark/src/main/scala/ai/chronon/spark/Join.scala] -(Metadata Export)[https://github.com/airbnb/chronon/blob/master/spark/src/main/scala/ai/chronon/spark/MetadataExporter.scala] +(GroupBy)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/GroupBy.scala] +(Staging Query)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala] +(Join backfills)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/Join.scala] +(Metadata Export)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/MetadataExporter.scala] Online flows that update and read data & metadata from the kvStore -(GroupBy window tail upload )[https://github.com/airbnb/chronon/blob/master/spark/src/main/scala/ai/chronon/spark/GroupByUpload.scala] -(Streaming window head upload)[https://github.com/airbnb/chronon/blob/master/spark/src/main/scala/ai/chronon/spark/streaming/GroupBy.scala] -(Fetching)[https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Fetcher.scala] +(GroupBy window tail upload )[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/GroupByUpload.scala] +(Streaming window head upload)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/streaming/GroupBy.scala] +(Fetching)[https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Fetcher.scala] Aggregations -(time based aggregations)[https://github.com/airbnb/chronon/blob/master/aggregator/src/main/scala/ai/chronon/aggregator/base/TimedAggregators.scala] -(time independent aggregations)[https://github.com/airbnb/chronon/blob/master/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala] -(integration point with rest of chronon)[https://github.com/airbnb/chronon/blob/master/aggregator/src/main/scala/ai/chronon/aggregator/row/ColumnAggregator.scala#L223] -(Windowing)[https://github.com/airbnb/chronon/tree/master/aggregator/src/main/scala/ai/chronon/aggregator/windowing] +(time based aggregations)[https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/base/TimedAggregators.scala] +(time independent aggregations)[https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala] +(integration point with rest of chronon)[https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/row/ColumnAggregator.scala#L223] +(Windowing)[https://github.com/airbnb/chronon/tree/main/aggregator/src/main/scala/ai/chronon/aggregator/windowing] **Testing** -(Testing - sbt commands)[https://github.com/airbnb/chronon/blob/master/devnotes.md#testing] +(Testing - sbt commands)[https://github.com/airbnb/chronon/blob/main/devnotes.md#testing] (Automated testing - circle-ci pipelines)[https://app.circleci.com/pipelines/github/airbnb/chronon] -(Dev Setup)[https://github.com/airbnb/chronon/blob/master/devnotes.md#prerequisites] +(Dev Setup)[https://github.com/airbnb/chronon/blob/main/devnotes.md#prerequisites] diff --git a/README.md b/README.md index 65609bdfc..8d4a1f11a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Does not include: ## Setup -To get started with the Chronon, all you need to do is download the [docker-compose.yml](https://github.com/airbnb/chronon/blob/master/docker-compose.yml) file and run it locally: +To get started with the Chronon, all you need to do is download the [docker-compose.yml](https://github.com/airbnb/chronon/blob/main/docker-compose.yml) file and run it locally: ```bash curl -o docker-compose.yml https://chronon.ai/docker-compose.yml @@ -74,7 +74,7 @@ In this example, let's assume that we're a large online retailer, and we've dete ## Raw data sources -Fabricated raw data is included in the [data](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/data) directory. It includes four tables: +Fabricated raw data is included in the [data](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/data) directory. It includes four tables: 1. Users - includes basic information about users such as account created date; modeled as a batch data source that updates daily 2. Purchases - a log of all purchases by users; modeled as a log table with a streaming (i.e. Kafka) event-bus counterpart @@ -141,11 +141,11 @@ v1 = GroupBy( ) ``` -See the whole code file here: [purchases GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/purchases.py). This is also in your docker image. We'll be running computation for it and the other GroupBys in [Step 3 - Backfilling Data](#step-3---backfilling-data). +See the whole code file here: [purchases GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/purchases.py). This is also in your docker image. We'll be running computation for it and the other GroupBys in [Step 3 - Backfilling Data](#step-3---backfilling-data). **Feature set 2: Returns data features** -We perform a similar set of aggregations on returns data in the [returns GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/returns.py). The code is not included here because it looks similar to the above example. +We perform a similar set of aggregations on returns data in the [returns GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/returns.py). The code is not included here because it looks similar to the above example. **Feature set 3: User data features** @@ -167,7 +167,7 @@ v1 = GroupBy( ) ``` -Taken from the [users GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/users.py). +Taken from the [users GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/users.py). ### Step 2 - Join the features together @@ -200,7 +200,7 @@ v1 = Join( ) ``` -Taken from the [training_set Join](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/joins/quickstart/training_set.py). +Taken from the [training_set Join](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/joins/quickstart/training_set.py). The `left` side of the join is what defines the timestamps and primary keys for the backfill (notice that it is built on top of the `checkout` event, as dictated by our use case). @@ -370,7 +370,7 @@ Using chronon for your feature engineering work simplifies and improves your ML 4. Chronon exposes easy endpoints for feature fetching. 5. Consistency is guaranteed and measurable. -For a more detailed view into the benefits of using Chronon, see [Benefits of Chronon documentation](https://github.com/airbnb/chronon/tree/master?tab=readme-ov-file#benefits-of-chronon-over-other-approaches). +For a more detailed view into the benefits of using Chronon, see [Benefits of Chronon documentation](https://github.com/airbnb/chronon/tree/main?tab=readme-ov-file#benefits-of-chronon-over-other-approaches). # Benefits of Chronon over other approaches diff --git a/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala b/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala index aa9d2979f..f2501804a 100644 --- a/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala +++ b/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala @@ -411,7 +411,7 @@ class FrequentItems[T: FrequentItemsFriendly](val mapSize: Int, val errorType: E // See: Back to the future: an even more nearly optimal cardinality estimation algorithm, 2017 // https://arxiv.org/abs/1708.06839 // refer to the chart here to tune your sketch size with lgK -// https://github.com/apache/incubator-datasketches-java/blob/master/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 +// https://github.com/apache/incubator-datasketches-java/blob/main/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 // default is about 1200 bytes class ApproxDistinctCount[Input: CpcFriendly](lgK: Int = 8) extends SimpleAggregator[Input, CpcSketch, Long] { override def outputType: DataType = LongType diff --git a/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala b/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala index a9ad3f2bc..bd039f875 100644 --- a/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala +++ b/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala @@ -22,7 +22,7 @@ import java.util case class BankersEntry[IR](var value: IR, ts: Long) -// ported from: https://github.com/IBM/sliding-window-aggregators/blob/master/rust/src/two_stacks_lite/mod.rs with some +// ported from: https://github.com/IBM/sliding-window-aggregators/blob/main/rust/src/two_stacks_lite/mod.rs with some // modification to work with simple aggregator class TwoStackLiteAggregationBuffer[Input, IR >: Null, Output >: Null](aggregator: SimpleAggregator[Input, IR, Output], maxSize: Int) { diff --git a/airflow/helpers.py b/airflow/helpers.py index 34e262beb..15dabc64a 100644 --- a/airflow/helpers.py +++ b/airflow/helpers.py @@ -66,7 +66,7 @@ def safe_part(p): return re.sub("[^A-Za-z0-9_]", "__", safe_name) -# https://github.com/airbnb/chronon/blob/master/api/src/main/scala/ai/chronon/api/Extensions.scala +# https://github.com/airbnb/chronon/blob/main/api/src/main/scala/ai/chronon/api/Extensions.scala def sanitize(name): return re.sub("[^a-zA-Z0-9_]", "_", name) diff --git a/api/py/ai/chronon/group_by.py b/api/py/ai/chronon/group_by.py index 3b95bbba0..18194f60f 100644 --- a/api/py/ai/chronon/group_by.py +++ b/api/py/ai/chronon/group_by.py @@ -61,7 +61,7 @@ class Operation: APPROX_UNIQUE_COUNT = ttypes.Operation.APPROX_UNIQUE_COUNT # refer to the chart here to tune your sketch size with lgK # default is 8 - # https://github.com/apache/incubator-datasketches-java/blob/master/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 + # https://github.com/apache/incubator-datasketches-java/blob/main/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 APPROX_UNIQUE_COUNT_LGK = collector(ttypes.Operation.APPROX_UNIQUE_COUNT) UNIQUE_COUNT = ttypes.Operation.UNIQUE_COUNT COUNT = ttypes.Operation.COUNT diff --git a/api/py/setup.py b/api/py/setup.py index 208b8152a..f38e6c753 100644 --- a/api/py/setup.py +++ b/api/py/setup.py @@ -27,7 +27,7 @@ __version__ = "local" -__branch__ = "master" +__branch__ = "main" def get_version(): version_str = os.environ.get("CHRONON_VERSION_STR", __version__) branch_str = os.environ.get("CHRONON_BRANCH_STR", __branch__) diff --git a/build.sbt b/build.sbt index b0db70fd1..a35443db0 100644 --- a/build.sbt +++ b/build.sbt @@ -94,8 +94,8 @@ git.gitTagToVersionNumber := { tag: String => // Git plugin will automatically add SNAPSHOT for dirty workspaces so remove it to avoid duplication. val versionStr = if (git.gitUncommittedChanges.value) version.value.replace("-SNAPSHOT", "") else version.value val branchTag = git.gitCurrentBranch.value.replace("/", "-") - if (branchTag == "master") { - // For master branches, we tag the packages as - + if (branchTag == "main" || branchTag = "master") { + // For main branches, we tag the packages as - Some(s"${versionStr}") } else { // For user branches, we tag the packages as -- diff --git a/build.sh b/build.sh index f403a035a..8a1ca7abb 100755 --- a/build.sh +++ b/build.sh @@ -7,8 +7,8 @@ set -euxo pipefail BRANCH="$(git rev-parse --abbrev-ref HEAD)" -if [[ "$BRANCH" != "master" ]]; then - echo "$(tput bold) You are not on master!" +if [[ "$BRANCH" != "main" ]]; then + echo "$(tput bold) You are not on main branch!" echo "$(tput sgr0) Are you sure you want to release? (y to continue)" read response if [[ "$response" != "y" ]]; then diff --git a/devnotes.md b/devnotes.md index d862558ad..88bbe7f82 100644 --- a/devnotes.md +++ b/devnotes.md @@ -104,7 +104,7 @@ sbt python_api Note: This will create the artifacts with the version specific naming specified under `version.sbt` ```text -Builds on master will result in: +Builds on main branch will result in: -.jar [JARs] chronon_2.11-0.7.0-SNAPSHOT.jar [Python] chronon-ai-0.7.0-SNAPSHOT.tar.gz @@ -227,15 +227,15 @@ This command will take into the account of `version.sbt` and handles a series of 2. Select "refresh" and "release" 3. Wait for 30 mins to sync to [maven](https://repo1.maven.org/maven2/) or [sonatype UI](https://search.maven.org/search?q=g:ai.chronon) 4. Push the local release commits (DO NOT SQUASH), and the new tag created from step 1 to Github. - 1. chronon repo disallow push to master directly, so instead push commits to a branch `git push origin master:your-name--release-xxx` + 1. chronon repo disallow push to main branch directly, so instead push commits to a branch `git push origin main:your-name--release-xxx` 2. your PR should contain exactly two commits, 1 setting the release version, 1 setting the new snapshot version. 3. make sure to use **Rebase pull request** instead of the regular Merge or Squash options when merging the PR. -5. Push release tag to master branch +5. Push release tag to main branch 1. tag new version to release commit `Setting version to 0.0.xx`. If not already tagged, can be added by ``` git tag -fa v0.0.xx ``` - 2. push tag to master + 2. push tag ``` git push origin ``` diff --git a/docs/source/Code_Guidelines.md b/docs/source/Code_Guidelines.md index 8d23637eb..ccfa2a6ba 100644 --- a/docs/source/Code_Guidelines.md +++ b/docs/source/Code_Guidelines.md @@ -69,4 +69,4 @@ in terms of power. Also Spark APIs are mainly in Scala2. Every new behavior should be unit-tested. We have implemented a fuzzing framework that can produce data randomly as scala objects or spark tables - [see](../../spark/src/test/scala/ai/chronon/spark/test/DataFrameGen.scala). Use it for testing. -Python code is also covered by tests - [see](https://github.com/airbnb/chronon/tree/master/api/py/test). \ No newline at end of file +Python code is also covered by tests - [see](https://github.com/airbnb/chronon/tree/main/api/py/test). \ No newline at end of file diff --git a/docs/source/authoring_features/ChainingFeatures.md b/docs/source/authoring_features/ChainingFeatures.md index 54bdc69e5..8abac087d 100644 --- a/docs/source/authoring_features/ChainingFeatures.md +++ b/docs/source/authoring_features/ChainingFeatures.md @@ -79,9 +79,9 @@ enriched_listings = Join( ``` ### Configuration Example -[Chaining GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/sample_team/sample_chaining_group_by.py) +[Chaining GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/sample_team/sample_chaining_group_by.py) -[Chaining Join](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/joins/sample_team/sample_chaining_join.py) +[Chaining Join](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/joins/sample_team/sample_chaining_join.py) ## Clarifications - The goal of chaining is to use output of a Join as input to downstream computations like GroupBy or a Join. As of today we support the case 1 and case 2 in future plan diff --git a/docs/source/authoring_features/GroupBy.md b/docs/source/authoring_features/GroupBy.md index 07f724f94..6c39480fb 100644 --- a/docs/source/authoring_features/GroupBy.md +++ b/docs/source/authoring_features/GroupBy.md @@ -27,7 +27,7 @@ This can be achieved by using the output of one `GroupBy` as the input to the ne ## Supported aggregations -All supported aggregations are defined [here](https://github.com/airbnb/chronon/blob/master/api/thrift/api.thrift#L51). +All supported aggregations are defined [here](https://github.com/airbnb/chronon/blob/main/api/thrift/api.thrift#L51). Chronon supports powerful aggregation patterns and the section below goes into detail of the properties and behaviors of aggregations. @@ -181,7 +181,7 @@ If you look at the parameters column in the above table - you will see `k`. For approx_unique_count and approx_percentile - k stands for the size of the `sketch` - the larger this is, the more accurate and expensive to compute the results will be. Mapping between k and size for approx_unique_count is -[here](https://github.com/apache/incubator-datasketches-java/blob/master/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180) +[here](https://github.com/apache/incubator-datasketches-java/blob/main/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180) for approx_percentile is the first table in [here](https://datasketches.apache.org/docs/KLL/KLLAccuracyAndSize.html). `percentiles` for `approx_percentile` is an array of doubles between 0 and 1, where you want percentiles at. (Ex: "[0.25, 0.5, 0.75]") @@ -193,7 +193,7 @@ The following examples are broken down by source type. We strongly suggest makin ## Realtime Event GroupBy examples -This example is based on the [returns](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/returns.py) GroupBy from the quickstart guide that performs various aggregations over the `refund_amt` column over various windows. +This example is based on the [returns](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/returns.py) GroupBy from the quickstart guide that performs various aggregations over the `refund_amt` column over various windows. ```python source = Source( @@ -236,7 +236,7 @@ v1 = GroupBy( ## Bucketed GroupBy Example -In this example we take the [Purchases GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/purchases.py) from the Quickstart tutorial and modify it to include buckets based on a hypothetical `"credit_card_type"` column. +In this example we take the [Purchases GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/purchases.py) from the Quickstart tutorial and modify it to include buckets based on a hypothetical `"credit_card_type"` column. ```python source = Source( @@ -283,7 +283,7 @@ v1 = GroupBy( ## Simple Batch Event GroupBy examples -Example GroupBy with windowed aggregations. Taken from [purchases.py](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/purchases.py). +Example GroupBy with windowed aggregations. Taken from [purchases.py](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/purchases.py). Important things to note about this case relative to the streaming GroupBy: * The default accuracy here is `SNAPSHOT` meaning that updates to the online KV store only happen in batch, and also backfills will be midnight accurate rather than intra day accurate. @@ -329,7 +329,7 @@ v1 = GroupBy( ### Batch Entity GroupBy examples -This is taken from the [Users GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/users.py) from the quickstart tutorial. +This is taken from the [Users GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/users.py) from the quickstart tutorial. ```python diff --git a/docs/source/authoring_features/Join.md b/docs/source/authoring_features/Join.md index f86181463..57e2aaa06 100644 --- a/docs/source/authoring_features/Join.md +++ b/docs/source/authoring_features/Join.md @@ -6,7 +6,7 @@ Let's use an example to explain this further. In the [Quickstart](../getting_sta This is important because it means that when we serve the model online, inference will be made at checkout time, and therefore backfilled features for training data should correspond to a historical checkout event, with features computed as of those checkout times. In other words, every row of training data for the model has identical feature values to what the model would have seen had it made a production inference request at that time. -To see how we do this, let's take a look at the left side of the join definition (taken from [Quickstart Training Set Join](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/joins/quickstart/training_set.py)). +To see how we do this, let's take a look at the left side of the join definition (taken from [Quickstart Training Set Join](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/joins/quickstart/training_set.py)). ```python source = Source( diff --git a/docs/source/authoring_features/Source.md b/docs/source/authoring_features/Source.md index cc1f95146..49a6041e8 100644 --- a/docs/source/authoring_features/Source.md +++ b/docs/source/authoring_features/Source.md @@ -18,7 +18,7 @@ All sources are basically composed of the following pieces*: ## Streaming EventSource -Taken from the [returns.py](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/returns.py) example GroupBy in the quickstart tutorial. +Taken from the [returns.py](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/returns.py) example GroupBy in the quickstart tutorial. ```python source = Source( @@ -84,7 +84,7 @@ As you can see, a pre-requisite to using the streaming `EntitySource` is a chang ## Batch EntitySource -Taken from the [users.py](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/users.py) example GroupBy in the quickstart tutorial. +Taken from the [users.py](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/users.py) example GroupBy in the quickstart tutorial. ```python source = Source( diff --git a/docs/source/authoring_features/StagingQuery.md b/docs/source/authoring_features/StagingQuery.md index ded0b6b51..a3e853064 100644 --- a/docs/source/authoring_features/StagingQuery.md +++ b/docs/source/authoring_features/StagingQuery.md @@ -57,9 +57,9 @@ v1 = Join( ``` Note: The output namespace of the staging query is dependent on the metaData value for output_namespace. By default, the -metadata is extracted from [teams.json](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/teams.json) (or default team if one is not set). +metadata is extracted from [teams.json](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/teams.json) (or default team if one is not set). -**[See more configuration examples here](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/staging_queries)** +**[See more configuration examples here](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/staging_queries)** ## Date Logic and Template Parameters diff --git a/docs/source/getting_started/Tutorial.md b/docs/source/getting_started/Tutorial.md index 1ddfa9b32..485e73f89 100644 --- a/docs/source/getting_started/Tutorial.md +++ b/docs/source/getting_started/Tutorial.md @@ -19,7 +19,7 @@ Does not include: ## Setup -To get started with the Chronon, all you need to do is download the [docker-compose.yml](https://github.com/airbnb/chronon/blob/master/docker-compose.yml) file and run it locally: +To get started with the Chronon, all you need to do is download the [docker-compose.yml](https://github.com/airbnb/chronon/blob/main/docker-compose.yml) file and run it locally: ```bash curl -o docker-compose.yml https://chronon.ai/docker-compose.yml @@ -34,7 +34,7 @@ In this example, let's assume that we're a large online retailer, and we've dete ## Raw data sources -Fabricated raw data is included in the [data](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/data) directory. It includes four tables: +Fabricated raw data is included in the [data](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/data) directory. It includes four tables: 1. Users - includes basic information about users such as account created date; modeled as a batch data source that updates daily 2. Purchases - a log of all purchases by users; modeled as a log table with a streaming (i.e. Kafka) event-bus counterpart @@ -101,11 +101,11 @@ v1 = GroupBy( ) ``` -See the whole code file here: [purchases GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/purchases.py). This is also in your docker image. We'll be running computation for it and the other GroupBys in [Step 3 - Backfilling Data](#step-3---backfilling-data). +See the whole code file here: [purchases GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/purchases.py). This is also in your docker image. We'll be running computation for it and the other GroupBys in [Step 3 - Backfilling Data](#step-3---backfilling-data). **Feature set 2: Returns data features** -We perform a similar set of aggregations on returns data in the [returns GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/returns.py). The code is not included here because it looks similar to the above example. +We perform a similar set of aggregations on returns data in the [returns GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/returns.py). The code is not included here because it looks similar to the above example. **Feature set 3: User data features** @@ -127,7 +127,7 @@ v1 = GroupBy( ) ``` -Taken from the [users GroupBy](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/group_bys/quickstart/users.py). +Taken from the [users GroupBy](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/group_bys/quickstart/users.py). ### Step 2 - Join the features together @@ -160,7 +160,7 @@ v1 = Join( ) ``` -Taken from the [training_set Join](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/joins/quickstart/training_set.py). +Taken from the [training_set Join](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/joins/quickstart/training_set.py). The `left` side of the join is what defines the timestamps and primary keys for the backfill (notice that it is built on top of the `checkout` event, as dictated by our use case). @@ -330,4 +330,4 @@ Using chronon for your feature engineering work simplifies and improves your ML 4. Chronon exposes easy endpoints for feature fetching. 5. Consistency is guaranteed and measurable. -For a more detailed view into the benefits of using Chronon, see [Benefits of Chronon documentation](https://github.com/airbnb/chronon/tree/master?tab=readme-ov-file#benefits-of-chronon-over-other-approaches). +For a more detailed view into the benefits of using Chronon, see [Benefits of Chronon documentation](https://github.com/airbnb/chronon/tree/main?tab=readme-ov-file#benefits-of-chronon-over-other-approaches). diff --git a/docs/source/setup/Data_Integration.md b/docs/source/setup/Data_Integration.md index 6d0ff7985..ce1052320 100644 --- a/docs/source/setup/Data_Integration.md +++ b/docs/source/setup/Data_Integration.md @@ -10,11 +10,11 @@ Chronon jobs require Spark to run. If you already have a spark environment up an ## Configuring Spark -To configure Chronon to run on spark, you just need a `spark_submit.sh` script that can be used in Chronon's [`run.py`](https://github.com/airbnb/chronon/blob/master/api/py/ai/chronon/repo/run.py) Python script (this is the python-based CLI entry point for all jobs). +To configure Chronon to run on spark, you just need a `spark_submit.sh` script that can be used in Chronon's [`run.py`](https://github.com/airbnb/chronon/blob/main/api/py/ai/chronon/repo/run.py) Python script (this is the python-based CLI entry point for all jobs). -We recommend putting your `spark_submit.sh` within a `scripts/` subdirectory of your main `chronon` directory (see [Developer Setup docs](./Developer_Setup.md) for how to setup the main `chronon` directory.). If you do that, then you can use `run.py` as-is, as that is the [default location](https://github.com/airbnb/chronon/blob/master/api/py/ai/chronon/repo/run.py#L483) for `spark_submit.sh`. +We recommend putting your `spark_submit.sh` within a `scripts/` subdirectory of your main `chronon` directory (see [Developer Setup docs](./Developer_Setup.md) for how to setup the main `chronon` directory.). If you do that, then you can use `run.py` as-is, as that is the [default location](https://github.com/airbnb/chronon/blob/main/api/py/ai/chronon/repo/run.py#L483) for `spark_submit.sh`. -You can see an example `spark_submit.sh` script used by the quickstart guide here: [Quickstart example spark_submit.sh](https://github.com/airbnb/chronon/blob/master/api/py/test/sample/scripts/spark_submit.sh). +You can see an example `spark_submit.sh` script used by the quickstart guide here: [Quickstart example spark_submit.sh](https://github.com/airbnb/chronon/blob/main/api/py/test/sample/scripts/spark_submit.sh). Note that this replies on an environment variable set in the `docker-compose.yml` which basically just points `$SPARK_SUBMIT` variable to the system level `spark-submit` binary. diff --git a/docs/source/setup/Developer_Setup.md b/docs/source/setup/Developer_Setup.md index 26ec17d8e..4311bd85a 100644 --- a/docs/source/setup/Developer_Setup.md +++ b/docs/source/setup/Developer_Setup.md @@ -34,7 +34,7 @@ Key points: 2. There are `group_bys` and `joins` subdirectories inside the root directory, under which there are team directories. Note that the team directory names must match what is within `teams.json` 3. Within each of these team directories are the actual user-written chronon files. Note that there can be sub-directories within each team directory for organization if desired. -For an example setup of this directory, see the [Sample](https://github.com/airbnb/chronon/tree/master/api/py/test/sample) that is also mounted to the docker image that is used in the Quickstart guide. +For an example setup of this directory, see the [Sample](https://github.com/airbnb/chronon/tree/main/api/py/test/sample) that is also mounted to the docker image that is used in the Quickstart guide. You can also use the following command to create a scratch directory from your `cwd`: diff --git a/docs/source/setup/Online_Integration.md b/docs/source/setup/Online_Integration.md index 51b3b36cd..dfb015368 100644 --- a/docs/source/setup/Online_Integration.md +++ b/docs/source/setup/Online_Integration.md @@ -10,11 +10,11 @@ This integration gives Chronon the ability to: ## Example -If you'd to start with an example, please refer to the [MongoDB Implementation in the Quickstart Guide](https://github.com/airbnb/chronon/tree/master/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online). This provides a complete working example of how to integrate Chronon with MongoDB. +If you'd to start with an example, please refer to the [MongoDB Implementation in the Quickstart Guide](https://github.com/airbnb/chronon/tree/main/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online). This provides a complete working example of how to integrate Chronon with MongoDB. ## Components -**KVStore**: The biggest part of the API implementation is the [KVStore](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Api.scala#L43). +**KVStore**: The biggest part of the API implementation is the [KVStore](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Api.scala#L43). ```scala object KVStore { @@ -47,11 +47,11 @@ trait KVStore { There are three functions to implement as part of this integration: 1. `create`: which takes a string and creates a new database/dataset with that name. -2. `multiGet`: which takes a `Seq` of [`GetRequest`](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Api.scala#L33) and converts them into a `Future[Seq[GetResponse]]` by querying the underlying KVStore. -3. `multiPut`: which takes a `Seq` of [`PutRequest`](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Api.scala#L38) and converts them into `Future[Seq[Boolean]]` (success/fail) by attempting to insert them into the underlying KVStore. +2. `multiGet`: which takes a `Seq` of [`GetRequest`](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Api.scala#L33) and converts them into a `Future[Seq[GetResponse]]` by querying the underlying KVStore. +3. `multiPut`: which takes a `Seq` of [`PutRequest`](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Api.scala#L38) and converts them into `Future[Seq[Boolean]]` (success/fail) by attempting to insert them into the underlying KVStore. 4. `bulkPut`: to upload a hive table into your kv store. It takes the table name and partitions as `String`s as well as the dataset as a `String`. If you have another mechanism (like an airflow upload operator) to upload data from hive into your kv stores you don't need to implement this method. -See the [MongoDB example here](https://github.com/airbnb/chronon/blob/master/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online/MongoKvStore.scala). +See the [MongoDB example here](https://github.com/airbnb/chronon/blob/main/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online/MongoKvStore.scala). **StreamDecoder**: This is responsible for "decoding" or converting the raw values that Chronon streaming jobs will read into events that it knows how to process. @@ -98,12 +98,12 @@ Chronon has a type system that can map to Spark's or Avro's type system. Schema | StructType | Array[Any] | -See the [Quickstart example here](https://github.com/airbnb/chronon/blob/master/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online/QuickstartMutationDecoder.scala). +See the [Quickstart example here](https://github.com/airbnb/chronon/blob/main/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online/QuickstartMutationDecoder.scala). -**API:** The main API that requires implementation is [API](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Api.scala#L151). This combines the above implementations with other client and logging configuration. +**API:** The main API that requires implementation is [API](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Api.scala#L151). This combines the above implementations with other client and logging configuration. -[ChrononMongoOnlineImpl](https://github.com/airbnb/chronon/blob/master/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online/ChrononMongoOnlineImpl.scala) Is an example implemenation of the API. +[ChrononMongoOnlineImpl](https://github.com/airbnb/chronon/blob/main/quickstart/mongo-online-impl/src/main/scala/ai/chronon/quickstart/online/ChrononMongoOnlineImpl.scala) Is an example implemenation of the API. Once you have the api object you can build a fetcher class using the api object like so diff --git a/docs/source/setup/Orchestration.md b/docs/source/setup/Orchestration.md index 908b6de58..ca8829f1e 100644 --- a/docs/source/setup/Orchestration.md +++ b/docs/source/setup/Orchestration.md @@ -6,29 +6,29 @@ Airflow is currently the best supported method for orchestration, however, other ## Airflow Integration -See the [Airflow Directory](https://github.com/airbnb/chronon/tree/master/airflow) for initial boilerplate code. +See the [Airflow Directory](https://github.com/airbnb/chronon/tree/main/airflow) for initial boilerplate code. The files in this directory can be used to create the following Chronon Airflow DAGs. -1. GroupBy DAGs, created by [group_by_dag_constructor.py](https://github.com/airbnb/chronon/tree/master/airflow/group_by_dag_constructor.py): +1. GroupBy DAGs, created by [group_by_dag_constructor.py](https://github.com/airbnb/chronon/tree/main/airflow/group_by_dag_constructor.py): 1. `chronon_batch_dag_{team_name}`: One DAG per team that uploads snapshots of computed features to the KV store for online group_bys, and frontfills daily snapshots for group_bys. 2. `chronon_streaming_dag_{team_name}`: One DAG per team that runs Streaming jobs for `online=True, realtime=True` GroupBys. These tasks run every 15 minutes and are configured to "keep alive" streaming jobs (i.e. do nothing if running, else attempt restart if dead/not started). -2. Join DAGs, created by [join_dag_constructor.py](https://github.com/airbnb/chronon/tree/master/airflow/join_dag_constructor.py): +2. Join DAGs, created by [join_dag_constructor.py](https://github.com/airbnb/chronon/tree/main/airflow/join_dag_constructor.py): 1. `chronon_join_{join_name}`: One DAG per join that performs backfill and daily frontfill of join data to the offline Hive table. -3. Staging Query DAGs, created by [staging_query_dag_constructor.py](https://github.com/airbnb/chronon/tree/master/airflow/staging_query_dag_constructor.py): +3. Staging Query DAGs, created by [staging_query_dag_constructor.py](https://github.com/airbnb/chronon/tree/main/airflow/staging_query_dag_constructor.py): 1. `chronon_staging_query_{team_name}`: One DAG per team that creates daily jobs for each Staging Query for the team. -4. Online/Offline Consistency Check DAGs, created by [online_offline_consistency_dag_constructor.py](https://github.com/airbnb/chronon/tree/master/airflow/online_offline_consistency_dag_constructor.py): +4. Online/Offline Consistency Check DAGs, created by [online_offline_consistency_dag_constructor.py](https://github.com/airbnb/chronon/tree/main/airflow/online_offline_consistency_dag_constructor.py): 1. `chronon_online_offline_comparison_{join_name}`: One DAG per join that computes the consistency of online serving data vs offline data for that join, and outputs the measurements to a stats table for each join that is configured. Note that logging must be enabled for this pipeline to work. To deploy this to your airflow environment, first copy everything in this directory over to your Airflow directory (where your other DAG files live), then set the following configurations: -1. Set your configuration variables in [constants.py](https://github.com/airbnb/chronon/tree/master/airflow/constants.py). -2. Implement the `get_kv_store_upload_operator` function in [helpers.py](https://github.com/airbnb/chronon/tree/master/airflow/helpers.py). **This is only required if you want to use Chronon online serving**. +1. Set your configuration variables in [constants.py](https://github.com/airbnb/chronon/tree/main/airflow/constants.py). +2. Implement the `get_kv_store_upload_operator` function in [helpers.py](https://github.com/airbnb/chronon/tree/main/airflow/helpers.py). **This is only required if you want to use Chronon online serving**. ## Alternate Integrations -While Airflow is currently the most well-supported integration, there is no reason why you couldn't choose a different orchestration engine to power the above flows. If you're interested in such an integration and you think that the community might benefit from your work, please consider [contributing](https://github.com/airbnb/chronon/blob/master/CONTRIBUTE.md) back to the project. +While Airflow is currently the most well-supported integration, there is no reason why you couldn't choose a different orchestration engine to power the above flows. If you're interested in such an integration and you think that the community might benefit from your work, please consider [contributing](https://github.com/airbnb/chronon/blob/main/CONTRIBUTE.md) back to the project. If you have questions about how to approach a different integration, feel free to ask for help in the [community Discord channel](https://discord.gg/GbmGATNqqP). diff --git a/docs/source/test_deploy_serve/Serve.md b/docs/source/test_deploy_serve/Serve.md index 001f04ce4..e967447c8 100644 --- a/docs/source/test_deploy_serve/Serve.md +++ b/docs/source/test_deploy_serve/Serve.md @@ -4,15 +4,15 @@ The main way to serve production Chronon data online is with the Java or Scala F The main online Java Fetcher libraries can be found here: -1. [`JavaFetcher`](https://github.com/airbnb/chronon/blob/master/online/src/main/java/ai/chronon/online/JavaFetcher.java) -2. [`JavaRequest`](https://github.com/airbnb/chronon/blob/master/online/src/main/java/ai/chronon/online/JavaRequest.java) -3. [`JavaResponse`](https://github.com/airbnb/chronon/blob/master/online/src/main/java/ai/chronon/online/JavaResponse.java) +1. [`JavaFetcher`](https://github.com/airbnb/chronon/blob/main/online/src/main/java/ai/chronon/online/JavaFetcher.java) +2. [`JavaRequest`](https://github.com/airbnb/chronon/blob/main/online/src/main/java/ai/chronon/online/JavaRequest.java) +3. [`JavaResponse`](https://github.com/airbnb/chronon/blob/main/online/src/main/java/ai/chronon/online/JavaResponse.java) And their scala counterparts: -1. [`Fetcher`](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Fetcher.scala) -2. [`Request`](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Fetcher.scala#L39) -3. [`Response`](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/Fetcher.scala#L48) +1. [`Fetcher`](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Fetcher.scala) +2. [`Request`](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Fetcher.scala#L39) +3. [`Response`](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Fetcher.scala#L48) Example Implementation diff --git a/proposals/CHIP-1.md b/proposals/CHIP-1.md index b5a05c369..d2f0de409 100644 --- a/proposals/CHIP-1.md +++ b/proposals/CHIP-1.md @@ -50,7 +50,7 @@ The caches will be configured on a per-GroupBy basis, i.e. two caches per GroupB Caching will be an opt-in feature that can be enabled by Chronon developers. -Most of the code changes are in [FetcherBase.scala](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/FetcherBase.scala). +Most of the code changes are in [FetcherBase.scala](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/FetcherBase.scala). ### Batch Caching Details @@ -144,7 +144,7 @@ The size of the cache should ideally be set in terms of maximum memory usage (e. ### Step 1: BatchIr Caching -We start by caching the conversion from `batchBytes` to `FinalBatchIr` (the [toBatchIr function in FetcherBase](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/FetcherBase.scala#L102)) and `Map[String, AnyRef]`. +We start by caching the conversion from `batchBytes` to `FinalBatchIr` (the [toBatchIr function in FetcherBase](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/FetcherBase.scala#L102)) and `Map[String, AnyRef]`. To make testing easier, we'll disable this feature by default and enable it via Java Args. @@ -166,7 +166,7 @@ Results: will add ### Step 3: `TiledIr` Caching -The second step is caching [tile bytes to TiledIr](https://github.com/airbnb/chronon/blob/master/online/src/main/scala/ai/chronon/online/TileCodec.scala#L77C67-L77C67). This is only possible if the tile bytes contain information about whether a tile is complete (i.e. it won’t be updated anymore). The Flink side marks tiles as complete. +The second step is caching [tile bytes to TiledIr](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/TileCodec.scala#L77C67-L77C67). This is only possible if the tile bytes contain information about whether a tile is complete (i.e. it won’t be updated anymore). The Flink side marks tiles as complete. This cache can be "monoid-aware". Instead of storing multiple consecutive tiles for a given time range, we combine the tiles and store a single, larger tile in memory. For example, we combine two tiles, [0, 1) and [1, 2), into one, [0, 2). From 52089f4d97cbb2b006b5c5e1c066115c41644c3d Mon Sep 17 00:00:00 2001 From: nikhilsimha Date: Wed, 21 Feb 2024 12:37:45 -0800 Subject: [PATCH 02/17] undo changes to non chronon refs --- .../scala/ai/chronon/aggregator/base/SimpleAggregators.scala | 2 +- .../aggregator/windowing/TwoStackLiteAggregationBuffer.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala b/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala index f2501804a..aa9d2979f 100644 --- a/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala +++ b/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala @@ -411,7 +411,7 @@ class FrequentItems[T: FrequentItemsFriendly](val mapSize: Int, val errorType: E // See: Back to the future: an even more nearly optimal cardinality estimation algorithm, 2017 // https://arxiv.org/abs/1708.06839 // refer to the chart here to tune your sketch size with lgK -// https://github.com/apache/incubator-datasketches-java/blob/main/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 +// https://github.com/apache/incubator-datasketches-java/blob/master/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 // default is about 1200 bytes class ApproxDistinctCount[Input: CpcFriendly](lgK: Int = 8) extends SimpleAggregator[Input, CpcSketch, Long] { override def outputType: DataType = LongType diff --git a/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala b/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala index bd039f875..a9ad3f2bc 100644 --- a/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala +++ b/aggregator/src/main/scala/ai/chronon/aggregator/windowing/TwoStackLiteAggregationBuffer.scala @@ -22,7 +22,7 @@ import java.util case class BankersEntry[IR](var value: IR, ts: Long) -// ported from: https://github.com/IBM/sliding-window-aggregators/blob/main/rust/src/two_stacks_lite/mod.rs with some +// ported from: https://github.com/IBM/sliding-window-aggregators/blob/master/rust/src/two_stacks_lite/mod.rs with some // modification to work with simple aggregator class TwoStackLiteAggregationBuffer[Input, IR >: Null, Output >: Null](aggregator: SimpleAggregator[Input, IR, Output], maxSize: Int) { From 7a748c0f7d7ea28fd17ecd413a3884b3ae5d6d32 Mon Sep 17 00:00:00 2001 From: nikhilsimha Date: Wed, 21 Feb 2024 12:39:00 -0800 Subject: [PATCH 03/17] undo changes to non chronon refs --- api/py/ai/chronon/group_by.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/py/ai/chronon/group_by.py b/api/py/ai/chronon/group_by.py index 18194f60f..3b95bbba0 100644 --- a/api/py/ai/chronon/group_by.py +++ b/api/py/ai/chronon/group_by.py @@ -61,7 +61,7 @@ class Operation: APPROX_UNIQUE_COUNT = ttypes.Operation.APPROX_UNIQUE_COUNT # refer to the chart here to tune your sketch size with lgK # default is 8 - # https://github.com/apache/incubator-datasketches-java/blob/main/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 + # https://github.com/apache/incubator-datasketches-java/blob/master/src/main/java/org/apache/datasketches/cpc/CpcSketch.java#L180 APPROX_UNIQUE_COUNT_LGK = collector(ttypes.Operation.APPROX_UNIQUE_COUNT) UNIQUE_COUNT = ttypes.Operation.UNIQUE_COUNT COUNT = ttypes.Operation.COUNT From e9fa9ea718dba5981e33b64e427800bb30df0443 Mon Sep 17 00:00:00 2001 From: nikhilsimha Date: Wed, 21 Feb 2024 12:48:24 -0800 Subject: [PATCH 04/17] convert assignment to equality check --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index a35443db0..ee6fa2428 100644 --- a/build.sbt +++ b/build.sbt @@ -94,7 +94,7 @@ git.gitTagToVersionNumber := { tag: String => // Git plugin will automatically add SNAPSHOT for dirty workspaces so remove it to avoid duplication. val versionStr = if (git.gitUncommittedChanges.value) version.value.replace("-SNAPSHOT", "") else version.value val branchTag = git.gitCurrentBranch.value.replace("/", "-") - if (branchTag == "main" || branchTag = "master") { + if (branchTag == "main" || branchTag == "master") { // For main branches, we tag the packages as - Some(s"${versionStr}") } else { From 98fce4bd0f020541cbbf663bc39aeb83891bb50f Mon Sep 17 00:00:00 2001 From: Adam Kocoloski Date: Wed, 21 Feb 2024 20:12:00 -0500 Subject: [PATCH 05/17] Fix some Markdown violations in CONTRIBUTE (#687) As above, I noticed some rendering issues in the GitHub UI and followed (most of) the linter recommendations to address. Also the link from the README was broken. --- CONTRIBUTE.md | 115 ++++++++++++++++++++++++++------------------------ README.md | 2 +- 2 files changed, 62 insertions(+), 55 deletions(-) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index d7e483511..3f43a2060 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -11,7 +11,7 @@ Everyone is welcome to contribute to Chronon. We value all forms of contribution - Test cases to make the codebase more robust - Tutorials, blog posts, talks that promote the project. - Functionality extensions, new features, etc. -- Optimizations +- Optimizations - Support for new aggregations and data types - Support for connectors to different storage systems and event buses @@ -22,11 +22,11 @@ In the interest of keeping Chronon a stable platform for users, some changes are - Changes that could break online fetching flows, including changing the timestamp watermarking or processing in the lambda architecture, or Serde logic. - Changes that would interfere with existing Airflow DAGs, for example changing the default schedule in a way that would cause breakage on recent versions of Airflow. -There are exceptions to these general rules, however, please be sure to follow the “major change” guidelines if you wish to make such a change. +There are exceptions to these general rules, however, please be sure to follow the “major change” guidelines if you wish to make such a change. ## General Development Process -Everyone in the community is welcome to send patches, documents, and propose new features to the project. +Everyone in the community is welcome to send patches, documents, and propose new features to the project. Code changes require a stamp of approval from Chronon contributors to be merged, as outlined in the project bylaws. @@ -38,9 +38,9 @@ The process for reporting bugs and requesting smaller features is also outlined Pull Requests (PRs) should follow these guidelines as much as possible: -**Code Guidelines** +### Code Guidelines -- Follow our (code style guidelines)[docs/source/Code_Guidelines.md] +- Follow our [code style guidelines](docs/source/Code_Guidelines.md) - Well scoped (avoid multiple unrelated changes in the same PR) - Code should be rebased on the latest version of the latest version of the master branch - All lint checks and test cases should pass @@ -56,18 +56,17 @@ Although these guidelines apply essentially to the PRs’ title and body message The rules below will help to achieve uniformity that has several benefits, both for review and for the code base maintenance as a whole, helping you to write commit messages with a good quality suitable for the Chronon project, allowing fast log searches, bisecting, and so on. -**PR title** +#### PR title -Guarantee a title exists -Don’t use Github usernames in the title, like @username (enforced); -Include tags as a hint about what component(s) of the code the PRs / commits “touch”. For example [BugFix], [CI], [Streaming], [Spark], etc. If more than one tag exist, multiple brackets should be used, like [BugFix][CI] +- Guarantee a title exists +- Don’t use Github usernames in the title, like @username (enforced) +- Include tags as a hint about what component(s) of the code the PRs / commits “touch”. For example [BugFix], [CI], [Streaming], [Spark], etc. If more than one tag exist, multiple brackets should be used, like [BugFix][CI] -**PR body** - -Guarantee a body exists -Include a simple and clear explanation of the purpose of the change -Include any relevant information about how it was tested +#### PR body +- Guarantee a body exists +- Include a simple and clear explanation of the purpose of the change +- Include any relevant information about how it was tested ## Release Guidelines @@ -83,8 +82,8 @@ Issues need to contain all relevant information based on the type of the issue. - Summary of what the user was trying to achieve - Sample data - Inputs, Expected Outputs (by the user) and Current Output - - Configuration - StagingQuery / GroupBy or Join -- Repro steps + - Configuration - StagingQuery / GroupBy or Join +- Repro steps - What commands were run and what was the full output of the command - PR guidelines - Includes a failing test case based on sample data @@ -92,14 +91,15 @@ Issues need to contain all relevant information based on the type of the issue. ### Crash Reports - Summary of what the user was trying to achieve - - Sample data - Inputs, Expected Outputs (by the user) - - Configuration - StagingQuery / GroupBy or Join -- Repro steps + - Sample data - Inputs, Expected Outputs (by the user) + - Configuration - StagingQuery / GroupBy or Join +- Repro steps - What commands were run and the output along with the error stack trace - PR guidelines - Includes a test case for the crash ## Feature requests and Optimization Requests + We expect the proposer to create a CHIP / Chronon Improvement Proposal document as detailed below # Chronon Improvement Proposal (CHIP) @@ -147,7 +147,6 @@ For the most part monitoring, command line tool changes, and configs are added w ## What should be included in a CHIP? - A CHIP should contain the following sections: - Motivation: describe the problem to be solved @@ -163,13 +162,13 @@ Anyone can initiate a CHIP but you shouldn't do it unless you have an intention ## Process Here is the process for making a CHIP: -1. Create a PR in chronon/proposals with a single markdown file.Take the next available CHIP number and create a file “CHIP-42 Monoid caching for online & real-time feature fetches”. This is the document that you will iterate on. -2. Fill in the sections as described above and file a PR. These proposal document PRs are reviewed by the committer who is on-call. They usually get merged once there is enough detail and clarity. + +1. Create a PR in chronon/proposals with a single markdown file.Take the next available CHIP number and create a file “CHIP-42 Monoid caching for online & real-time feature fetches”. This is the document that you will iterate on. +2. Fill in the sections as described above and file a PR. These proposal document PRs are reviewed by the committer who is on-call. They usually get merged once there is enough detail and clarity. 3. Start a [DISCUSS] issue on github. Please ensure that the subject of the thread is of the format [DISCUSS] CHIP-{your CHIP number} {your CHIP heading}. In the process of the discussion you may update the proposal. You should let people know the changes you are making. -4. Once the proposal is finalized, tag the issue with the “voting-due” label. These proposals are more serious than code changes and more serious even than release votes. In the weekly committee meetings we will vote for/against the CHIP - where Yes, Veto-no, Neutral are the choices. The criteria for acceptance is 3+ “yes” vote count by the members of the committee without a veto-no. Veto-no votes require in-depth technical justifications to be provided on the github issue +4. Once the proposal is finalized, tag the issue with the “voting-due” label. These proposals are more serious than code changes and more serious even than release votes. In the weekly committee meetings we will vote for/against the CHIP - where Yes, Veto-no, Neutral are the choices. The criteria for acceptance is 3+ “yes” vote count by the members of the committee without a veto-no. Veto-no votes require in-depth technical justifications to be provided on the github issue. 5. Please update the CHIP markdown doc to reflect the current stage of the CHIP after a vote. This acts as the permanent record indicating the result of the CHIP (e.g., Accepted or Rejected). Also report the result of the CHIP vote to the github issue thread. - It's not unusual for a CHIP proposal to take long discussions to be finalized. Below are some general suggestions on driving CHIP towards consensus. Notice that these are hints rather than rules. Contributors should make pragmatic decisions in accordance with individual situations. - The progress of a CHIP should not be long blocked on an unresponsive reviewer. A reviewer who blocks a CHIP with dissenting opinions should try to respond to the subsequent replies timely, or at least provide a reasonable estimated time to respond. @@ -180,40 +179,48 @@ It's not unusual for a CHIP proposal to take long discussions to be finalized. B # Resources Below is a list of resources that can be useful for development and debugging. -## Docs -(Docsite)[https://chronon.ai] -(doc directory)[https://github.com/airbnb/chronon/tree/main/docs/source] -(Code of conduct)[TODO] +## Docs -## Links: +[Docsite](https://chronon.ai)\ +[doc directory](https://github.com/airbnb/chronon/tree/main/docs/source)\ +[Code of conduct](TODO) -(pip project)[https://pypi.org/project/chronon-ai/] -(maven central)[https://mvnrepository.com/artifact/ai.chronon/]: (publishing)[https://github.com/airbnb/chronon/blob/main/devnotes.md#publishing-all-the-artifacts-of-chronon] -(Docsite: publishing)[https://github.com/airbnb/chronon/blob/main/devnotes.md#chronon-artifacts-publish-process] +## Links +[pip project](https://pypi.org/project/chronon-ai/)\ +[maven central](https://mvnrepository.com/artifact/ai.chronon/): [publishing](https://github.com/airbnb/chronon/blob/main/devnotes.md#publishing-all-the-artifacts-of-chronon)\ +[Docsite: publishing](https://github.com/airbnb/chronon/blob/main/devnotes.md#chronon-artifacts-publish-process) ## Code Pointers -Api - (Thrift)[https://github.com/airbnb/chronon/blob/main/api/thrift/api.thrift#L180], (Python)[https://github.com/airbnb/chronon/blob/main/api/py/ai/chronon/group_by.py] -(CLI driver entry point for job launching.)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/Driver.scala] - -**Offline flows that produce hive tables or file output** -(GroupBy)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/GroupBy.scala] -(Staging Query)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala] -(Join backfills)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/Join.scala] -(Metadata Export)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/MetadataExporter.scala] -Online flows that update and read data & metadata from the kvStore -(GroupBy window tail upload )[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/GroupByUpload.scala] -(Streaming window head upload)[https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/streaming/GroupBy.scala] -(Fetching)[https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Fetcher.scala] -Aggregations -(time based aggregations)[https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/base/TimedAggregators.scala] -(time independent aggregations)[https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala] -(integration point with rest of chronon)[https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/row/ColumnAggregator.scala#L223] -(Windowing)[https://github.com/airbnb/chronon/tree/main/aggregator/src/main/scala/ai/chronon/aggregator/windowing] - -**Testing** -(Testing - sbt commands)[https://github.com/airbnb/chronon/blob/main/devnotes.md#testing] -(Automated testing - circle-ci pipelines)[https://app.circleci.com/pipelines/github/airbnb/chronon] -(Dev Setup)[https://github.com/airbnb/chronon/blob/main/devnotes.md#prerequisites] +### API + +[Thrift](https://github.com/airbnb/chronon/blob/main/api/thrift/api.thrift#L180), [Python](https://github.com/airbnb/chronon/blob/main/api/py/ai/chronon/group_by.py)\ +[CLI driver entry point for job launching.](https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/Driver.scala) + +### Offline flows that produce hive tables or file output + +[GroupBy](https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/GroupBy.scala)\ +[Staging Query](https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala)\ +[Join backfills](https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/Join.scala)\ +[Metadata Export](https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/MetadataExporter.scala) + +### Online flows that update and read data & metadata from the kvStore + +[GroupBy window tail upload](https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/GroupByUpload.scala)\ +[Streaming window head upload](https://github.com/airbnb/chronon/blob/main/spark/src/main/scala/ai/chronon/spark/streaming/GroupBy.scala)\ +[Fetching](https://github.com/airbnb/chronon/blob/main/online/src/main/scala/ai/chronon/online/Fetcher.scala) + +### Aggregations + +[time based aggregations](https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/base/TimedAggregators.scala)\ +[time independent aggregations](https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/base/SimpleAggregators.scala)\ +[integration point with rest of chronon](https://github.com/airbnb/chronon/blob/main/aggregator/src/main/scala/ai/chronon/aggregator/row/ColumnAggregator.scala#L223)\ +[Windowing](https://github.com/airbnb/chronon/tree/main/aggregator/src/main/scala/ai/chronon/aggregator/windowing) + +### Testing + +[Testing - sbt commands](https://github.com/airbnb/chronon/blob/main/devnotes.md#testing)\ +[Automated testing - circle-ci pipelines](https://app.circleci.com/pipelines/github/airbnb/chronon)\ +[Dev Setup](https://github.com/airbnb/chronon/blob/main/devnotes.md#prerequisites) diff --git a/README.md b/README.md index 8d4a1f11a..2d4b693c1 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ With Chronon you can use any data available in your organization, including ever # Contributing -We welcome contributions to the Chronon project! Please read our (CONTRIBUTING.md)[CONTRIBUTING.md] for details. +We welcome contributions to the Chronon project! Please read [CONTRIBUTE](CONTRIBUTE.md) for details. # Support From b722d0a925ef4ef338fd89ba329eeaece96bc5c2 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 21 Feb 2024 21:02:10 -0500 Subject: [PATCH 06/17] Remove github actions - we have circle ci (#689) --- .github/workflows/scala.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/scala.yml diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml deleted file mode 100644 index f182d0c09..000000000 --- a/.github/workflows/scala.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Scala CI - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - name: Run tests - run: sbt test From ffd5154a373ce14dd45bb3809132b8d3713d488a Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 26 Feb 2024 14:27:44 -0800 Subject: [PATCH 07/17] Deprecate spark 2_11 tests (#691) * Deprecate spark 2_11 tests * remove ref --- .circleci/config.yml | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e16f8d1de..8dea27f5a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,33 +41,6 @@ jobs: docker push houpy0829/chronon-ci:base fi - - "Scala 11 -- Spark 2 Tests": - executor: docker_baseimg_executor - steps: - - checkout - - run: - name: Run Spark 2.4.0 tests - shell: /bin/bash -leuxo pipefail - command: | - conda activate chronon_py - # Increase if we see OOM. - export SBT_OPTS="-XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=4G -Xmx4G -Xms2G" - sbt "++ 2.11.12 test" - - store_test_results: - path: /chronon/spark/target/test-reports - - store_test_results: - path: /chronon/aggregator/target/test-reports - - run: - name: Compress spark-warehouse - command: | - cd /tmp/ && tar -czvf spark-warehouse.tar.gz chronon/spark-warehouse - when: on_fail - - store_artifacts: - path: /tmp/spark-warehouse.tar.gz - destination: spark_warehouse.tar.gz - when: on_fail - "Scala 12 -- Spark 3 Tests": executor: docker_baseimg_executor steps: @@ -167,9 +140,6 @@ workflows: build_test_deploy: jobs: - "Docker Base Build" - - "Scala 11 -- Spark 2 Tests": - requires: - - "Docker Base Build" - "Scala 12 -- Spark 3 Tests": requires: - "Docker Base Build" From 596dacc03b04c46d62e949712930aa538351a93f Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 26 Feb 2024 14:28:02 -0800 Subject: [PATCH 08/17] Remove embedded from our build (#692) * Remove embedded from our build * nits --- Dockerfile | 4 ++-- build.sbt | 62 ++++++++++++++++++------------------------------------ 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/Dockerfile b/Dockerfile index ba01b09ef..4caefd00b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM openjdk:8-jre-slim # Set this manually before building the image, requires a local build of the jar -ENV CHRONON_JAR_PATH=spark/target-embedded/scala-2.12/your_build.jar +ENV CHRONON_JAR_PATH=spark/target/scala-2.12/your_build.jar # Update package lists and install necessary tools RUN apt-get update && apt-get install -y \ @@ -75,7 +75,7 @@ WORKDIR ${SPARK_HOME} # If doing a regular local spark box. WORKDIR /srv/chronon -ENV DRIVER_JAR_PATH="/srv/spark/spark_embedded.jar" +ENV DRIVER_JAR_PATH="/srv/spark/spark_uber.jar" COPY api/py/test/sample ./ COPY quickstart/mongo-online-impl /srv/onlineImpl diff --git a/build.sbt b/build.sbt index ee6fa2428..45afaa42c 100644 --- a/build.sbt +++ b/build.sbt @@ -1,9 +1,11 @@ -import sbt.Keys._ +import sbt.Keys.* import sbt.Test import scala.io.StdIn -import scala.sys.process._ -import complete.DefaultParsers._ +import scala.sys.process.* +import complete.DefaultParsers.* + +import scala.language.postfixOps lazy val scala211 = "2.11.12" lazy val scala212 = "2.12.12" @@ -51,7 +53,7 @@ lazy val publishSettings = Seq( ) // Release related configs -import sbtrelease.ReleasePlugin.autoImport.ReleaseTransformations._ +import sbtrelease.ReleasePlugin.autoImport.ReleaseTransformations.* lazy val releaseSettings = Seq( releaseUseGlobalVersion := false, releaseVersionBump := sbtrelease.Version.Bump.Next, @@ -80,13 +82,13 @@ enablePlugins(GitVersioning, GitBranchPrompt) lazy val supportedVersions = List(scala211, scala212, scala213) lazy val root = (project in file(".")) - .aggregate(api, aggregator, online, spark_uber, spark_embedded, flink) + .aggregate(api, aggregator, online, spark_uber, flink) .settings( publish / skip := true, crossScalaVersions := Nil, name := "chronon" ) - .settings(releaseSettings: _*) + .settings(releaseSettings *) // Git related config git.useGitDescribe := true @@ -96,10 +98,10 @@ git.gitTagToVersionNumber := { tag: String => val branchTag = git.gitCurrentBranch.value.replace("/", "-") if (branchTag == "main" || branchTag == "master") { // For main branches, we tag the packages as - - Some(s"${versionStr}") + Some(versionStr) } else { // For user branches, we tag the packages as -- - Some(s"${branchTag}-${versionStr}") + Some(s"$branchTag-$versionStr") } } @@ -203,7 +205,7 @@ lazy val api = project val outputJava = (Compile / sourceManaged).value Thrift.gen(inputThrift.getPath, outputJava.getPath, "java") }.taskValue, - sourceGenerators in Compile += python_api_build.taskValue, + Compile / sourceGenerators += python_api_build.taskValue, crossScalaVersions := supportedVersions, libraryDependencies ++= fromMatrix(scalaVersion.value, "spark-sql/provided") ++ @@ -240,8 +242,8 @@ python_api := { val s: TaskStreams = streams.value val versionStr = (api / version).value val branchStr = git.gitCurrentBranch.value.replace("/", "-") - s.log.info(s"Building Python API version: ${versionStr}, branch: ${branchStr}, action: ${action} ...") - if ((s"api/py/python-api-build.sh ${versionStr} ${branchStr} ${action}" !) == 0) { + s.log.info(s"Building Python API version: ${versionStr}, branch: $branchStr, action: $action ...") + if ((s"api/py/python-api-build.sh $versionStr $branchStr $action" !) == 0) { s.log.success("Built Python API") } else { throw new IllegalStateException("Python API build failed!") @@ -284,21 +286,6 @@ lazy val aggregator = project ) lazy val online = project - .dependsOn(aggregator.%("compile->compile;test->test")) - .settings( - publishSettings, - crossScalaVersions := supportedVersions, - libraryDependencies ++= Seq( - "org.scala-lang.modules" %% "scala-java8-compat" % "0.9.0", - // statsd 3.0 has local aggregation - TODO: upgrade - "com.datadoghq" % "java-dogstatsd-client" % "2.7", - "org.rogach" %% "scallop" % "4.0.1", - "net.jodah" % "typetools" % "0.4.1" - ), - libraryDependencies ++= fromMatrix(scalaVersion.value, "spark-all", "scala-parallel-collections", "netty-buffer") - ) - -lazy val online_unshaded = (project in file("online")) .dependsOn(aggregator.%("compile->compile;test->test")) .settings( target := target.value.toPath.resolveSibling("target-no-assembly").toFile, @@ -325,13 +312,14 @@ def cleanSparkMeta(): Unit = { file(tmp_warehouse) / "metastore_db") } -val sparkBaseSettings: Seq[Setting[_]] = Seq( +val sparkBaseSettings: Seq[Setting[?]] = Seq( assembly / test := {}, assembly / artifact := { val art = (assembly / artifact).value art.withClassifier(Some("assembly")) }, - mainClass in (Compile, run) := Some("ai.chronon.spark.Driver"), + Compile / mainClass := Some("ai.chronon.spark.Driver"), + run / mainClass := Some("ai.chronon.spark.Driver"), cleanFiles ++= Seq(file(tmp_warehouse)), Test / testOptions += Tests.Setup(() => cleanSparkMeta()), // compatibility for m1 chip laptop @@ -339,23 +327,13 @@ val sparkBaseSettings: Seq[Setting[_]] = Seq( ) ++ addArtifact(assembly / artifact, assembly) ++ publishSettings lazy val spark_uber = (project in file("spark")) - .dependsOn(aggregator.%("compile->compile;test->test"), online_unshaded) + .dependsOn(aggregator.%("compile->compile;test->test"), online) .settings( sparkBaseSettings, crossScalaVersions := supportedVersions, libraryDependencies ++= fromMatrix(scalaVersion.value, "jackson", "spark-all/provided") ) -lazy val spark_embedded = (project in file("spark")) - .dependsOn(aggregator.%("compile->compile;test->test"), online_unshaded) - .settings( - sparkBaseSettings, - crossScalaVersions := supportedVersions, - libraryDependencies ++= fromMatrix(scalaVersion.value, "spark-all"), - target := target.value.toPath.resolveSibling("target-embedded").toFile, - Test / test := {} - ) - lazy val flink = (project in file("flink")) .dependsOn(aggregator.%("compile->compile;test->test"), online) .settings( @@ -385,10 +363,10 @@ sphinx := { ThisBuild / assemblyMergeStrategy := { case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard - case PathList("META-INF", _ @_*) => MergeStrategy.filterDistinctLines + case PathList("META-INF", _*) => MergeStrategy.filterDistinctLines case "plugin.xml" => MergeStrategy.last - case PathList("com", "fasterxml", _ @_*) => MergeStrategy.last - case PathList("com", "google", _ @_*) => MergeStrategy.last + case PathList("com", "fasterxml", _*) => MergeStrategy.last + case PathList("com", "google", _*) => MergeStrategy.last case _ => MergeStrategy.first } exportJars := true From b0a67f1cf191a051e4c38ccf0b6a64d4e6da75f0 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 26 Feb 2024 14:33:31 -0800 Subject: [PATCH 09/17] Revert "Remove embedded from our build (#692)" (#693) This reverts commit 596dacc03b04c46d62e949712930aa538351a93f. --- Dockerfile | 4 ++-- build.sbt | 62 ++++++++++++++++++++++++++++++++++++------------------ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4caefd00b..ba01b09ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM openjdk:8-jre-slim # Set this manually before building the image, requires a local build of the jar -ENV CHRONON_JAR_PATH=spark/target/scala-2.12/your_build.jar +ENV CHRONON_JAR_PATH=spark/target-embedded/scala-2.12/your_build.jar # Update package lists and install necessary tools RUN apt-get update && apt-get install -y \ @@ -75,7 +75,7 @@ WORKDIR ${SPARK_HOME} # If doing a regular local spark box. WORKDIR /srv/chronon -ENV DRIVER_JAR_PATH="/srv/spark/spark_uber.jar" +ENV DRIVER_JAR_PATH="/srv/spark/spark_embedded.jar" COPY api/py/test/sample ./ COPY quickstart/mongo-online-impl /srv/onlineImpl diff --git a/build.sbt b/build.sbt index 45afaa42c..ee6fa2428 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,9 @@ -import sbt.Keys.* +import sbt.Keys._ import sbt.Test import scala.io.StdIn -import scala.sys.process.* -import complete.DefaultParsers.* - -import scala.language.postfixOps +import scala.sys.process._ +import complete.DefaultParsers._ lazy val scala211 = "2.11.12" lazy val scala212 = "2.12.12" @@ -53,7 +51,7 @@ lazy val publishSettings = Seq( ) // Release related configs -import sbtrelease.ReleasePlugin.autoImport.ReleaseTransformations.* +import sbtrelease.ReleasePlugin.autoImport.ReleaseTransformations._ lazy val releaseSettings = Seq( releaseUseGlobalVersion := false, releaseVersionBump := sbtrelease.Version.Bump.Next, @@ -82,13 +80,13 @@ enablePlugins(GitVersioning, GitBranchPrompt) lazy val supportedVersions = List(scala211, scala212, scala213) lazy val root = (project in file(".")) - .aggregate(api, aggregator, online, spark_uber, flink) + .aggregate(api, aggregator, online, spark_uber, spark_embedded, flink) .settings( publish / skip := true, crossScalaVersions := Nil, name := "chronon" ) - .settings(releaseSettings *) + .settings(releaseSettings: _*) // Git related config git.useGitDescribe := true @@ -98,10 +96,10 @@ git.gitTagToVersionNumber := { tag: String => val branchTag = git.gitCurrentBranch.value.replace("/", "-") if (branchTag == "main" || branchTag == "master") { // For main branches, we tag the packages as - - Some(versionStr) + Some(s"${versionStr}") } else { // For user branches, we tag the packages as -- - Some(s"$branchTag-$versionStr") + Some(s"${branchTag}-${versionStr}") } } @@ -205,7 +203,7 @@ lazy val api = project val outputJava = (Compile / sourceManaged).value Thrift.gen(inputThrift.getPath, outputJava.getPath, "java") }.taskValue, - Compile / sourceGenerators += python_api_build.taskValue, + sourceGenerators in Compile += python_api_build.taskValue, crossScalaVersions := supportedVersions, libraryDependencies ++= fromMatrix(scalaVersion.value, "spark-sql/provided") ++ @@ -242,8 +240,8 @@ python_api := { val s: TaskStreams = streams.value val versionStr = (api / version).value val branchStr = git.gitCurrentBranch.value.replace("/", "-") - s.log.info(s"Building Python API version: ${versionStr}, branch: $branchStr, action: $action ...") - if ((s"api/py/python-api-build.sh $versionStr $branchStr $action" !) == 0) { + s.log.info(s"Building Python API version: ${versionStr}, branch: ${branchStr}, action: ${action} ...") + if ((s"api/py/python-api-build.sh ${versionStr} ${branchStr} ${action}" !) == 0) { s.log.success("Built Python API") } else { throw new IllegalStateException("Python API build failed!") @@ -286,6 +284,21 @@ lazy val aggregator = project ) lazy val online = project + .dependsOn(aggregator.%("compile->compile;test->test")) + .settings( + publishSettings, + crossScalaVersions := supportedVersions, + libraryDependencies ++= Seq( + "org.scala-lang.modules" %% "scala-java8-compat" % "0.9.0", + // statsd 3.0 has local aggregation - TODO: upgrade + "com.datadoghq" % "java-dogstatsd-client" % "2.7", + "org.rogach" %% "scallop" % "4.0.1", + "net.jodah" % "typetools" % "0.4.1" + ), + libraryDependencies ++= fromMatrix(scalaVersion.value, "spark-all", "scala-parallel-collections", "netty-buffer") + ) + +lazy val online_unshaded = (project in file("online")) .dependsOn(aggregator.%("compile->compile;test->test")) .settings( target := target.value.toPath.resolveSibling("target-no-assembly").toFile, @@ -312,14 +325,13 @@ def cleanSparkMeta(): Unit = { file(tmp_warehouse) / "metastore_db") } -val sparkBaseSettings: Seq[Setting[?]] = Seq( +val sparkBaseSettings: Seq[Setting[_]] = Seq( assembly / test := {}, assembly / artifact := { val art = (assembly / artifact).value art.withClassifier(Some("assembly")) }, - Compile / mainClass := Some("ai.chronon.spark.Driver"), - run / mainClass := Some("ai.chronon.spark.Driver"), + mainClass in (Compile, run) := Some("ai.chronon.spark.Driver"), cleanFiles ++= Seq(file(tmp_warehouse)), Test / testOptions += Tests.Setup(() => cleanSparkMeta()), // compatibility for m1 chip laptop @@ -327,13 +339,23 @@ val sparkBaseSettings: Seq[Setting[?]] = Seq( ) ++ addArtifact(assembly / artifact, assembly) ++ publishSettings lazy val spark_uber = (project in file("spark")) - .dependsOn(aggregator.%("compile->compile;test->test"), online) + .dependsOn(aggregator.%("compile->compile;test->test"), online_unshaded) .settings( sparkBaseSettings, crossScalaVersions := supportedVersions, libraryDependencies ++= fromMatrix(scalaVersion.value, "jackson", "spark-all/provided") ) +lazy val spark_embedded = (project in file("spark")) + .dependsOn(aggregator.%("compile->compile;test->test"), online_unshaded) + .settings( + sparkBaseSettings, + crossScalaVersions := supportedVersions, + libraryDependencies ++= fromMatrix(scalaVersion.value, "spark-all"), + target := target.value.toPath.resolveSibling("target-embedded").toFile, + Test / test := {} + ) + lazy val flink = (project in file("flink")) .dependsOn(aggregator.%("compile->compile;test->test"), online) .settings( @@ -363,10 +385,10 @@ sphinx := { ThisBuild / assemblyMergeStrategy := { case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard - case PathList("META-INF", _*) => MergeStrategy.filterDistinctLines + case PathList("META-INF", _ @_*) => MergeStrategy.filterDistinctLines case "plugin.xml" => MergeStrategy.last - case PathList("com", "fasterxml", _*) => MergeStrategy.last - case PathList("com", "google", _*) => MergeStrategy.last + case PathList("com", "fasterxml", _ @_*) => MergeStrategy.last + case PathList("com", "google", _ @_*) => MergeStrategy.last case _ => MergeStrategy.first } exportJars := true From c93ba284dff8c0d7c5ee232f619f754e4c71ec38 Mon Sep 17 00:00:00 2001 From: Pengyu Hou <3771747+better365@users.noreply.github.com> Date: Mon, 26 Feb 2024 17:53:33 -0800 Subject: [PATCH 10/17] Update run.py (#696) add missing space Signed-off-by: Pengyu Hou <3771747+better365@users.noreply.github.com> --- api/py/ai/chronon/repo/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/py/ai/chronon/repo/run.py b/api/py/ai/chronon/repo/run.py index 88036b095..7ee1a34ce 100755 --- a/api/py/ai/chronon/repo/run.py +++ b/api/py/ai/chronon/repo/run.py @@ -506,7 +506,7 @@ def _gen_final_args(self, start_ds=None, end_ds=None): online_jar=self.online_jar, online_class=self.online_class, ) - override_start_partition_arg = "--start-partition-override=" + start_ds if start_ds else "" + override_start_partition_arg = " --start-partition-override=" + start_ds if start_ds else "" final_args = base_args + " " + str(self.args) + override_start_partition_arg return final_args From dcb37509d3dcb1c229527966ab825066a1120025 Mon Sep 17 00:00:00 2001 From: "Caio Camatta (Stripe)" <108533014+caiocamatta-stripe@users.noreply.github.com> Date: Wed, 28 Feb 2024 12:04:22 -0500 Subject: [PATCH 11/17] Add tiled implementation of the Flink app (#627) * Add custom triggers * Move triggers * Add KeySelector * Comments * Rename tiling package to window * WIP runTiledGroupByJob * Comment-out AsyncKVStoreWriterTest.scala ? question mark ? * Add ChrononFlinkRowAggregators * Refactor AvroCodec slightly * Add TiledAvroCodecFn * Add LateEventCounter * Finish runTiledGroupByJob * Add ChrononFlinkRowAggregationFunctionTest * Add missing @Test decorator * Add KeySelector tests * Add e2e tiled test * Scalafmt * Comments * Uncomment AsyncKVStoreWriterTest * Remove slot sharing so that test finally halts * Tweak strings in key selector test * Rename files, change comments * keyToBytes in process function should convert to array first * Refactor tiled Flink test, use watermark strategy * Improve e2e test so that we check actual tile IRs * rm debug=true * Use log4j * Remove comment * Minor clean up, change comments * scalafmt * Add missing getSmallestWindowResolutionInMillis * Add missing tiledCodec * Enable debug logs it tests * Info instead of debug * Fix lack of isolation in test sink * Make BaseAvroCodecFn abstract * Update FlinkJob comments * Comment * Move getSmallestWindowResolutionInMillis to GroupByOps * Use new GroupByOps method, fix mistake * Revert "Move getSmallestWindowResolutionInMillis to GroupByOps" * Use toScala, use multiline strings * Use logger.debug * Scalafmt * Fix compile error --- .../aggregator/windowing/Resolution.scala | 21 +- .../ai/chronon/flink/AsyncKVStoreWriter.scala | 2 + .../scala/ai/chronon/flink/AvroCodecFn.scala | 102 ++++++-- .../scala/ai/chronon/flink/FlinkJob.scala | 149 ++++++++++-- .../scala/ai/chronon/flink/FlinkSource.scala | 2 + .../chronon/flink/RichMetricsOperators.scala | 27 +++ .../chronon/flink/SparkExpressionEvalFn.scala | 2 +- .../flink/window/FlinkRowAggregators.scala | 206 +++++++++++++++++ .../ai/chronon/flink/window/KeySelector.scala | 33 +++ .../ai/chronon/flink/window/Trigger.scala | 180 +++++++++++++++ .../flink/test/FlinkJobIntegrationTest.scala | 164 +++++++------ .../chronon/flink/test/FlinkTestUtils.scala | 87 ++++++- .../FlinkRowAggregationFunctionTest.scala | 218 ++++++++++++++++++ .../flink/test/window/KeySelectorTest.scala | 58 +++++ 14 files changed, 1146 insertions(+), 105 deletions(-) create mode 100644 flink/src/main/scala/ai/chronon/flink/RichMetricsOperators.scala create mode 100644 flink/src/main/scala/ai/chronon/flink/window/FlinkRowAggregators.scala create mode 100644 flink/src/main/scala/ai/chronon/flink/window/KeySelector.scala create mode 100644 flink/src/main/scala/ai/chronon/flink/window/Trigger.scala create mode 100644 flink/src/test/scala/ai/chronon/flink/test/window/FlinkRowAggregationFunctionTest.scala create mode 100644 flink/src/test/scala/ai/chronon/flink/test/window/KeySelectorTest.scala diff --git a/aggregator/src/main/scala/ai/chronon/aggregator/windowing/Resolution.scala b/aggregator/src/main/scala/ai/chronon/aggregator/windowing/Resolution.scala index 681df08c9..5db1fd5a9 100644 --- a/aggregator/src/main/scala/ai/chronon/aggregator/windowing/Resolution.scala +++ b/aggregator/src/main/scala/ai/chronon/aggregator/windowing/Resolution.scala @@ -17,7 +17,10 @@ package ai.chronon.aggregator.windowing import ai.chronon.api.Extensions.{WindowOps, WindowUtils} -import ai.chronon.api.{TimeUnit, Window} +import ai.chronon.api.{GroupBy, TimeUnit, Window} + +import scala.util.ScalaJavaConversions.ListOps +import scala.util.ScalaVersionSpecificCollectionsConverter.convertJavaListToScala trait Resolution extends Serializable { // For a given window what is the resolution of the tail @@ -57,3 +60,19 @@ object DailyResolution extends Resolution { val hopSizes: Array[Long] = Array(WindowUtils.Day.millis) } + +object ResolutionUtils { + + /** + * Find the smallest tail window resolution in a GroupBy. Returns None if the GroupBy does not define any windows. + * The window resolutions are: 5 min for a GroupBy a window < 12 hrs, 1 hr for < 12 days, 1 day for > 12 days. + * */ + def getSmallestWindowResolutionInMillis(groupBy: GroupBy): Option[Long] = + Option( + groupBy.aggregations.toScala.toArray + .flatMap(aggregation => + if (aggregation.windows != null) aggregation.windows.toScala + else None) + .map(FiveMinuteResolution.calculateTailHop) + ).filter(_.nonEmpty).map(_.min) +} diff --git a/flink/src/main/scala/ai/chronon/flink/AsyncKVStoreWriter.scala b/flink/src/main/scala/ai/chronon/flink/AsyncKVStoreWriter.scala index 0912a2e94..6d3fecae3 100644 --- a/flink/src/main/scala/ai/chronon/flink/AsyncKVStoreWriter.scala +++ b/flink/src/main/scala/ai/chronon/flink/AsyncKVStoreWriter.scala @@ -75,6 +75,8 @@ class AsyncKVStoreWriter(onlineImpl: Api, featureGroupName: String) // The context used for the future callbacks implicit lazy val executor: ExecutionContext = AsyncKVStoreWriter.ExecutionContextInstance + // One may want to use different KV stores depending on whether tiling is on. + // The untiled version of Chronon works on "append" store semantics, and the tiled version works on "overwrite". protected def getKVStore: KVStore = { onlineImpl.genKvStore } diff --git a/flink/src/main/scala/ai/chronon/flink/AvroCodecFn.scala b/flink/src/main/scala/ai/chronon/flink/AvroCodecFn.scala index a88ea0aa7..16eb8dbb2 100644 --- a/flink/src/main/scala/ai/chronon/flink/AvroCodecFn.scala +++ b/flink/src/main/scala/ai/chronon/flink/AvroCodecFn.scala @@ -3,6 +3,7 @@ package ai.chronon.flink import org.slf4j.LoggerFactory import ai.chronon.api.Extensions.GroupByOps import ai.chronon.api.{Constants, DataModel, Query, StructType => ChrononStructType} +import ai.chronon.flink.window.TimestampedTile import ai.chronon.online.{AvroConversions, GroupByServingInfoParsed} import ai.chronon.online.KVStore.PutRequest import org.apache.flink.api.common.functions.RichFlatMapFunction @@ -13,28 +14,32 @@ import org.apache.flink.util.Collector import scala.jdk.CollectionConverters._ /** - * A Flink function that is responsible for converting the Spark expr eval output and converting that to a form - * that can be written out to the KV store (PutRequest object) - * @param groupByServingInfoParsed The GroupBy we are working with - * @tparam T The input data type + * Base class for the Avro conversion Flink operator. + * + * Subclasses should override the RichFlatMapFunction methods (flatMap) and groupByServingInfoParsed. + * + * @tparam IN The input data type which contains the data to be avro-converted to bytes. + * @tparam OUT The output data type (generally a PutRequest). */ -case class AvroCodecFn[T](groupByServingInfoParsed: GroupByServingInfoParsed) - extends RichFlatMapFunction[Map[String, Any], PutRequest] { - @transient lazy val logger = LoggerFactory.getLogger(getClass) +sealed abstract class BaseAvroCodecFn[IN, OUT] extends RichFlatMapFunction[IN, OUT] { + def groupByServingInfoParsed: GroupByServingInfoParsed + @transient lazy val logger = LoggerFactory.getLogger(getClass) @transient protected var avroConversionErrorCounter: Counter = _ + @transient protected var eventProcessingErrorCounter: Counter = + _ // Shared metric for errors across the entire Flink app. - protected val query: Query = groupByServingInfoParsed.groupBy.streamingSource.get.getEvents.query - protected val streamingDataset: String = groupByServingInfoParsed.groupBy.streamingDataset + protected lazy val query: Query = groupByServingInfoParsed.groupBy.streamingSource.get.getEvents.query + protected lazy val streamingDataset: String = groupByServingInfoParsed.groupBy.streamingDataset // TODO: update to use constant names that are company specific - protected val timeColumnAlias: String = Constants.TimeColumn - protected val timeColumn: String = Option(query.timeColumn).getOrElse(timeColumnAlias) + protected lazy val timeColumnAlias: String = Constants.TimeColumn + protected lazy val timeColumn: String = Option(query.timeColumn).getOrElse(timeColumnAlias) - protected val (keyToBytes, valueToBytes): (Any => Array[Byte], Any => Array[Byte]) = + protected lazy val (keyToBytes, valueToBytes): (Any => Array[Byte], Any => Array[Byte]) = getKVSerializers(groupByServingInfoParsed) - protected val (keyColumns, valueColumns): (Array[String], Array[String]) = getKVColumns - protected val extraneousRecord: Any => Array[Any] = { + protected lazy val (keyColumns, valueColumns): (Array[String], Array[String]) = getKVColumns + protected lazy val extraneousRecord: Any => Array[Any] = { case x: Map[_, _] if x.keys.forall(_.isInstanceOf[String]) => x.flatMap { case (key, value) => Array(key, value) }.toArray } @@ -70,6 +75,16 @@ case class AvroCodecFn[T](groupByServingInfoParsed: GroupByServingInfoParsed) val valueColumns = groupByServingInfoParsed.groupBy.aggregationInputs ++ additionalColumns (keyColumns, valueColumns) } +} + +/** + * A Flink function that is responsible for converting the Spark expr eval output and converting that to a form + * that can be written out to the KV store (PutRequest object) + * @param groupByServingInfoParsed The GroupBy we are working with + * @tparam T The input data type + */ +case class AvroCodecFn[T](groupByServingInfoParsed: GroupByServingInfoParsed) + extends BaseAvroCodecFn[Map[String, Any], PutRequest] { override def open(configuration: Configuration): Unit = { super.open(configuration) @@ -87,16 +102,69 @@ case class AvroCodecFn[T](groupByServingInfoParsed: GroupByServingInfoParsed) } catch { case e: Exception => // To improve availability, we don't rethrow the exception. We just drop the event - // and track the errors in a metric. If there are too many errors we'll get alerted/paged. + // and track the errors in a metric. Alerts should be set up on this metric. logger.error(s"Error converting to Avro bytes - $e") + eventProcessingErrorCounter.inc() avroConversionErrorCounter.inc() } def avroConvertMapToPutRequest(in: Map[String, Any]): PutRequest = { val tsMills = in(timeColumnAlias).asInstanceOf[Long] - val keyBytes = keyToBytes(keyColumns.map(in.get(_).get)) - val valueBytes = valueToBytes(valueColumns.map(in.get(_).get)) + val keyBytes = keyToBytes(keyColumns.map(in(_))) + val valueBytes = valueToBytes(valueColumns.map(in(_))) PutRequest(keyBytes, valueBytes, streamingDataset, Some(tsMills)) } +} +/** + * A Flink function that is responsible for converting an array of pre-aggregates (aka a tile) to a form + * that can be written out to the KV store (PutRequest object). + * + * @param groupByServingInfoParsed The GroupBy we are working with + * @tparam T The input data type + */ +case class TiledAvroCodecFn[T](groupByServingInfoParsed: GroupByServingInfoParsed) + extends BaseAvroCodecFn[TimestampedTile, PutRequest] { + override def open(configuration: Configuration): Unit = { + super.open(configuration) + val metricsGroup = getRuntimeContext.getMetricGroup + .addGroup("chronon") + .addGroup("feature_group", groupByServingInfoParsed.groupBy.getMetaData.getName) + avroConversionErrorCounter = metricsGroup.counter("avro_conversion_errors") + eventProcessingErrorCounter = metricsGroup.counter("event_processing_error") + } + override def close(): Unit = super.close() + + override def flatMap(value: TimestampedTile, out: Collector[PutRequest]): Unit = + try { + out.collect(avroConvertTileToPutRequest(value)) + } catch { + case e: Exception => + // To improve availability, we don't rethrow the exception. We just drop the event + // and track the errors in a metric. Alerts should be set up on this metric. + logger.error(s"Error converting to Avro bytes - ", e) + eventProcessingErrorCounter.inc() + avroConversionErrorCounter.inc() + } + + def avroConvertTileToPutRequest(in: TimestampedTile): PutRequest = { + val tsMills = in.latestTsMillis + + // 'keys' is a map of (key name in schema -> key value), e.g. Map("card_number" -> "4242-4242-4242-4242") + // We convert to AnyRef because Chronon expects an AnyRef (for scala <> java interoperability reasons). + val keys: Map[String, AnyRef] = keyColumns.zip(in.keys.map(_.asInstanceOf[AnyRef])).toMap + val keyBytes = keyToBytes(in.keys.toArray) + val valueBytes = in.tileBytes + + logger.debug( + s""" + |Avro converting tile to PutRequest - tile=${in} + |groupBy=${groupByServingInfoParsed.groupBy.getMetaData.getName} tsMills=$tsMills keys=$keys + |keyBytes=${java.util.Base64.getEncoder.encodeToString(keyBytes)} + |valueBytes=${java.util.Base64.getEncoder.encodeToString(valueBytes)} + |streamingDataset=$streamingDataset""".stripMargin + ) + + PutRequest(keyBytes, valueBytes, streamingDataset, Some(tsMills)) + } } diff --git a/flink/src/main/scala/ai/chronon/flink/FlinkJob.scala b/flink/src/main/scala/ai/chronon/flink/FlinkJob.scala index 1a275e950..25b7f0039 100644 --- a/flink/src/main/scala/ai/chronon/flink/FlinkJob.scala +++ b/flink/src/main/scala/ai/chronon/flink/FlinkJob.scala @@ -1,25 +1,31 @@ package ai.chronon.flink +import ai.chronon.aggregator.windowing.ResolutionUtils +import ai.chronon.api.{DataType} import ai.chronon.api.Extensions.{GroupByOps, SourceOps} -import ai.chronon.online.GroupByServingInfoParsed +import ai.chronon.flink.window.{ + AlwaysFireOnElementTrigger, + FlinkRowAggProcessFunction, + FlinkRowAggregationFunction, + KeySelector, + TimestampedTile +} +import ai.chronon.online.{GroupByServingInfoParsed, SparkConversions} import ai.chronon.online.KVStore.PutRequest -import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment} +import org.apache.flink.streaming.api.scala.{DataStream, OutputTag, StreamExecutionEnvironment} import org.apache.spark.sql.Encoder import org.apache.flink.api.scala._ import org.apache.flink.streaming.api.functions.async.RichAsyncFunction +import org.apache.flink.streaming.api.windowing.assigners.{TumblingEventTimeWindows, WindowAssigner} +import org.apache.flink.streaming.api.windowing.time.Time +import org.apache.flink.streaming.api.windowing.windows.TimeWindow +import org.slf4j.LoggerFactory /** - * Flink job that processes a single streaming GroupBy and writes out the results - * (raw events in untiled, pre-aggregates in case of tiled) to the KV store. - * At a high level, the operators are structured as follows: - * Kafka source -> Spark expression eval -> Avro conversion -> KV store writer - * Kafka source - Reads objects of type T (specific case class, Thrift / Proto) from a Kafka topic - * Spark expression eval - Evaluates the Spark SQL expression in the GroupBy and projects and filters the input data - * Avro conversion - Converts the Spark expr eval output to a form that can be written out to the KV store (PutRequest object) - * KV store writer - Writes the PutRequest objects to the KV store using the AsyncDataStream API + * Flink job that processes a single streaming GroupBy and writes out the results to the KV store. * - * In the untiled version there are no-shuffles and thus this ends up being a single node in the Flink DAG - * (with the above 4 operators and parallelism as injected by the user) + * There are two versions of the job, tiled and untiled. The untiled version writes out raw events while the tiled + * version writes out pre-aggregates. See the `runGroupByJob` and `runTiledGroupByJob` methods for more details. * * @param eventSrc - Provider of a Flink Datastream[T] for the given topic and feature group * @param sinkFn - Async Flink writer function to help us write to the KV store @@ -33,10 +39,13 @@ class FlinkJob[T](eventSrc: FlinkSource[T], groupByServingInfoParsed: GroupByServingInfoParsed, encoder: Encoder[T], parallelism: Int) { + private[this] val logger = LoggerFactory.getLogger(getClass) + + val featureGroupName: String = groupByServingInfoParsed.groupBy.getMetaData.getName + logger.info(f"Creating Flink job. featureGroupName=${featureGroupName}") protected val exprEval: SparkExpressionEvalFn[T] = new SparkExpressionEvalFn[T](encoder, groupByServingInfoParsed.groupBy) - val featureGroupName: String = groupByServingInfoParsed.groupBy.getMetaData.getName if (groupByServingInfoParsed.groupBy.streamingSource.isEmpty) { throw new IllegalArgumentException( @@ -47,7 +56,25 @@ class FlinkJob[T](eventSrc: FlinkSource[T], // The source of our Flink application is a Kafka topic val kafkaTopic: String = groupByServingInfoParsed.groupBy.streamingSource.get.topic + /** + * The "untiled" version of the Flink app. + * + * At a high level, the operators are structured as follows: + * Kafka source -> Spark expression eval -> Avro conversion -> KV store writer + * Kafka source - Reads objects of type T (specific case class, Thrift / Proto) from a Kafka topic + * Spark expression eval - Evaluates the Spark SQL expression in the GroupBy and projects and filters the input data + * Avro conversion - Converts the Spark expr eval output to a form that can be written out to the KV store + * (PutRequest object) + * KV store writer - Writes the PutRequest objects to the KV store using the AsyncDataStream API + * + * In this untiled version, there are no shuffles and thus this ends up being a single node in the Flink DAG + * (with the above 4 operators and parallelism as injected by the user). + */ def runGroupByJob(env: StreamExecutionEnvironment): DataStream[WriteResponse] = { + logger.info( + f"Running Flink job for featureGroupName=${featureGroupName}, kafkaTopic=${kafkaTopic}. " + + f"Tiling is disabled.") + val sourceStream: DataStream[T] = eventSrc .getDataStream(kafkaTopic, featureGroupName)(env, parallelism) @@ -70,4 +97,100 @@ class FlinkJob[T](eventSrc: FlinkSource[T], featureGroupName ) } + + /** + * The "tiled" version of the Flink app. + * + * The operators are structured as follows: + * 1. Kafka source - Reads objects of type T (specific case class, Thrift / Proto) from a Kafka topic + * 2. Spark expression eval - Evaluates the Spark SQL expression in the GroupBy and projects and filters the input + * data + * 3. Window/tiling - This window aggregates incoming events, keeps track of the IRs, and sends them forward so + * they are written out to the KV store + * 4. Avro conversion - Finishes converting the output of the window (the IRs) to a form that can be written out + * to the KV store (PutRequest object) + * 5. KV store writer - Writes the PutRequest objects to the KV store using the AsyncDataStream API + * + * The window causes a split in the Flink DAG, so there are two nodes, (1+2) and (3+4+5). + */ + def runTiledGroupByJob(env: StreamExecutionEnvironment): DataStream[WriteResponse] = { + logger.info( + f"Running Flink job for featureGroupName=${featureGroupName}, kafkaTopic=${kafkaTopic}. " + + f"Tiling is enabled.") + + val tilingWindowSizeInMillis: Option[Long] = + ResolutionUtils.getSmallestWindowResolutionInMillis(groupByServingInfoParsed.groupBy) + + val sourceStream: DataStream[T] = + eventSrc + .getDataStream(kafkaTopic, featureGroupName)(env, parallelism) + + val sparkExprEvalDS: DataStream[Map[String, Any]] = sourceStream + .flatMap(exprEval) + .uid(s"spark-expr-eval-flatmap-$featureGroupName") + .name(s"Spark expression eval for $featureGroupName") + .setParallelism(sourceStream.parallelism) // Use same parallelism as previous operator + + val inputSchema: Seq[(String, DataType)] = + exprEval.getOutputSchema.fields + .map(field => (field.name, SparkConversions.toChrononType(field.name, field.dataType))) + .toSeq + + val window = TumblingEventTimeWindows + .of(Time.milliseconds(tilingWindowSizeInMillis.get)) + .asInstanceOf[WindowAssigner[Map[String, Any], TimeWindow]] + + // An alternative to AlwaysFireOnElementTrigger can be used: BufferedProcessingTimeTrigger. + // The latter will buffer writes so they happen at most every X milliseconds per GroupBy & key. + val trigger = new AlwaysFireOnElementTrigger() + + // We use Flink "Side Outputs" to track any late events that aren't computed. + val tilingLateEventsTag = OutputTag[Map[String, Any]]("tiling-late-events") + + // The tiling operator works the following way: + // 1. Input: Spark expression eval (previous operator) + // 2. Key by the entity key(s) defined in the groupby + // 3. Window by a tumbling window + // 4. Use our custom trigger that will "FIRE" on every element + // 5. the AggregationFunction merges each incoming element with the current IRs which are kept in state + // - Each time a "FIRE" is triggered (i.e. on every event), getResult() is called and the current IRs are emitted + // 6. A process window function does additional processing each time the AggregationFunction emits results + // - The only purpose of this window function is to mark tiles as closed so we can do client-side caching in SFS + // 7. Output: TimestampedTile, containing the current IRs (Avro encoded) and the timestamp of the current element + val tilingDS: DataStream[TimestampedTile] = + sparkExprEvalDS + .keyBy(KeySelector.getKeySelectionFunction(groupByServingInfoParsed.groupBy)) + .window(window) + .trigger(trigger) + .sideOutputLateData(tilingLateEventsTag) + .aggregate( + // See Flink's "ProcessWindowFunction with Incremental Aggregation" + preAggregator = new FlinkRowAggregationFunction(groupByServingInfoParsed.groupBy, inputSchema), + windowFunction = new FlinkRowAggProcessFunction(groupByServingInfoParsed.groupBy, inputSchema) + ) + .uid(s"tiling-01-$featureGroupName") + .name(s"Tiling for $featureGroupName") + .setParallelism(sourceStream.parallelism) + + // Track late events + val sideOutputStream: DataStream[Map[String, Any]] = + tilingDS + .getSideOutput(tilingLateEventsTag) + .flatMap(new LateEventCounter(featureGroupName)) + .uid(s"tiling-side-output-01-$featureGroupName") + .name(s"Tiling Side Output Late Data for $featureGroupName") + .setParallelism(sourceStream.parallelism) + + val putRecordDS: DataStream[PutRequest] = tilingDS + .flatMap(new TiledAvroCodecFn[T](groupByServingInfoParsed)) + .uid(s"avro-conversion-01-$featureGroupName") + .name(s"Avro conversion for $featureGroupName") + .setParallelism(sourceStream.parallelism) + + AsyncKVStoreWriter.withUnorderedWaits( + putRecordDS, + sinkFn, + featureGroupName + ) + } } diff --git a/flink/src/main/scala/ai/chronon/flink/FlinkSource.scala b/flink/src/main/scala/ai/chronon/flink/FlinkSource.scala index ceeb0d9c6..336525556 100644 --- a/flink/src/main/scala/ai/chronon/flink/FlinkSource.scala +++ b/flink/src/main/scala/ai/chronon/flink/FlinkSource.scala @@ -6,6 +6,8 @@ abstract class FlinkSource[T] extends Serializable { /** * Return a Flink DataStream for the given topic and feature group. + * + * When implementing a source, you should also make a conscious decision about your allowed lateness strategy. */ def getDataStream(topic: String, groupName: String)( env: StreamExecutionEnvironment, diff --git a/flink/src/main/scala/ai/chronon/flink/RichMetricsOperators.scala b/flink/src/main/scala/ai/chronon/flink/RichMetricsOperators.scala new file mode 100644 index 000000000..086ecc865 --- /dev/null +++ b/flink/src/main/scala/ai/chronon/flink/RichMetricsOperators.scala @@ -0,0 +1,27 @@ +package ai.chronon.flink + +import org.apache.flink.api.common.functions.RichFlatMapFunction +import org.apache.flink.configuration.Configuration +import org.apache.flink.metrics.Counter +import org.apache.flink.util.Collector + +/** + * Function to count late events. + * + * This function should consume the Side Output of the main tiling window. + * */ +class LateEventCounter(featureGroupName: String) extends RichFlatMapFunction[Map[String, Any], Map[String, Any]] { + @transient private var lateEventCounter: Counter = _ + + override def open(parameters: Configuration): Unit = { + val metricsGroup = getRuntimeContext.getMetricGroup + .addGroup("chronon") + .addGroup("feature_group", featureGroupName) + lateEventCounter = metricsGroup.counter("tiling.late_events") + } + + override def flatMap(in: Map[String, Any], out: Collector[Map[String, Any]]): Unit = { + lateEventCounter.inc() + out.collect(in); + } +} diff --git a/flink/src/main/scala/ai/chronon/flink/SparkExpressionEvalFn.scala b/flink/src/main/scala/ai/chronon/flink/SparkExpressionEvalFn.scala index 78e44540b..793517cbc 100644 --- a/flink/src/main/scala/ai/chronon/flink/SparkExpressionEvalFn.scala +++ b/flink/src/main/scala/ai/chronon/flink/SparkExpressionEvalFn.scala @@ -101,7 +101,7 @@ class SparkExpressionEvalFn[T](encoder: Encoder[T], groupBy: GroupBy) extends Ri } catch { case e: Exception => // To improve availability, we don't rethrow the exception. We just drop the event - // and track the errors in a metric. If there are too many errors we'll get alerted/paged. + // and track the errors in a metric. Alerts should be set up on this metric. logger.error(s"Error evaluating Spark expression - $e") exprEvalErrorCounter.inc() } diff --git a/flink/src/main/scala/ai/chronon/flink/window/FlinkRowAggregators.scala b/flink/src/main/scala/ai/chronon/flink/window/FlinkRowAggregators.scala new file mode 100644 index 000000000..090772362 --- /dev/null +++ b/flink/src/main/scala/ai/chronon/flink/window/FlinkRowAggregators.scala @@ -0,0 +1,206 @@ +package ai.chronon.flink.window + +import ai.chronon.aggregator.row.RowAggregator +import ai.chronon.api.Extensions.GroupByOps +import ai.chronon.api.{Constants, DataType, GroupBy, Row} +import ai.chronon.online.{ArrayRow, TileCodec} +import org.apache.flink.api.common.functions.AggregateFunction +import org.apache.flink.configuration.Configuration +import org.apache.flink.metrics.Counter +import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction +import org.apache.flink.streaming.api.windowing.windows.TimeWindow +import org.apache.flink.util.Collector +import org.slf4j.LoggerFactory + +import scala.util.{Failure, Success, Try} + +/** + * TimestampedIR combines the current Intermediate Result with the timestamp of the event being processed. + * We need to keep track of the timestamp of the event processed so we can calculate processing lag down the line. + * + * Example: for a GroupBy with 2 windows, we'd have TimestampedTile( [IR for window 1, IR for window 2], timestamp ). + * + * @param ir the array of partial aggregates + * @param latestTsMillis timestamp of the current event being processed + */ +case class TimestampedIR( + ir: Array[Any], + latestTsMillis: Option[Long] +) + +/** + * Wrapper Flink aggregator around Chronon's RowAggregator. Relies on Flink to pass in + * the correct set of events for the tile. As the aggregates produced by this function + * are used on the serving side along with other pre-aggregates, we don't 'finalize' the + * Chronon RowAggregator and instead return the intermediate representation. + * + * (This cannot be a RichAggregateFunction because Flink does not support Rich functions in windows.) + */ +class FlinkRowAggregationFunction( + groupBy: GroupBy, + inputSchema: Seq[(String, DataType)] +) extends AggregateFunction[Map[String, Any], TimestampedIR, TimestampedIR] { + @transient private[flink] var rowAggregator: RowAggregator = _ + @transient lazy val logger = LoggerFactory.getLogger(getClass) + + private val valueColumns: Array[String] = inputSchema.map(_._1).toArray // column order matters + private val timeColumnAlias: String = Constants.TimeColumn + + /* + * Initialize the transient rowAggregator. + * Running this method is an idempotent operation: + * 1. The initialized RowAggregator is always the same given a `groupBy` and `inputSchema`. + * 2. The RowAggregator itself doens't hold state; Flink keeps track of the state of the IRs. + */ + private def initializeRowAggregator(): Unit = + rowAggregator = TileCodec.buildRowAggregator(groupBy, inputSchema) + + override def createAccumulator(): TimestampedIR = { + initializeRowAggregator() + TimestampedIR(rowAggregator.init, None) + } + + override def add( + element: Map[String, Any], + accumulatorIr: TimestampedIR + ): TimestampedIR = { + // Most times, the time column is a Long, but it could be a Double. + val tsMills = Try(element(timeColumnAlias).asInstanceOf[Long]) + .getOrElse(element(timeColumnAlias).asInstanceOf[Double].toLong) + val row = toChrononRow(element, tsMills) + + // Given that the rowAggregator is transient, it may be null when a job is restored from a checkpoint + if (rowAggregator == null) { + logger.debug( + f"The Flink RowAggregator was null for groupBy=${groupBy.getMetaData.getName} tsMills=$tsMills" + ) + initializeRowAggregator() + } + + logger.debug( + f"Flink pre-aggregates BEFORE adding new element: accumulatorIr=[${accumulatorIr.ir + .mkString(", ")}] groupBy=${groupBy.getMetaData.getName} tsMills=$tsMills element=$element" + ) + + val partialAggregates = Try { + rowAggregator.update(accumulatorIr.ir, row) + } + + partialAggregates match { + case Success(v) => { + logger.debug( + f"Flink pre-aggregates AFTER adding new element [${v.mkString(", ")}] " + + f"groupBy=${groupBy.getMetaData.getName} tsMills=$tsMills element=$element" + ) + TimestampedIR(v, Some(tsMills)) + } + case Failure(e) => + logger.error( + s"Flink error calculating partial row aggregate. " + + s"groupBy=${groupBy.getMetaData.getName} tsMills=$tsMills element=$element", + e + ) + throw e + } + } + + // Note we return intermediate results here as the results of this + // aggregator are used on the serving side along with other pre-aggregates + override def getResult(accumulatorIr: TimestampedIR): TimestampedIR = + accumulatorIr + + override def merge(aIr: TimestampedIR, bIr: TimestampedIR): TimestampedIR = + TimestampedIR( + rowAggregator.merge(aIr.ir, bIr.ir), + aIr.latestTsMillis + .flatMap(aL => bIr.latestTsMillis.map(bL => Math.max(aL, bL))) + .orElse(aIr.latestTsMillis.orElse(bIr.latestTsMillis)) + ) + + def toChrononRow(value: Map[String, Any], tsMills: Long): Row = { + // The row values need to be in the same order as the input schema columns + // The reason they are out of order in the first place is because the CatalystUtil does not return values in the + // same order as the schema + val values: Array[Any] = valueColumns.map(value(_)) + new ArrayRow(values, tsMills) + } +} + +/** + * TimestampedTile combines the entity keys, the encoded Intermediate Result, and the timestamp of the event being processed. + * + * We need the timestamp of the event processed so we can calculate processing lag down the line. + * + * @param keys the GroupBy entity keys + * @param tileBytes encoded tile IR + * @param latestTsMillis timestamp of the current event being processed + */ +case class TimestampedTile( + keys: List[Any], + tileBytes: Array[Byte], + latestTsMillis: Long +) + +// This process function is only meant to be used downstream of the ChrononFlinkAggregationFunction +class FlinkRowAggProcessFunction( + groupBy: GroupBy, + inputSchema: Seq[(String, DataType)] +) extends ProcessWindowFunction[TimestampedIR, TimestampedTile, List[Any], TimeWindow] { + + @transient private[flink] var tileCodec: TileCodec = _ + @transient lazy val logger = LoggerFactory.getLogger(getClass) + + @transient private var rowProcessingErrorCounter: Counter = _ + @transient private var eventProcessingErrorCounter: Counter = + _ // Shared metric for errors across the entire Flink app. + + override def open(parameters: Configuration): Unit = { + super.open(parameters) + tileCodec = new TileCodec(groupBy, inputSchema) + + val metricsGroup = getRuntimeContext.getMetricGroup + .addGroup("chronon") + .addGroup("feature_group", groupBy.getMetaData.getName) + rowProcessingErrorCounter = metricsGroup.counter("tiling_process_function_error") + eventProcessingErrorCounter = metricsGroup.counter("event_processing_error") + } + + /** + * Process events emitted from the aggregate function. + * Output format: (keys, encoded tile IR, timestamp of the event being processed) + * */ + override def process( + keys: List[Any], + context: Context, + elements: Iterable[TimestampedIR], + out: Collector[TimestampedTile] + ): Unit = { + val windowEnd = context.window.getEnd + val irEntry = elements.head + val isComplete = context.currentWatermark >= windowEnd + + val tileBytes = Try { + tileCodec.makeTileIr(irEntry.ir, isComplete) + } + + tileBytes match { + case Success(v) => { + logger.debug( + s""" + |Flink aggregator processed element irEntry=$irEntry + |tileBytes=${java.util.Base64.getEncoder.encodeToString(v)} + |windowEnd=$windowEnd groupBy=${groupBy.getMetaData.getName} + |keys=$keys isComplete=$isComplete tileAvroSchema=${tileCodec.tileAvroSchema}""" + ) + // The timestamp should never be None here. + out.collect(TimestampedTile(keys, v, irEntry.latestTsMillis.get)) + } + case Failure(e) => + // To improve availability, we don't rethrow the exception. We just drop the event + // and track the errors in a metric. Alerts should be set up on this metric. + logger.error(s"Flink process error making tile IR", e) + eventProcessingErrorCounter.inc() + rowProcessingErrorCounter.inc() + } + } +} diff --git a/flink/src/main/scala/ai/chronon/flink/window/KeySelector.scala b/flink/src/main/scala/ai/chronon/flink/window/KeySelector.scala new file mode 100644 index 000000000..900d8bebd --- /dev/null +++ b/flink/src/main/scala/ai/chronon/flink/window/KeySelector.scala @@ -0,0 +1,33 @@ +package ai.chronon.flink.window + +import ai.chronon.api.GroupBy + +import scala.jdk.CollectionConverters._ +import org.slf4j.LoggerFactory + +/** + * A KeySelector is what Flink uses to determine how to partition a DataStream. In a distributed environment, the + * KeySelector guarantees that events with the same key always end up in the same machine. + * If invoked multiple times on the same object, the returned key must be the same. + */ +object KeySelector { + private[this] lazy val logger = LoggerFactory.getLogger(getClass) + + /** + * Given a GroupBy, create a function to key the output of a SparkExprEval operator by the entities defined in the + * GroupBy. The function returns a List of size equal to the number of keys in the GroupBy. + * + * For example, if a GroupBy is defined as "GroupBy(..., keys=["color", "size"], ...), the function will key the + * Flink SparkExprEval DataStream by color and size, so all events with the same (color, size) are sent to the same + * operator. + */ + def getKeySelectionFunction(groupBy: GroupBy): Map[String, Any] => List[Any] = { + // List uses MurmurHash.seqHash for its .hashCode(), which gives us hashing based on content. + // (instead of based on the instance, which is the case for Array). + val groupByKeys: List[String] = groupBy.keyColumns.asScala.toList + logger.info( + f"Creating key selection function for Flink app. groupByKeys=$groupByKeys" + ) + (sparkEvalOutput: Map[String, Any]) => groupByKeys.collect(sparkEvalOutput) + } +} diff --git a/flink/src/main/scala/ai/chronon/flink/window/Trigger.scala b/flink/src/main/scala/ai/chronon/flink/window/Trigger.scala new file mode 100644 index 000000000..f72dddbe6 --- /dev/null +++ b/flink/src/main/scala/ai/chronon/flink/window/Trigger.scala @@ -0,0 +1,180 @@ +package ai.chronon.flink.window + +import org.apache.flink.api.common.state.{ValueState, ValueStateDescriptor} +import org.apache.flink.streaming.api.windowing.triggers.{Trigger, TriggerResult} +import org.apache.flink.streaming.api.windowing.windows.TimeWindow + +/** + * Custom Flink Trigger that fires on every event received. + * */ +class AlwaysFireOnElementTrigger extends Trigger[Map[String, Any], TimeWindow] { + override def onElement( + element: Map[String, Any], + timestamp: Long, + window: TimeWindow, + ctx: Trigger.TriggerContext + ): TriggerResult = + TriggerResult.FIRE + + override def onProcessingTime( + time: Long, + window: TimeWindow, + ctx: Trigger.TriggerContext + ): TriggerResult = + TriggerResult.CONTINUE + + override def onEventTime( + time: Long, + window: TimeWindow, + ctx: Trigger.TriggerContext + ): TriggerResult = + // We don't need to PURGE here since we don't have explicit state. + // Flink's "Window Lifecycle" doc: "The window is completely removed when the time (event or processing time) + // passes its end timestamp plus the user-specified allowed lateness" + TriggerResult.CONTINUE + + // This Trigger doesn't hold state, so we don't need to do anything when the window is purged. + override def clear( + window: TimeWindow, + ctx: Trigger.TriggerContext + ): Unit = {} + + override def canMerge: Boolean = true + + override def onMerge( + window: TimeWindow, + mergeContext: Trigger.OnMergeContext + ): Unit = {} +} + +/** + * BufferedProcessingTimeTrigger is a custom Trigger that fires at most every 'bufferSizeMillis' within a window. + * It is intended for incremental window aggregations using event-time semantics. + * + * Purpose: This trigger exists as an optimization to reduce the number of writes to our online store and better handle + * contention that arises from having hot keys. + * + * Details: + * - The buffer timers are NOT aligned with the UNIX Epoch, they can fire at any timestamp. e.g., if the first + * event arrives at 14ms, and the buffer size is 100ms, the timer will fire at 114ms. + * - Buffer timers are only scheduled when events come in. If there's a gap in events, this trigger won't fire. + * + * Edge cases handled: + * - If the (event-time) window closes before the last (processing-time) buffer fires, this trigger will fire + * the remaining buffered elements before closing. + * + * Example: + * Window size = 300,000 ms (5 minutes) + * BufferSizeMillis = 100 ms. + * Assume we are using this trigger on a GroupBy that counts the number unique IDs see. + * For simplicity, assume event time and processing time are synchronized (although in practice this is never true) + * + * Event 1: ts = 14 ms, ID = A. + * preAggregate (a Set that keeps track of all unique IDs seen) = [A] + * this causes a timer to be set for timestamp = 114 ms. + * Event 2: ts = 38 ms, ID = B. + * preAggregate = [A, B] + * Event 3: ts = 77 ms, ID = B. + * preAggregate = [A, B] + * Timer set for 114ms fires. + * we emit the preAggregate [A, B]. + * Event 4: ts = 400ms, ID = C. + * preAggregate = [A,B,C] (we don't purge the previous events when the time fires!) + * this causes a timer to be set for timestamp = 500 ms + * Timer set for 500ms fires. + * we emit the preAggregate [A, B, C]. + * */ +class BufferedProcessingTimeTrigger(bufferSizeMillis: Long) extends Trigger[Map[String, Any], TimeWindow] { + // Each pane has its own state. A Flink pane is an actual instance of a defined window for a given key. + private val nextTimerTimestampStateDescriptor = + new ValueStateDescriptor[java.lang.Long]("nextTimerTimestampState", classOf[java.lang.Long]) + + /** + * When an element arrives, set up a processing time trigger to fire after `bufferSizeMillis`. + * If a timer is already set, we don't want to create a new one. + * + * Late events are treated the same way as regular events; they will still get buffered. + */ + override def onElement( + element: Map[String, Any], + timestamp: Long, + window: TimeWindow, + ctx: Trigger.TriggerContext + ): TriggerResult = { + val nextTimerTimestampState: ValueState[java.lang.Long] = ctx.getPartitionedState( + nextTimerTimestampStateDescriptor + ) + + // Set timer if one doesn't already exist + if (nextTimerTimestampState.value() == null) { + val nextFireTimestampMillis = ctx.getCurrentProcessingTime + bufferSizeMillis + ctx.registerProcessingTimeTimer(nextFireTimestampMillis) + nextTimerTimestampState.update(nextFireTimestampMillis) + } + + TriggerResult.CONTINUE + } + + /** + * When the processing-time timer set up in `onElement` fires, we emit the results without purging the window. + * i.e., we keep the current pre-aggregates/IRs in the window so we can continue aggregating. + * + * Note: We don't need to PURGE the window anywhere. Flink will do that automatically when a window expires. + * Flink Docs: "[...] Flink keeps the state of windows until their allowed lateness expires. Once this happens, Flink + * removes the window and deletes its state [...]". + * + * Note: In case the app crashes after a processing-time timer is set, but before it fires, it will fire immediately + * after recovery. + */ + override def onProcessingTime( + timestamp: Long, + window: TimeWindow, + ctx: Trigger.TriggerContext + ): TriggerResult = { + val nextTimerTimestampState = ctx.getPartitionedState(nextTimerTimestampStateDescriptor) + nextTimerTimestampState.update(null) + TriggerResult.FIRE + } + + /** + * Fire any elements left in the buffer if the window ends before the last processing-time timer is fired. + * This can happen because we are using event-time semantics for the window, and processing-time for the buffer timer. + * + * Flink automatically sets up an event timer for the end of the window (+ allowed lateness) as soon as it + * sees the first element in it. See 'registerCleanupTimer' in Flink's 'WindowOperator.java'. + */ + override def onEventTime( + timestamp: Long, + window: TimeWindow, + ctx: Trigger.TriggerContext + ): TriggerResult = { + val nextTimerTimestampState: ValueState[java.lang.Long] = ctx.getPartitionedState( + nextTimerTimestampStateDescriptor + ) + if (nextTimerTimestampState.value() != null) { + TriggerResult.FIRE + } else { + TriggerResult.CONTINUE + } + } + + /** + * When a window is being purged (e.g., because it has expired), we delete timers and state. + * + * This function is called immediately after our 'onEventTime' which fires at the end of the window. + * See 'onEventTime' in Flink's 'WindowOperator.java'. + */ + override def clear(window: TimeWindow, ctx: Trigger.TriggerContext): Unit = { + // Remove the lingering processing-time timer if it exist. + val nextTimerTimestampState: ValueState[java.lang.Long] = ctx.getPartitionedState( + nextTimerTimestampStateDescriptor + ) + val nextTimerTimestampStateValue = nextTimerTimestampState.value() + if (nextTimerTimestampStateValue != null) { + ctx.deleteProcessingTimeTimer(nextTimerTimestampStateValue) + } + + // Delete state + nextTimerTimestampState.clear() + } +} diff --git a/flink/src/test/scala/ai/chronon/flink/test/FlinkJobIntegrationTest.scala b/flink/src/test/scala/ai/chronon/flink/test/FlinkJobIntegrationTest.scala index 3c04014cb..83f4bd55d 100644 --- a/flink/src/test/scala/ai/chronon/flink/test/FlinkJobIntegrationTest.scala +++ b/flink/src/test/scala/ai/chronon/flink/test/FlinkJobIntegrationTest.scala @@ -1,44 +1,20 @@ package ai.chronon.flink.test -import ai.chronon.api.Extensions.{WindowOps, WindowUtils} -import ai.chronon.api.{GroupBy, GroupByServingInfo, PartitionSpec} -import ai.chronon.flink.{FlinkJob, FlinkSource, SparkExpressionEvalFn, WriteResponse} -import ai.chronon.online.Extensions.StructTypeOps +import ai.chronon.flink.window.{TimestampedIR, TimestampedTile} +import ai.chronon.flink.{FlinkJob, SparkExpressionEvalFn} import ai.chronon.online.{Api, GroupByServingInfoParsed} +import ai.chronon.online.KVStore.PutRequest import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration -import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment} -import org.apache.flink.api.scala._ -import org.apache.flink.streaming.api.functions.sink.SinkFunction +import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment import org.apache.flink.test.util.MiniClusterWithClientResource import org.apache.spark.sql.Encoders -import org.apache.spark.sql.types.StructType import org.junit.Assert.assertEquals import org.junit.{After, Before, Test} import org.mockito.Mockito.withSettings import org.scalatestplus.mockito.MockitoSugar.mock -import java.util -import java.util.Collections import scala.jdk.CollectionConverters.asScalaBufferConverter -class E2EEventSource(mockEvents: Seq[E2ETestEvent]) extends FlinkSource[E2ETestEvent] { - override def getDataStream(topic: String, groupName: String)(env: StreamExecutionEnvironment, - parallelism: Int): DataStream[E2ETestEvent] = { - env.fromCollection(mockEvents) - } -} - -class CollectSink extends SinkFunction[WriteResponse] { - override def invoke(value: WriteResponse, context: SinkFunction.Context): Unit = { - CollectSink.values.add(value) - } -} - -object CollectSink { - // must be static - val values: util.List[WriteResponse] = Collections.synchronizedList(new util.ArrayList()) -} - class FlinkJobIntegrationTest { val flinkCluster = new MiniClusterWithClientResource( @@ -47,6 +23,30 @@ class FlinkJobIntegrationTest { .setNumberTaskManagers(1) .build) + // Decode a PutRequest into a TimestampedTile + def avroConvertPutRequestToTimestampedTile[T]( + in: PutRequest, + groupByServingInfoParsed: GroupByServingInfoParsed + ): TimestampedTile = { + // Decode the key bytes into a GenericRecord + val tileBytes = in.valueBytes + val record = groupByServingInfoParsed.keyCodec.decode(in.keyBytes) + + // Get all keys we expect to be in the GenericRecord + val decodedKeys: List[String] = + groupByServingInfoParsed.groupBy.keyColumns.asScala.map(record.get(_).toString).toList + + val tsMills = in.tsMillis.get + TimestampedTile(decodedKeys, tileBytes, tsMills) + } + + // Decode a TimestampedTile into a TimestampedIR + def avroConvertTimestampedTileToTimestampedIR(timestampedTile: TimestampedTile, + groupByServingInfoParsed: GroupByServingInfoParsed): TimestampedIR = { + val tileIR = groupByServingInfoParsed.tiledCodec.decodeTileIr(timestampedTile.tileBytes) + TimestampedIR(tileIR._1, Some(timestampedTile.latestTsMillis)) + } + @Before def setup(): Unit = { flinkCluster.before() @@ -56,45 +56,7 @@ class FlinkJobIntegrationTest { @After def teardown(): Unit = { flinkCluster.after() - } - - private def makeTestGroupByServingInfoParsed(groupBy: GroupBy, - inputSchema: StructType, - outputSchema: StructType): GroupByServingInfoParsed = { - val groupByServingInfo = new GroupByServingInfo() - groupByServingInfo.setGroupBy(groupBy) - - // Set input avro schema for groupByServingInfo - groupByServingInfo.setInputAvroSchema( - inputSchema.toAvroSchema("Input").toString(true) - ) - - // Set key avro schema for groupByServingInfo - groupByServingInfo.setKeyAvroSchema( - StructType( - groupBy.keyColumns.asScala.map { keyCol => - val keyColStructType = outputSchema.fields.find(field => field.name == keyCol) - keyColStructType match { - case Some(col) => col - case None => - throw new IllegalArgumentException(s"Missing key col from output schema: $keyCol") - } - } - ).toAvroSchema("Key") - .toString(true) - ) - - // Set value avro schema for groupByServingInfo - val aggInputColNames = groupBy.aggregations.asScala.map(_.inputColumn).toList - groupByServingInfo.setSelectedAvroSchema( - StructType(outputSchema.fields.filter(field => aggInputColNames.contains(field.name))) - .toAvroSchema("Value") - .toString(true) - ) - new GroupByServingInfoParsed( - groupByServingInfo, - PartitionSpec(format = "yyyy-MM-dd", spanMillis = WindowUtils.Day.millis) - ) + CollectSink.values.clear() } @Test @@ -113,9 +75,10 @@ class FlinkJobIntegrationTest { val outputSchema = new SparkExpressionEvalFn(encoder, groupBy).getOutputSchema - val groupByServingInfoParsed = makeTestGroupByServingInfoParsed(groupBy, encoder.schema, outputSchema) + val groupByServingInfoParsed = + FlinkTestUtils.makeTestGroupByServingInfoParsed(groupBy, encoder.schema, outputSchema) val mockApi = mock[Api](withSettings().serializable()) - val writerFn = new MockAsyncKVStoreWriter(Seq(true), mockApi, "testFG") + val writerFn = new MockAsyncKVStoreWriter(Seq(true), mockApi, "testFlinkJobEndToEndFG") val job = new FlinkJob[E2ETestEvent](source, writerFn, groupByServingInfoParsed, encoder, 2) job.runGroupByJob(env).addSink(new CollectSink) @@ -132,4 +95,67 @@ class FlinkJobIntegrationTest { // check that all the writes were successful assertEquals(writeEventCreatedDS.map(_.status), Seq(true, true, true)) } + + @Test + def testTiledFlinkJobEndToEnd(): Unit = { + implicit val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment + + // Create some test events with multiple different ids so we can check if tiling/pre-aggregation works correctly + // for each of them. + val id1Elements = Seq(E2ETestEvent(id = "id1", int_val = 1, double_val = 1.5, created = 1L), + E2ETestEvent(id = "id1", int_val = 1, double_val = 2.5, created = 2L)) + val id2Elements = Seq(E2ETestEvent(id = "id2", int_val = 1, double_val = 10.0, created = 3L)) + val elements: Seq[E2ETestEvent] = id1Elements ++ id2Elements + val source = new WatermarkedE2EEventSource(elements) + + // Make a GroupBy that SUMs the double_val of the elements. + val groupBy = FlinkTestUtils.makeGroupBy(Seq("id")) + + // Prepare the Flink Job + val encoder = Encoders.product[E2ETestEvent] + val outputSchema = new SparkExpressionEvalFn(encoder, groupBy).getOutputSchema + val groupByServingInfoParsed = + FlinkTestUtils.makeTestGroupByServingInfoParsed(groupBy, encoder.schema, outputSchema) + val mockApi = mock[Api](withSettings().serializable()) + val writerFn = new MockAsyncKVStoreWriter(Seq(true), mockApi, "testTiledFlinkJobEndToEndFG") + val job = new FlinkJob[E2ETestEvent](source, writerFn, groupByServingInfoParsed, encoder, 2) + job.runTiledGroupByJob(env).addSink(new CollectSink) + + env.execute("TiledFlinkJobIntegrationTest") + + // capture the datastream of the 'created' timestamps of all the written out events + val writeEventCreatedDS = CollectSink.values.asScala + + // BASIC ASSERTIONS + // All elements were processed + assert(writeEventCreatedDS.size == elements.size) + // check that the timestamps of the written out events match the input events + // we use a Set as we can have elements out of order given we have multiple tasks + assertEquals(writeEventCreatedDS.map(_.putRequest.tsMillis).map(_.get).toSet, elements.map(_.created).toSet) + // check that all the writes were successful + assertEquals(writeEventCreatedDS.map(_.status), Seq(true, true, true)) + + // Assert that the pre-aggregates/tiles are correct + // Get a list of the final IRs for each key. + val finalIRsPerKey: Map[List[Any], List[Any]] = writeEventCreatedDS + .map(writeEvent => { + // First, we work back from the PutRequest decode it to TimestampedTile and then TimestampedIR + val timestampedTile = + avroConvertPutRequestToTimestampedTile(writeEvent.putRequest, groupByServingInfoParsed) + val timestampedIR = avroConvertTimestampedTileToTimestampedIR(timestampedTile, groupByServingInfoParsed) + + // We're interested in the the keys, Intermediate Result, and the timestamp for each processed event + (timestampedTile.keys, timestampedIR.ir.toList, writeEvent.putRequest.tsMillis.get) + }) + .groupBy(_._1) // Group by the keys + .map((keys) => (keys._1, keys._2.maxBy(_._3)._2)) // pick just the events with largest timestamp + + // Looking back at our test events, we expect the following Intermediate Results to be generated: + val expectedFinalIRsPerKey = Map( + List("id1") -> List(4.0), // Add up the double_val of the two 'id1' events + List("id2") -> List(10.0) + ) + + assertEquals(expectedFinalIRsPerKey, finalIRsPerKey) + } } diff --git a/flink/src/test/scala/ai/chronon/flink/test/FlinkTestUtils.scala b/flink/src/test/scala/ai/chronon/flink/test/FlinkTestUtils.scala index 790b06f28..ff6a9ae2f 100644 --- a/flink/src/test/scala/ai/chronon/flink/test/FlinkTestUtils.scala +++ b/flink/src/test/scala/ai/chronon/flink/test/FlinkTestUtils.scala @@ -1,19 +1,50 @@ package ai.chronon.flink.test import ai.chronon.api.{Accuracy, Builders, GroupBy, Operation, TimeUnit, Window} -import ai.chronon.flink.AsyncKVStoreWriter +import ai.chronon.flink.{AsyncKVStoreWriter, FlinkSource, WriteResponse} import ai.chronon.online.{Api, KVStore} -import org.apache.flink.api.java.ExecutionEnvironment -import org.apache.flink.configuration.Configuration -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment +import ai.chronon.api.Extensions.{WindowOps, WindowUtils} +import ai.chronon.api.{GroupByServingInfo, PartitionSpec} +import ai.chronon.online.Extensions.StructTypeOps +import ai.chronon.online.GroupByServingInfoParsed +import org.apache.flink.api.common.eventtime.{SerializableTimestampAssigner, WatermarkStrategy} +import org.apache.flink.api.scala.createTypeInformation +import org.apache.flink.streaming.api.functions.sink.SinkFunction +import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment} +import org.apache.spark.sql.types.StructType import org.mockito.ArgumentMatchers import org.mockito.Mockito.{when, withSettings} import org.scalatestplus.mockito.MockitoSugar.mock +import java.time.Duration +import java.util +import java.util.Collections import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future} +import scala.jdk.CollectionConverters.asScalaBufferConverter case class E2ETestEvent(id: String, int_val: Int, double_val: Double, created: Long) +class E2EEventSource(mockEvents: Seq[E2ETestEvent]) extends FlinkSource[E2ETestEvent] { + override def getDataStream(topic: String, groupName: String)(env: StreamExecutionEnvironment, + parallelism: Int): DataStream[E2ETestEvent] = { + env.fromCollection(mockEvents) + } +} + +class WatermarkedE2EEventSource(mockEvents: Seq[E2ETestEvent]) extends FlinkSource[E2ETestEvent] { + def watermarkStrategy: WatermarkStrategy[E2ETestEvent] = + WatermarkStrategy + .forBoundedOutOfOrderness[E2ETestEvent](Duration.ofSeconds(5)) + .withTimestampAssigner(new SerializableTimestampAssigner[E2ETestEvent] { + override def extractTimestamp(event: E2ETestEvent, previousElementTimestamp: Long): Long = + event.created + }) + override def getDataStream(topic: String, groupName: String)(env: StreamExecutionEnvironment, + parallelism: Int): DataStream[E2ETestEvent] = { + env.fromCollection(mockEvents).assignTimestampsAndWatermarks(watermarkStrategy) + } +} + class MockAsyncKVStoreWriter(mockResults: Seq[Boolean], onlineImpl: Api, featureGroup: String) extends AsyncKVStoreWriter(onlineImpl, featureGroup) { override def getKVStore: KVStore = { @@ -25,8 +56,56 @@ class MockAsyncKVStoreWriter(mockResults: Seq[Boolean], onlineImpl: Api, feature } } +class CollectSink extends SinkFunction[WriteResponse] { + override def invoke(value: WriteResponse, context: SinkFunction.Context): Unit = { + CollectSink.values.add(value) + } +} + +object CollectSink { + // must be static + val values: util.List[WriteResponse] = Collections.synchronizedList(new util.ArrayList()) +} + object FlinkTestUtils { + def makeTestGroupByServingInfoParsed(groupBy: GroupBy, + inputSchema: StructType, + outputSchema: StructType): GroupByServingInfoParsed = { + val groupByServingInfo = new GroupByServingInfo() + groupByServingInfo.setGroupBy(groupBy) + // Set input avro schema for groupByServingInfo + groupByServingInfo.setInputAvroSchema( + inputSchema.toAvroSchema("Input").toString(true) + ) + + // Set key avro schema for groupByServingInfo + groupByServingInfo.setKeyAvroSchema( + StructType( + groupBy.keyColumns.asScala.map { keyCol => + val keyColStructType = outputSchema.fields.find(field => field.name == keyCol) + keyColStructType match { + case Some(col) => col + case None => + throw new IllegalArgumentException(s"Missing key col from output schema: $keyCol") + } + } + ).toAvroSchema("Key") + .toString(true) + ) + + // Set value avro schema for groupByServingInfo + val aggInputColNames = groupBy.aggregations.asScala.map(_.inputColumn).toList + groupByServingInfo.setSelectedAvroSchema( + StructType(outputSchema.fields.filter(field => aggInputColNames.contains(field.name))) + .toAvroSchema("Value") + .toString(true) + ) + new GroupByServingInfoParsed( + groupByServingInfo, + PartitionSpec(format = "yyyy-MM-dd", spanMillis = WindowUtils.Day.millis) + ) + } def makeGroupBy(keyColumns: Seq[String], filters: Seq[String] = Seq.empty): GroupBy = Builders.GroupBy( sources = Seq( diff --git a/flink/src/test/scala/ai/chronon/flink/test/window/FlinkRowAggregationFunctionTest.scala b/flink/src/test/scala/ai/chronon/flink/test/window/FlinkRowAggregationFunctionTest.scala new file mode 100644 index 000000000..e702a2fa1 --- /dev/null +++ b/flink/src/test/scala/ai/chronon/flink/test/window/FlinkRowAggregationFunctionTest.scala @@ -0,0 +1,218 @@ +package ai.chronon.flink.test.window + +import ai.chronon.api._ +import ai.chronon.flink.window.FlinkRowAggregationFunction +import ai.chronon.online.TileCodec +import org.junit.Assert.fail +import org.junit.Test + +import scala.util.{Failure, Try} + +class FlinkRowAggregationFunctionTest { + private val aggregations: Seq[Aggregation] = Seq( + Builders.Aggregation( + Operation.AVERAGE, + "views", + Seq( + new Window(1, TimeUnit.DAYS), + new Window(1, TimeUnit.HOURS), + new Window(30, TimeUnit.DAYS) + ) + ), + Builders.Aggregation( + Operation.AVERAGE, + "rating", + Seq( + new Window(1, TimeUnit.DAYS), + new Window(1, TimeUnit.HOURS) + ) + ), + Builders.Aggregation( + Operation.MAX, + "title", + Seq( + new Window(1, TimeUnit.DAYS) + ) + ), + Builders.Aggregation( + Operation.LAST, + "title", + Seq( + new Window(1, TimeUnit.DAYS) + ) + ) + ) + + private val schema = List( + Constants.TimeColumn -> LongType, + "views" -> IntType, + "rating" -> FloatType, + "title" -> StringType + ) + + @Test + def testFlinkAggregatorProducesCorrectResults(): Unit = { + val groupByMetadata = Builders.MetaData(name = "my_group_by") + val groupBy = Builders.GroupBy(metaData = groupByMetadata, aggregations = aggregations) + val aggregateFunc = new FlinkRowAggregationFunction(groupBy, schema) + + var acc = aggregateFunc.createAccumulator() + val rows = Seq( + createRow(1519862399984L, 4, 4.0f, "A"), + createRow(1519862399984L, 40, 5.0f, "B"), + createRow(1519862399988L, 3, 3.0f, "C"), + createRow(1519862399988L, 5, 4.0f, "D"), + createRow(1519862399994L, 4, 4.0f, "A"), + createRow(1519862399999L, 10, 4.0f, "A") + ) + rows.foreach(row => acc = aggregateFunc.add(row, acc)) + val result = aggregateFunc.getResult(acc) + + // we sanity check the final result of the accumulator + // to do so, we must first expand / decompress the windowed tile IR into a full tile + // then we can finalize the tile and get the final result + val tileCodec = new TileCodec(groupBy, schema) + val expandedIr = tileCodec.expandWindowedTileIr(result.ir) + val finalResult = tileCodec.windowedRowAggregator.finalize(expandedIr) + + // expect 7 columns as we have 3 view avg time windows, 2 rating avg and 1 max title, 1 last title + assert(finalResult.length == 7) + val expectedAvgViews = 11.0f + val expectedAvgRating = 4.0f + val expectedMax = "D" + val expectedLast = "A" + val expectedResult = Array( + expectedAvgViews, + expectedAvgViews, + expectedAvgViews, + expectedAvgRating, + expectedAvgRating, + expectedMax, + expectedLast + ) + assert(finalResult sameElements expectedResult) + } + + @Test + def testFlinkAggregatorResultsCanBeMergedWithOtherPreAggregates(): Unit = { + val groupByMetadata = Builders.MetaData(name = "my_group_by") + val groupBy = Builders.GroupBy(metaData = groupByMetadata, aggregations = aggregations) + val aggregateFunc = new FlinkRowAggregationFunction(groupBy, schema) + + // create partial aggregate 1 + var acc1 = aggregateFunc.createAccumulator() + val rows1 = Seq( + createRow(1519862399984L, 4, 4.0f, "A"), + createRow(1519862399984L, 40, 5.0f, "B") + ) + rows1.foreach(row => acc1 = aggregateFunc.add(row, acc1)) + val partialResult1 = aggregateFunc.getResult(acc1) + + // create partial aggregate 2 + var acc2 = aggregateFunc.createAccumulator() + val rows2 = Seq( + createRow(1519862399988L, 3, 3.0f, "C"), + createRow(1519862399988L, 5, 4.0f, "D") + ) + rows2.foreach(row => acc2 = aggregateFunc.add(row, acc2)) + val partialResult2 = aggregateFunc.getResult(acc2) + + // create partial aggregate 3 + var acc3 = aggregateFunc.createAccumulator() + val rows3 = Seq( + createRow(1519862399994L, 4, 4.0f, "A"), + createRow(1519862399999L, 10, 4.0f, "A") + ) + rows3.foreach(row => acc3 = aggregateFunc.add(row, acc3)) + val partialResult3 = aggregateFunc.getResult(acc3) + + // lets merge the partial results together and check + val mergedPartialAggregates = aggregateFunc.rowAggregator + .merge( + aggregateFunc.rowAggregator.merge(partialResult1.ir, partialResult2.ir), + partialResult3.ir + ) + + // we sanity check the final result of the accumulator + // to do so, we must first expand / decompress the windowed tile IR into a full tile + // then we can finalize the tile and get the final result + val tileCodec = new TileCodec(groupBy, schema) + val expandedIr = tileCodec.expandWindowedTileIr(mergedPartialAggregates) + val finalResult = tileCodec.windowedRowAggregator.finalize(expandedIr) + + // expect 7 columns as we have 3 view avg time windows, 2 rating avg and 1 max title, 1 last title + assert(finalResult.length == 7) + val expectedAvgViews = 11.0f + val expectedAvgRating = 4.0f + val expectedMax = "D" + val expectedLast = "A" + val expectedResult = Array( + expectedAvgViews, + expectedAvgViews, + expectedAvgViews, + expectedAvgRating, + expectedAvgRating, + expectedMax, + expectedLast + ) + assert(finalResult sameElements expectedResult) + } + + @Test + def testFlinkAggregatorProducesCorrectResultsIfInputIsInIncorrectOrder(): Unit = { + val groupByMetadata = Builders.MetaData(name = "my_group_by") + val groupBy = Builders.GroupBy(metaData = groupByMetadata, aggregations = aggregations) + val aggregateFunc = new FlinkRowAggregationFunction(groupBy, schema) + + var acc = aggregateFunc.createAccumulator() + + // Create a map where the entries are not in the same order as `schema`. + val outOfOrderRow = Map[String, Any]( + "rating" -> 4.0f, + Constants.TimeColumn -> 1519862399999L, + "title" -> "A", + "views" -> 10 + ) + + // If the aggregator fails to fix the order, we'll get a ClassCastException + Try { + acc = aggregateFunc.add(outOfOrderRow, acc) + } match { + case Failure(e) => { + fail( + s"An exception was thrown by the aggregator when it should not have been. " + + s"The aggregator should fix the order without failing. $e") + } + case _ => + } + + val result = aggregateFunc.getResult(acc) + + // we sanity check the final result of the accumulator + // to do so, we must first expand / decompress the windowed tile IR into a full tile + // then we can finalize the tile and get the final result + val tileCodec = new TileCodec(groupBy, schema) + val expandedIr = tileCodec.expandWindowedTileIr(result.ir) + val finalResult = tileCodec.windowedRowAggregator.finalize(expandedIr) + assert(finalResult.length == 7) + + val expectedResult = Array( + outOfOrderRow("views"), + outOfOrderRow("views"), + outOfOrderRow("views"), + outOfOrderRow("rating"), + outOfOrderRow("rating"), + outOfOrderRow("title"), + outOfOrderRow("title") + ) + assert(finalResult sameElements expectedResult) + } + + def createRow(ts: Long, views: Int, rating: Float, title: String): Map[String, Any] = + Map( + Constants.TimeColumn -> ts, + "views" -> views, + "rating" -> rating, + "title" -> title + ) +} diff --git a/flink/src/test/scala/ai/chronon/flink/test/window/KeySelectorTest.scala b/flink/src/test/scala/ai/chronon/flink/test/window/KeySelectorTest.scala new file mode 100644 index 000000000..b81c39aab --- /dev/null +++ b/flink/src/test/scala/ai/chronon/flink/test/window/KeySelectorTest.scala @@ -0,0 +1,58 @@ +package ai.chronon.flink.test.window + +import ai.chronon.api.Builders +import ai.chronon.flink.window.KeySelector +import org.junit.Test + +class KeySelectorTest { + @Test + def TestChrononFlinkJobCorrectlyKeysByAGroupbysEntityKeys(): Unit = { + // We expect something like this to come out of the SparkExprEval operator + val sampleSparkExprEvalOutput: Map[String, Any] = + Map("number" -> 4242, "ip" -> "192.168.0.1", "user" -> "abc") + + val groupByWithOneEntityKey = Builders.GroupBy(keyColumns = Seq("number")) + val keyFunctionOne = KeySelector.getKeySelectionFunction(groupByWithOneEntityKey) + assert( + keyFunctionOne(sampleSparkExprEvalOutput) == List(4242) + ) + + val groupByWithTwoEntityKey = Builders.GroupBy(keyColumns = Seq("number", "user")) + val keyFunctionTwo = KeySelector.getKeySelectionFunction(groupByWithTwoEntityKey) + assert( + keyFunctionTwo(sampleSparkExprEvalOutput) == List(4242, "abc") + ) + } + + @Test + def testKeySelectorFunctionReturnsSameHashesForListsWithTheSameContent(): Unit = { + // This is more of a sanity check. It's not comprehensive. + // SINGLE ENTITY KEY + val map1: Map[String, Any] = + Map("number" -> 4242, "ip" -> "192.168.0.1", "user" -> "abc") + val map2: Map[String, Any] = + Map("number" -> 4242, "ip" -> "10.0.0.1", "user" -> "notabc") + val groupBySingleKey = Builders.GroupBy(keyColumns = Seq("number")) + val keyFunctionOne = KeySelector.getKeySelectionFunction(groupBySingleKey) + assert( + keyFunctionOne(map1).hashCode() == keyFunctionOne(map2).hashCode() + ) + + // TWO ENTITY KEYS + val map3: Map[String, Any] = + Map("number" -> 4242, "ip" -> "192.168.0.1", "user" -> "abc") + val map4: Map[String, Any] = + Map("ip" -> "192.168.0.1", "number" -> 4242, "user" -> "notabc") + val groupByTwoKeys = Builders.GroupBy(keyColumns = Seq("number", "ip")) + val keyFunctionTwo = KeySelector.getKeySelectionFunction(groupByTwoKeys) + assert( + keyFunctionTwo(map3).hashCode() == keyFunctionTwo(map4).hashCode() + ) + + val map5: Map[String, Any] = + Map("ip" -> "192.168.0.1", "number" -> null) + val map6: Map[String, Any] = + Map("ip" -> "192.168.0.1", "number" -> null) + assert(keyFunctionTwo(map5).hashCode() == keyFunctionTwo(map6).hashCode()) + } +} From aa3dc1ccdecc94dd35f46e4e05a126d2866dd222 Mon Sep 17 00:00:00 2001 From: "Caio Camatta (Stripe)" <108533014+caiocamatta-stripe@users.noreply.github.com> Date: Wed, 28 Feb 2024 12:10:43 -0500 Subject: [PATCH 12/17] Add documentation on the tiled architecture and the Flink job (#657) * WIP tiled architecture * Add diagrams * First draft done * Typos * Type * Update Tiled_Architecture.md based on comments * Split into two docs, Flink and Tiling * Add numbers for latency decrease * Minor changes addressing PR comments * Proofreading --- docs/images/Tiled_Architecture.png | Bin 0 -> 82877 bytes docs/images/Untiled_Architecture.png | Bin 0 -> 99379 bytes docs/source/Flink.md | 129 +++++++++++++++++++++++++++ docs/source/Tiled_Architecture.md | 61 +++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 docs/images/Tiled_Architecture.png create mode 100644 docs/images/Untiled_Architecture.png create mode 100644 docs/source/Flink.md create mode 100644 docs/source/Tiled_Architecture.md diff --git a/docs/images/Tiled_Architecture.png b/docs/images/Tiled_Architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..3bfcf654bef4d3406dc901fe0beabb1633dc500f GIT binary patch literal 82877 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

tV+vDv)9ID$2-za?lcf-dRt4sP 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 z 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 "Alice", + "created" -> 1000L, + "flavor" -> "chocolate" +) +``` + +#### 3. Window operator + +This window operator pre-aggregates incoming `Map(String -> Any)` and produces an array of IRs. Example: + +Event 1 `Map("Alice", 1000L, "chocolate")`. +- Pre-aggregates for key "Alice": `[count: 1, last_flavor: "chocolate"]` + +Event 2 for "Bob": `Map("Bob", 1200L, "strawberry")` +- Pre-aggregates for key "Bob": `[count: 1, last_flavor: "strawberry"]` + +Event 3 for "Alice": `Map("Alice", 1500L, "olive oil")` +- Pre-aggregates for key "Alice": `[count: 2, last_flavor: "olive oil"]` + +#### 4. Avro conversion + +This operator uses Avro to finish encoding the array of IRs into bytes and creates a `PutRequest`. + +Input: `[2, "olive oil"]` + +Output: `PutRequest(keyBytes: a927dcc=, valueBytes: d823eaa82==, ...)` (varies depending on your specific `KVStore` implementation). + +#### 5. KVStore Sink + +The final operator asynchronously writes the `PutRequests` to the KV store. These tiles are later decoded by the Fetcher and merged to calculate the final feature values. \ No newline at end of file diff --git a/docs/source/Tiled_Architecture.md b/docs/source/Tiled_Architecture.md new file mode 100644 index 000000000..1ac618a95 --- /dev/null +++ b/docs/source/Tiled_Architecture.md @@ -0,0 +1,61 @@ + +# The Tiled Architecture + +**Important**: Tiling is a new feature that is still in the process of being open-sourced. + +## What is tiling? + +Tiling, or the tiled architecture, is a modification to Chronon's online architecture to store pre-aggregates (also known as "IRs" or Intermediate Representations) in the Key-Value store instead of individual events. + +The primary purpose of tiling is to improve the handling of hot keys, increase scalability, and decrease feature serving latency. + +Tiling requires [Flink](https://flink.apache.org/). + +### Chronon without tiling +The regular, untiled version works as pictured in Figure 1. +- The "write" path: reads an event stream, processes the events in Spark, then writes them out to a datastore. +- The "read" path: reads O(events) events from the store, aggregates them, and returns the feature values to the user. + +![Architecture](../images/Untiled_Architecture.png) +_Figure 1: The untiled architecture_ + +At scale, aggregating O(n) events each time there is a request can become costly. For example, if you have an event stream producing 10 events/sec for a certain key, a request for a feature with a 12-hour window will have to fetch and aggregate 432,000 events every single time. For a simple GroupBy that counts the number of events for a key, Chronon would iterate over 432,000 items and count the total. + +### Chronon with tiling +The tiled architecture, depicted in Figure 2, works differently: +- The "write" path: reads an event stream, processes and pre-aggregates the events in a stateful Flink app, then writes out the pre-aggregates to "tiles" in the store. +- The "read" path: reads O(tiles) tiles from the store, merges the pre-aggregates, and returns the feature values to the user. + +![Architecture](../images/Tiled_Architecture.png) +_Figure 2: The tiled architecture_ + +Tiling shifts a significant part of the aggregation work to the write path, which allows for faster feature serving. + +Using the same example as above (an event stream producing 10 events/sec for a certain key, and a GroupBy with a 12-hour window), a request for feature values would fetch and merge 12 or 13 1-hour tiles. For a simple GroupBy that counts the number of events for a key, Chronon would iterate over 13 numbers and add them together. That's significantly less work. + +#### Example: Fetching and serving tiled features +Suppose you have a GroupBy with two aggregations, `COUNT` and `LAST`, both using 3-hour windows, and you are storing 1-hour tiles in KV Store. To serve them, the Chronon Fetcher would fetch three tiles: +``` +[0:00, 1:00) -> [2, "B"] +[1:00, 2:00) -> [9, "A"] +[2:00, 3:00) -> [3, "C"] +``` +Then, it would combine the IRs to get the final feature values: `[14, "C"]`. + +## When to use tiling + +In general, tiling improves scalability and decreases feature serving latency. Some use cases are: +- You want to decrease feature serving latency. At Stripe, migrating to tiling decreased serving latency by 33% at 4K rps. +- You don't have access to Spark Streaming +- You don't have access to a datastore with range queries +- You want to reduce fanout to your datastore. +- You need to support aggregating over hot key entities + +In particular, organizations operating at significant scale with many hot-key entities should consider using the tiled architecture. If the number of events per entity key is at most a few thousand, the untiled approach would still perform well. + + +## How to enable tiling + +To enable tiling, you first need to start using Flink on the write path. See the [Chronon on Flink documentation](./Flink.md) for instructions. As part of this process, you may also need to modify your KV store implementation to know how to write and fetch tiles. + +Once the Flink app is set up and writing tiles to your datastore, the final step is to enable tiled reads in the Fetcher. Just add `enable_tiling=true` to the [customJson](https://github.com/airbnb/chronon/blob/48b789dd2c216c62bbf1d74fbf4e779f23db541f/api/py/ai/chronon/group_by.py#L561) of any GroupBy definition. From 1f6839a55ce0121ae88329d486b7fa26ea25ba1a Mon Sep 17 00:00:00 2001 From: donghanz <95720920+donghanz@users.noreply.github.com> Date: Thu, 29 Feb 2024 08:11:06 -0800 Subject: [PATCH 13/17] feat: isolating Join Part computation (#684) * feat: allow only backfill selected join parts * unit test * add logging --------- Co-authored-by: Donghan Zhang --- .gitignore | 1 + .../main/scala/ai/chronon/spark/Driver.scala | 16 ++- .../main/scala/ai/chronon/spark/GroupBy.scala | 5 +- .../main/scala/ai/chronon/spark/Join.scala | 45 +++++-- .../scala/ai/chronon/spark/JoinBase.scala | 34 +++-- .../ai/chronon/spark/test/JoinTest.scala | 121 +++++++++++++++++- 6 files changed, 192 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index b639a6015..9f5f29e46 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ api/py/test/sample/production/joins/quickstart/ api/py/.coverage api/py/htmlcov/ **/derby.log +cs # Documentation builds docs/build/ diff --git a/spark/src/main/scala/ai/chronon/spark/Driver.scala b/spark/src/main/scala/ai/chronon/spark/Driver.scala index 7df794961..934e32532 100644 --- a/spark/src/main/scala/ai/chronon/spark/Driver.scala +++ b/spark/src/main/scala/ai/chronon/spark/Driver.scala @@ -16,7 +16,6 @@ package ai.chronon.spark -import org.slf4j.LoggerFactory import ai.chronon.api import ai.chronon.api.Extensions.{GroupByOps, MetadataOps, SourceOps} import ai.chronon.api.ThriftJsonCodec @@ -36,6 +35,7 @@ import org.apache.spark.sql.streaming.StreamingQueryListener.{ import org.apache.spark.sql.{DataFrame, SparkSession, SparkSessionExtensions} import org.apache.thrift.TBase import org.rogach.scallop.{ScallopConf, ScallopOption, Subcommand} +import org.slf4j.LoggerFactory import java.io.File import java.nio.file.{Files, Paths} @@ -232,6 +232,8 @@ object Driver { opt[String](required = false, descr = "Start date to compute join backfill, this start date will override start partition in conf.") + val selectedJoinParts: ScallopOption[List[String]] = + opt[List[String]](required = false, descr = "A list of join parts that require backfilling.") lazy val joinConf: api.Join = parseConf[api.Join](confPath()) override def subcommandName() = s"join_${joinConf.metaData.name}" } @@ -242,8 +244,18 @@ object Driver { args.joinConf, args.endDate(), args.buildTableUtils(), - !args.runFirstHole() + !args.runFirstHole(), + selectedJoinParts = args.selectedJoinParts.toOption ) + + if (args.selectedJoinParts.isDefined) { + join.computeJoinOpt(args.stepDays.toOption, args.startPartitionOverride.toOption) + logger.info( + s"Backfilling selected join parts: ${args.selectedJoinParts()} is complete. Skipping the final join. Exiting." + ) + return + } + val df = join.computeJoin(args.stepDays.toOption, args.startPartitionOverride.toOption) if (args.shouldExport()) { diff --git a/spark/src/main/scala/ai/chronon/spark/GroupBy.scala b/spark/src/main/scala/ai/chronon/spark/GroupBy.scala index a8224f1cf..fc7e724df 100644 --- a/spark/src/main/scala/ai/chronon/spark/GroupBy.scala +++ b/spark/src/main/scala/ai/chronon/spark/GroupBy.scala @@ -16,22 +16,21 @@ package ai.chronon.spark -import org.slf4j.LoggerFactory import ai.chronon.aggregator.base.TimeTuple import ai.chronon.aggregator.row.RowAggregator import ai.chronon.aggregator.windowing._ import ai.chronon.api -import ai.chronon.api.{Accuracy, Constants, DataModel, ParametricMacro} import ai.chronon.api.DataModel.{Entities, Events} import ai.chronon.api.Extensions._ +import ai.chronon.api.{Accuracy, Constants, DataModel, ParametricMacro} import ai.chronon.online.{RowWrapper, SparkConversions} import ai.chronon.spark.Extensions._ import org.apache.spark.rdd.RDD import org.apache.spark.sql.functions._ -import org.apache.spark.sql import org.apache.spark.sql.types._ import org.apache.spark.sql.{DataFrame, Row, SparkSession} import org.apache.spark.util.sketch.BloomFilter +import org.slf4j.LoggerFactory import java.util import scala.collection.{Seq, mutable} diff --git a/spark/src/main/scala/ai/chronon/spark/Join.scala b/spark/src/main/scala/ai/chronon/spark/Join.scala index c06d54390..054730905 100644 --- a/spark/src/main/scala/ai/chronon/spark/Join.scala +++ b/spark/src/main/scala/ai/chronon/spark/Join.scala @@ -16,7 +16,6 @@ package ai.chronon.spark -import org.slf4j.LoggerFactory import ai.chronon.api import ai.chronon.api.Extensions._ import ai.chronon.api._ @@ -27,13 +26,11 @@ import org.apache.spark.sql import org.apache.spark.sql.DataFrame import org.apache.spark.sql.functions._ -import java.util.concurrent.{Callable, ExecutorCompletionService, ExecutorService, Executors} -import scala.collection.Seq -import scala.collection.mutable -import scala.collection.parallel.ExecutionContextTaskSupport -import scala.concurrent.duration.{Duration, DurationInt} +import java.util.concurrent.Executors +import scala.collection.{Seq, mutable} +import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, ExecutionContextExecutorService, Future} -import scala.util.ScalaJavaConversions.{IterableOps, ListOps, MapOps} +import scala.util.ScalaJavaConversions.{ListOps, MapOps} import scala.util.{Failure, Success} /* @@ -65,8 +62,9 @@ class Join(joinConf: api.Join, tableUtils: TableUtils, skipFirstHole: Boolean = true, mutationScan: Boolean = true, - showDf: Boolean = false) - extends JoinBase(joinConf, endPartition, tableUtils, skipFirstHole, mutationScan, showDf) { + showDf: Boolean = false, + selectedJoinParts: Option[List[String]] = None) + extends JoinBase(joinConf, endPartition, tableUtils, skipFirstHole, mutationScan, showDf, selectedJoinParts) { private val bootstrapTable = joinConf.metaData.bootstrapTable @@ -154,8 +152,22 @@ class Join(joinConf: api.Join, }.toSeq } - val coveringSetsPerJoinPart: Seq[(JoinPartMetadata, Seq[CoveringSet])] = bootstrapInfo.joinParts.map { - joinPartMetadata => + val partsToCompute: Seq[JoinPartMetadata] = { + if (selectedJoinParts.isEmpty) { + bootstrapInfo.joinParts + } else { + bootstrapInfo.joinParts.filter(part => selectedJoinParts.get.contains(part.joinPart.fullPrefix)) + } + } + + if (selectedJoinParts.isDefined && partsToCompute.isEmpty) { + throw new IllegalArgumentException( + s"Selected join parts are not found. Available ones are: ${bootstrapInfo.joinParts.map(_.joinPart.fullPrefix).prettyInline}") + } + + val coveringSetsPerJoinPart: Seq[(JoinPartMetadata, Seq[CoveringSet])] = bootstrapInfo.joinParts + .filter(part => selectedJoinParts.isEmpty || partsToCompute.contains(part)) + .map { joinPartMetadata => val coveringSets = distinctBootstrapSets.map { case (hashes, rowCount) => val schema = hashes.toSet.flatMap(bootstrapInfo.hashToSchema.apply) @@ -169,7 +181,7 @@ class Join(joinConf: api.Join, CoveringSet(hashes, rowCount, isCovering) } (joinPartMetadata, coveringSets) - } + } logger.info( s"\n======= CoveringSet for JoinPart ${joinConf.metaData.name} for PartitionRange(${leftRange.start}, ${leftRange.end}) =======\n") @@ -185,7 +197,9 @@ class Join(joinConf: api.Join, coveringSetsPerJoinPart } - override def computeRange(leftDf: DataFrame, leftRange: PartitionRange, bootstrapInfo: BootstrapInfo): DataFrame = { + override def computeRange(leftDf: DataFrame, + leftRange: PartitionRange, + bootstrapInfo: BootstrapInfo): Option[DataFrame] = { val leftTaggedDf = if (leftDf.schema.names.contains(Constants.TimeColumn)) { leftDf.withTimeBasedColumn(Constants.TimePartitionColumn) } else { @@ -259,6 +273,9 @@ class Join(joinConf: api.Join, } val rightResults = Await.result(Future.sequence(rightResultsFuture), Duration.Inf).flatten + // early exit if selectedJoinParts is defined. Otherwise, we combine all join parts + if (selectedJoinParts.isDefined) return None + // combine bootstrap table and join part tables // sequentially join bootstrap table and each join part table. some column may exist both on left and right because // a bootstrap source can cover a partial date range. we combine the columns using coalesce-rule @@ -287,7 +304,7 @@ class Join(joinConf: api.Join, bootstrapInfo, leftDf.columns) finalDf.explain() - finalDf + Some(finalDf) } def applyDerivation(baseDf: DataFrame, bootstrapInfo: BootstrapInfo, leftColumns: Seq[String]): DataFrame = { diff --git a/spark/src/main/scala/ai/chronon/spark/JoinBase.scala b/spark/src/main/scala/ai/chronon/spark/JoinBase.scala index 12664ab03..d95fd0edd 100644 --- a/spark/src/main/scala/ai/chronon/spark/JoinBase.scala +++ b/spark/src/main/scala/ai/chronon/spark/JoinBase.scala @@ -16,7 +16,6 @@ package ai.chronon.spark -import org.slf4j.LoggerFactory import ai.chronon.api import ai.chronon.api.DataModel.{Entities, Events} import ai.chronon.api.Extensions._ @@ -28,6 +27,7 @@ import com.google.gson.Gson import org.apache.spark.sql.DataFrame import org.apache.spark.sql.functions._ import org.apache.spark.util.sketch.BloomFilter +import org.slf4j.LoggerFactory import java.time.Instant import scala.collection.JavaConverters._ @@ -38,7 +38,8 @@ abstract class JoinBase(joinConf: api.Join, tableUtils: TableUtils, skipFirstHole: Boolean, mutationScan: Boolean = true, - showDf: Boolean = false) { + showDf: Boolean = false, + selectedJoinParts: Option[Seq[String]] = None) { @transient lazy val logger = LoggerFactory.getLogger(getClass) assert(Option(joinConf.metaData.outputNamespace).nonEmpty, s"output namespace could not be empty or null") val metrics: Metrics.Context = Metrics.Context(Metrics.Environment.JoinOffline, joinConf) @@ -286,9 +287,13 @@ abstract class JoinBase(joinConf: api.Join, Some(rightDfWithDerivations) } - def computeRange(leftDf: DataFrame, leftRange: PartitionRange, bootstrapInfo: BootstrapInfo): DataFrame + def computeRange(leftDf: DataFrame, leftRange: PartitionRange, bootstrapInfo: BootstrapInfo): Option[DataFrame] def computeJoin(stepDays: Option[Int] = None, overrideStartPartition: Option[String] = None): DataFrame = { + computeJoinOpt(stepDays, overrideStartPartition).get + } + + def computeJoinOpt(stepDays: Option[Int] = None, overrideStartPartition: Option[String] = None): Option[DataFrame] = { assert(Option(joinConf.metaData.team).nonEmpty, s"join.metaData.team needs to be set for join ${joinConf.metaData.name}") @@ -337,7 +342,7 @@ abstract class JoinBase(joinConf: api.Join, def finalResult: DataFrame = tableUtils.sql(rangeToFill.genScanQuery(null, outputTable)) if (unfilledRanges.isEmpty) { logger.info(s"\nThere is no data to compute based on end partition of ${rangeToFill.end}.\n\n Exiting..") - return finalResult + return Some(finalResult) } stepDays.foreach(metrics.gauge("step_days", _)) @@ -358,14 +363,23 @@ abstract class JoinBase(joinConf: api.Join, leftDf(joinConf, range, tableUtils).map { leftDfInRange => if (showDf) leftDfInRange.prettyPrint() // set autoExpand = true to ensure backward compatibility due to column ordering changes - computeRange(leftDfInRange, range, bootstrapInfo).save(outputTable, tableProps, autoExpand = true) - val elapsedMins = (System.currentTimeMillis() - startMillis) / (60 * 1000) - metrics.gauge(Metrics.Name.LatencyMinutes, elapsedMins) - metrics.gauge(Metrics.Name.PartitionCount, range.partitions.length) - logger.info(s"Wrote to table $outputTable, into partitions: ${range.toString} $progress in $elapsedMins mins") + val finalDf = computeRange(leftDfInRange, range, bootstrapInfo) + if (selectedJoinParts.isDefined) { + assert(finalDf.isEmpty, + "The arg `selectedJoinParts` is defined, so no final join is required. `finalDf` should be empty") + logger.info(s"Skipping writing to the output table for range: ${range.toString} $progress") + return None + } else { + finalDf.get.save(outputTable, tableProps, autoExpand = true) + val elapsedMins = (System.currentTimeMillis() - startMillis) / (60 * 1000) + metrics.gauge(Metrics.Name.LatencyMinutes, elapsedMins) + metrics.gauge(Metrics.Name.PartitionCount, range.partitions.length) + logger.info( + s"Wrote to table $outputTable, into partitions: ${range.toString} $progress in $elapsedMins mins") + } } } logger.info(s"Wrote to table $outputTable, into partitions: $unfilledRanges") - finalResult + Some(finalResult) } } diff --git a/spark/src/test/scala/ai/chronon/spark/test/JoinTest.scala b/spark/src/test/scala/ai/chronon/spark/test/JoinTest.scala index 5ed03f9d2..95caa0c66 100644 --- a/spark/src/test/scala/ai/chronon/spark/test/JoinTest.scala +++ b/spark/src/test/scala/ai/chronon/spark/test/JoinTest.scala @@ -27,9 +27,10 @@ import ai.chronon.spark.stats.SummaryJob import org.apache.spark.rdd.RDD import org.apache.spark.sql.functions._ import org.apache.spark.sql.types.{StructType, StringType => SparkStringType} -import org.apache.spark.sql.{DataFrame, Row, SparkSession} +import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SparkSession} import org.junit.Assert._ import org.junit.Test +import org.scalatest.Assertions.intercept import scala.collection.JavaConverters._ import scala.util.ScalaJavaConversions.ListOps @@ -1170,4 +1171,122 @@ class JoinTest { val computed = runner.computeJoin(Some(7)) assertFalse(computed.isEmpty) } + + /** + * Create a event table as left side, 3 group bys as right side. + * Generate data using DataFrameGen and save to the tables. + * Create a join with only one join part selected. + * Run computeJoin(). + * Check if the selected join part is computed and the other join parts are not computed. + */ + @Test + def testSelectedJoinParts(): Unit = { + // Left + val itemQueries = List( + Column("item", api.StringType, 100), + Column("value", api.LongType, 100) + ) + val itemQueriesTable = s"$namespace.item_queries_selected_join_parts" + spark.sql(s"DROP TABLE IF EXISTS $itemQueriesTable") + spark.sql(s"DROP TABLE IF EXISTS ${itemQueriesTable}_tmp") + DataFrameGen.events(spark, itemQueries, 10000, partitions = 30).save(s"${itemQueriesTable}_tmp") + val leftDf = tableUtils.sql(s"SELECT item, value, ts, ds FROM ${itemQueriesTable}_tmp") + leftDf.save(itemQueriesTable) + val start = monthAgo + + // Right + val viewsSchema = List( + Column("user", api.StringType, 10000), + Column("item", api.StringType, 100), + Column("value", api.LongType, 100) + ) + val viewsTable = s"$namespace.view_selected_join_parts" + spark.sql(s"DROP TABLE IF EXISTS $viewsTable") + DataFrameGen.events(spark, viewsSchema, count = 10000, partitions = 30).save(viewsTable) + + // Group By + val gb1 = Builders.GroupBy( + sources = Seq( + Builders.Source.events( + table = viewsTable, + query = Builders.Query(startPartition = start) + )), + keyColumns = Seq("item"), + aggregations = Seq( + Builders.Aggregation(operation = Operation.LAST_K, argMap = Map("k" -> "10"), inputColumn = "user"), + Builders.Aggregation(operation = Operation.MAX, argMap = Map("k" -> "2"), inputColumn = "value") + ), + metaData = + Builders.MetaData(name = s"unit_test.item_views_selected_join_parts_1", namespace = namespace, team = "item_team"), + accuracy = Accuracy.SNAPSHOT + ) + + val gb2 = Builders.GroupBy( + sources = Seq( + Builders.Source.events( + table = viewsTable, + query = Builders.Query(startPartition = start) + )), + keyColumns = Seq("item"), + aggregations = Seq( + Builders.Aggregation(operation = Operation.MIN, argMap = Map("k" -> "1"), inputColumn = "value") + ), + metaData = + Builders.MetaData(name = s"unit_test.item_views_selected_join_parts_2", namespace = namespace, team = "item_team"), + accuracy = Accuracy.SNAPSHOT + ) + + val gb3 = Builders.GroupBy( + sources = Seq( + Builders.Source.events( + table = viewsTable, + query = Builders.Query(startPartition = start) + )), + keyColumns = Seq("item"), + aggregations = Seq( + Builders.Aggregation(operation = Operation.AVERAGE, inputColumn = "value") + ), + metaData = + Builders.MetaData(name = s"unit_test.item_views_selected_join_parts_3", namespace = namespace, team = "item_team"), + accuracy = Accuracy.SNAPSHOT + ) + + // Join + val joinConf = Builders.Join( + left = Builders.Source.events(Builders.Query(startPartition = start), table = itemQueriesTable), + joinParts = Seq( + Builders.JoinPart(groupBy = gb1, prefix = "user1"), + Builders.JoinPart(groupBy = gb2, prefix = "user2"), + Builders.JoinPart(groupBy = gb3, prefix = "user3") + ), + metaData = Builders.MetaData(name = s"unit_test.item_temporal_features.selected_join_parts", + namespace = namespace, + team = "item_team", + online = true) + ) + + // Drop Join Part tables if any + val partTable1 = s"${joinConf.metaData.outputTable}_user1_unit_test_item_views_selected_join_parts_1" + val partTable2 = s"${joinConf.metaData.outputTable}_user2_unit_test_item_views_selected_join_parts_2" + val partTable3 = s"${joinConf.metaData.outputTable}_user3_unit_test_item_views_selected_join_parts_3" + spark.sql(s"DROP TABLE IF EXISTS $partTable1") + spark.sql(s"DROP TABLE IF EXISTS $partTable2") + spark.sql(s"DROP TABLE IF EXISTS $partTable3") + + // Compute daily join. + val joinJob = new Join(joinConf, today, tableUtils, selectedJoinParts = Some(List("user1_unit_test_item_views_selected_join_parts_1"))) + + joinJob.computeJoinOpt() + + val part1 = tableUtils.sql(s"SELECT * FROM $partTable1") + assertTrue(part1.count() > 0) + + val thrown2 = intercept[AnalysisException] { + spark.sql(s"SELECT * FROM $partTable2") + } + val thrown3 = intercept[AnalysisException] { + spark.sql(s"SELECT * FROM $partTable3") + } + assert(thrown2.getMessage.contains("Table or view not found") && thrown3.getMessage.contains("Table or view not found")) + } } From e5be6e64271bc767e905543e0eb94d57cd10604f Mon Sep 17 00:00:00 2001 From: Adam Kocoloski Date: Thu, 29 Feb 2024 13:53:55 -0500 Subject: [PATCH 14/17] Rename CONTRIBUTE to CONTRIBUTING (#703) Trivial change so GitHub picks it up and shows the guidelines e.g. on github.com/airbnb/chronon/contribute https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors --- CONTRIBUTE.md => CONTRIBUTING.md | 0 README.md | 2 +- docs/source/setup/Orchestration.md | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename CONTRIBUTE.md => CONTRIBUTING.md (100%) diff --git a/CONTRIBUTE.md b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTE.md rename to CONTRIBUTING.md diff --git a/README.md b/README.md index 2d4b693c1..ef447fcef 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ With Chronon you can use any data available in your organization, including ever # Contributing -We welcome contributions to the Chronon project! Please read [CONTRIBUTE](CONTRIBUTE.md) for details. +We welcome contributions to the Chronon project! Please read [CONTRIBUTING](CONTRIBUTING.md) for details. # Support diff --git a/docs/source/setup/Orchestration.md b/docs/source/setup/Orchestration.md index ca8829f1e..1446e073e 100644 --- a/docs/source/setup/Orchestration.md +++ b/docs/source/setup/Orchestration.md @@ -29,6 +29,6 @@ To deploy this to your airflow environment, first copy everything in this direct ## Alternate Integrations -While Airflow is currently the most well-supported integration, there is no reason why you couldn't choose a different orchestration engine to power the above flows. If you're interested in such an integration and you think that the community might benefit from your work, please consider [contributing](https://github.com/airbnb/chronon/blob/main/CONTRIBUTE.md) back to the project. +While Airflow is currently the most well-supported integration, there is no reason why you couldn't choose a different orchestration engine to power the above flows. If you're interested in such an integration and you think that the community might benefit from your work, please consider [contributing](https://github.com/airbnb/chronon/blob/main/CONTRIBUTING.md) back to the project. If you have questions about how to approach a different integration, feel free to ask for help in the [community Discord channel](https://discord.gg/GbmGATNqqP). From 7bdc0cfeab3873a4d6a929697e6bd7aab0109659 Mon Sep 17 00:00:00 2001 From: Cristian Figueroa Date: Fri, 1 Mar 2024 10:04:56 -0800 Subject: [PATCH 15/17] [TableUtils] Prevent hard failures on duplicate setups (#681) * [TableUtils] Prevent hard failures on duplicate setups * Return a dataframe? * Add brickhouse resource * Resource: jar renaming * Use a local UDF * Spark 2.4/Spark 3 friendly solution * Extraneus input --- .../main/scala/ai/chronon/spark/TableUtils.scala | 16 +++++++++++++--- .../ai/chronon/spark/test/TableUtilsTest.scala | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/spark/src/main/scala/ai/chronon/spark/TableUtils.scala b/spark/src/main/scala/ai/chronon/spark/TableUtils.scala index 15007dee3..0703c437b 100644 --- a/spark/src/main/scala/ai/chronon/spark/TableUtils.scala +++ b/spark/src/main/scala/ai/chronon/spark/TableUtils.scala @@ -27,7 +27,7 @@ import org.apache.spark.rdd.RDD import org.apache.spark.sql.catalyst.plans.logical.{Filter, Project} import org.apache.spark.sql.functions._ import org.apache.spark.sql.types._ -import org.apache.spark.sql.{DataFrame, Row, SaveMode, SparkSession} +import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SaveMode, SparkSession} import org.apache.spark.storage.StorageLevel import java.time.format.DateTimeFormatter @@ -324,8 +324,18 @@ case class TableUtils(sparkSession: SparkSession) { val partitionCount = sparkSession.sparkContext.getConf.getInt("spark.default.parallelism", 1000) logger.info( s"\n----[Running query coalesced into at most $partitionCount partitions]----\n$query\n----[End of Query]----\n") - val df = sparkSession.sql(query).coalesce(partitionCount) - df + try { + // Run the query + val df = sparkSession.sql(query).coalesce(partitionCount) + df + } catch { + case e: AnalysisException if e.getMessage.contains(" already exists") => + logger.warn(s"Non-Fatal: ${e.getMessage}. Query may result in redefinition.") + sparkSession.sql("SHOW USER FUNCTIONS") + case e: Exception => + logger.error("Error running query:", e) + throw e + } } def insertUnPartitioned(df: DataFrame, diff --git a/spark/src/test/scala/ai/chronon/spark/test/TableUtilsTest.scala b/spark/src/test/scala/ai/chronon/spark/test/TableUtilsTest.scala index f15152533..5823cb397 100644 --- a/spark/src/test/scala/ai/chronon/spark/test/TableUtilsTest.scala +++ b/spark/src/test/scala/ai/chronon/spark/test/TableUtilsTest.scala @@ -22,6 +22,7 @@ import ai.chronon.spark.test.TestUtils.makeDf import ai.chronon.api.{StructField, _} import ai.chronon.online.SparkConversions import ai.chronon.spark.{IncompatibleSchemaException, PartitionRange, SparkSessionBuilder, TableUtils} +import org.apache.hadoop.hive.ql.exec.UDF import org.apache.spark.sql.functions.col import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SparkSession, types} import org.junit.Assert.{assertEquals, assertFalse, assertTrue} @@ -29,6 +30,14 @@ import org.junit.Test import scala.util.Try + + +class SimpleAddUDF extends UDF { + def evaluate(value: Int): Int = { + value + 20 + } +} + class TableUtilsTest { lazy val spark: SparkSession = SparkSessionBuilder.build("TableUtilsTest", local = true) private val tableUtils = TableUtils(spark) @@ -409,6 +418,12 @@ class TableUtilsTest { assertTrue(tableUtils.checkTablePermission(tableName)) } + @Test + def testDoubleUDFRegistration(): Unit = { + tableUtils.sql("CREATE TEMPORARY FUNCTION test AS 'ai.chronon.spark.test.SimpleAddUDF'") + tableUtils.sql("CREATE TEMPORARY FUNCTION test AS 'ai.chronon.spark.test.SimpleAddUDF'") + } + @Test def testIfPartitionExistsInTable(): Unit = { val tableName = "db.test_if_partition_exists" From 5ae48b1eb401ed7ab8fab1787dff6f979344c072 Mon Sep 17 00:00:00 2001 From: Cristian Figueroa Date: Fri, 1 Mar 2024 10:05:12 -0800 Subject: [PATCH 16/17] [Driver] Allow AtMillis on fetch CLI (#697) --- spark/src/main/scala/ai/chronon/spark/Driver.scala | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spark/src/main/scala/ai/chronon/spark/Driver.scala b/spark/src/main/scala/ai/chronon/spark/Driver.scala index 934e32532..1edc090a0 100644 --- a/spark/src/main/scala/ai/chronon/spark/Driver.scala +++ b/spark/src/main/scala/ai/chronon/spark/Driver.scala @@ -570,6 +570,11 @@ object Driver { descr = "file path to json of the keys to fetch", short = 'f' ) + val atMillis: ScallopOption[Long] = opt[Long]( + required = false, + descr = "timestamp to fetch the data at", + default = None + ) val interval: ScallopOption[Int] = opt[Int]( required = false, descr = "interval between requests in seconds", @@ -638,7 +643,7 @@ object Driver { fetchStats(args, objectMapper, keyMap, fetcher) } else { val startNs = System.nanoTime - val requests = Seq(Fetcher.Request(args.name(), keyMap)) + val requests = Seq(Fetcher.Request(args.name(), keyMap, args.atMillis.toOption)) val resultFuture = if (args.`type`() == "join") { fetcher.fetchJoin(requests) } else { From 1e091c1bfc77e0bd81c2722b36cec4419b90c9c0 Mon Sep 17 00:00:00 2001 From: Cristian Figueroa Date: Fri, 1 Mar 2024 10:05:35 -0800 Subject: [PATCH 17/17] [StagingQuery] allow java serializer for staging queries (#694) * [StagingQuery] allow java serializer for staging queries * Add argument to javaFetcher --- .../ai/chronon/spark/SparkSessionBuilder.scala | 15 ++++++++++----- .../scala/ai/chronon/spark/StagingQuery.scala | 3 ++- .../ai/chronon/spark/test/JavaFetcherTest.java | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/spark/src/main/scala/ai/chronon/spark/SparkSessionBuilder.scala b/spark/src/main/scala/ai/chronon/spark/SparkSessionBuilder.scala index 64ce6e654..be15616c5 100644 --- a/spark/src/main/scala/ai/chronon/spark/SparkSessionBuilder.scala +++ b/spark/src/main/scala/ai/chronon/spark/SparkSessionBuilder.scala @@ -35,7 +35,8 @@ object SparkSessionBuilder { def build(name: String, local: Boolean = false, localWarehouseLocation: Option[String] = None, - additionalConfig: Option[Map[String, String]] = None): SparkSession = { + additionalConfig: Option[Map[String, String]] = None, + enforceKryoSerializer: Boolean = true): SparkSession = { if (local) { //required to run spark locally with hive support enabled - for sbt test System.setSecurityManager(null) @@ -49,16 +50,20 @@ object SparkSessionBuilder { .config("spark.sql.session.timeZone", "UTC") //otherwise overwrite will delete ALL partitions, not just the ones it touches .config("spark.sql.sources.partitionOverwriteMode", "dynamic") - .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") - .config("spark.kryo.registrator", "ai.chronon.spark.ChrononKryoRegistrator") - .config("spark.kryoserializer.buffer.max", "2000m") - .config("spark.kryo.referenceTracking", "false") .config("hive.exec.dynamic.partition", "true") .config("hive.exec.dynamic.partition.mode", "nonstrict") .config("spark.sql.catalogImplementation", "hive") .config("spark.hadoop.hive.exec.max.dynamic.partitions", 30000) .config("spark.sql.legacy.timeParserPolicy", "LEGACY") + // Staging queries don't benefit from the KryoSerializer and in fact may fail with buffer underflow in some cases. + if (enforceKryoSerializer) { + baseBuilder + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config("spark.kryo.registrator", "ai.chronon.spark.ChrononKryoRegistrator") + .config("spark.kryoserializer.buffer.max", "2000m") + .config("spark.kryo.referenceTracking", "false") + } additionalConfig.foreach { configMap => configMap.foreach { config => baseBuilder = baseBuilder.config(config._1, config._2) } } diff --git a/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala b/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala index c25c7b0d7..9570ed945 100644 --- a/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala +++ b/spark/src/main/scala/ai/chronon/spark/StagingQuery.scala @@ -128,7 +128,8 @@ object StagingQuery { val stagingQueryJob = new StagingQuery( stagingQueryConf, parsedArgs.endDate(), - TableUtils(SparkSessionBuilder.build(s"staging_query_${stagingQueryConf.metaData.name}")) + TableUtils( + SparkSessionBuilder.build(s"staging_query_${stagingQueryConf.metaData.name}", enforceKryoSerializer = false)) ) stagingQueryJob.computeStagingQuery(parsedArgs.stepDays.toOption) } diff --git a/spark/src/test/scala/ai/chronon/spark/test/JavaFetcherTest.java b/spark/src/test/scala/ai/chronon/spark/test/JavaFetcherTest.java index 611c7c927..2bbefaede 100644 --- a/spark/src/test/scala/ai/chronon/spark/test/JavaFetcherTest.java +++ b/spark/src/test/scala/ai/chronon/spark/test/JavaFetcherTest.java @@ -39,7 +39,7 @@ public class JavaFetcherTest { String namespace = "java_fetcher_test"; - SparkSession session = SparkSessionBuilder.build(namespace, true, scala.Option.apply(null), scala.Option.apply(null)); + SparkSession session = SparkSessionBuilder.build(namespace, true, scala.Option.apply(null), scala.Option.apply(null), true); TableUtils tu = new TableUtils(session); InMemoryKvStore kvStore = new InMemoryKvStore(func(() -> tu)); MockApi mockApi = new MockApi(func(() -> kvStore), "java_fetcher_test");