Some thoughts on building Realtime Compute at Tecton

Share

I learned recently that Tecton was getting acquired by Databricks, and with that I also I started to think about the cool work that my team and I worked on that we never got to blog about. Seeing as how it's unlikely that we'll see a Tecton blog about those projects, I figure it's a good time to do it myself.

So, before I left, I was working on building realtime infrastructure at Tecton; specifically Server Groups and Stream Ingest API as part of the Realtime Compute team. I think both are super interesting problems and I thought our approach to them was pretty pragmatic and performant, and I specially wanted to shout out my whole team (Pooja, Sanika, Christopher, and many more) that help build the infrastructure I'm about to describe. But before that, I want to discuss what a feature store is, and why one would care about streaming at all.

What's a feature store

And where does streaming fit in?

There's plenty that has already been written about feature stores, and I don't want to rehash that. Fundamentally, a feature store makes feature data available to systems to support model training and model inference. Model training requires timestamped, historical data and support for training data generation which is point-in-time correct. Typically this data lives in some kind of offline store which supports large data volumes and higher latency/complicated queries. Model inference on the other hand, requires extremely low latency point-value lookups. Typically, this data is served via some API (HTTP/Json or gRPC), and the data is stored in some kind of online key-value store.

Where does streaming fit into all this? Two ways:

Stream Ingest API

AKA PushAPI

Assuming I've convinced you of the importance of streams, lets talk about how this was commonly implemented.

A common pattern to use streams in feature stores is to have streaming applications consume real-time events from an organizations pre-existing messaging systems (like Kafka or Kinesis). These stream applications could be Spark Structured Streaming applications, or as I've alluded to in previous posts, Flink Applications.

Tecton was built on Spark for historical reasons, so all the streaming applications we'd stand up were Spark Structured Streaming apps that could consume from organizations' Kafka topics/Kinesis streams.

Another important point to note - Tecton was a single-tenant, cloud-native platform. This meant that we would run a control plane per customer tenant in one AWS sub-account (i.e. the Control Plane), and have customer-owned data live in a separate sub-account (i.e. the Data Plane), typically owned by customers. They'd have to grant cross account permissions to specific Tecton IAM users that would have access to write data to the Data Plane.

So, in this setting, what problems did we see with streams in practice?

Enter Stream Ingest API. The idea here was pretty simple - move from a pull-based architecture, to a push-based architecture. This was something that I had worked on when we were building Feast as well, and the architectural advantages were substantial.

How did it work?

The Stream Ingest API was a grpc service, implemented in Kotlin, fronted by grpc-gateway to expose an HTTP/JSON API. Records could be batched in a single request, capped at 10records per request. Writes were made synchronously. In case of request failures, the whole request needed to be retried.

The service was designed to be stateless, which solved the problem of at-least-once/at-most-once delivery. Record delivery could be retried until producers got a success response.

Requests were validated strongly (required fields and types) against a schema that was pre-registered, and non-conformant records would return erroneous responses.

On launch, records once written could be edited, but this was highly discouraged 😅. The solution for this was to implement timestamping for each record when ingesting. This leads to a bi-temporal data model - user records have an "event" timestamp, provided by users, and an "ingestion" timestamp, applied by the system, providing immutability to records.

This obviously leads to the possibility of conflict between multiple concurrent write requests for the same event. Our system's behaviour was therefore "last-write-wins". In practice, this wasn't an issue since availability of the API was 99.99%.

Performance

A lot of the ingestion performance is highly dependent on the backing online store used. We used DynamoDB primarily.

Batch Size : 1

100 QPS500 QPS1000 QPS
p50575555
p90716967
p95868176
p99165127108

Batch Size : 10

20 QPS100 QPS200 QPS
p509811282
p9011513597
p95124151143
p99156213190

Integration with Streams

We made a sample Kafka consumer that could write to the Stream Ingest API, and all it needed for an endpoint and an API key for Tecton. That made adding Tecton to existing Data pipelines easy. Since integration involved writing

Phew. So that's basically what the Stream Ingest API did. I was pretty proud of it, we onboarding a few users to it before I left and it was scaling quite well.

One thing that is missing here however, is an understanding of how we would run UDFs on behalf of users when ingesting data via the Stream Ingest API, as well as tenancy for Ingestion Servers as well. That's a topic I'll cover in the next post, when I talk about Server Groups.