<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Devsisters Tech Blog]]></title><description><![CDATA[Real-world engineering stories from the developers behind Devsisters games and services, sharing the technology and know-how we use in production.]]></description><link>https://tech.devsisters.com</link><generator>GatsbyJS</generator><lastBuildDate>Tue, 20 Oct 2026 11:12:08 GMT</lastBuildDate><language><![CDATA[en]]></language><item><title><![CDATA[We Fixed the Definitions, Not the Queries: Adopting Databricks Metric Views]]></title><description><![CDATA[We introduced Metric Views into the data pipeline of a newly launched game. This post shares how we organized our data into layers so that dashboards and analytics agents read the same metric definitions, along with the design rules we settled on and the results.]]></description><link>https://tech.devsisters.com/en/posts/metric-view</link><guid isPermaLink="false">https://tech.devsisters.com/en/posts/metric-view</guid><pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Hello, I&apos;m Hyunki Park from the Data Intelligence Cell in the Tech Division. In this post, I&apos;ll share how we introduced Databricks Unity Catalog&apos;s Metric Views into the data pipeline of a newly launched game: how we organized our data into layers so that dashboards and analytics agents read the same metric definitions, the design rules we settled on along the way, and the results.&lt;/p&gt;
&lt;h2 id=&quot;background-metrics-that-were-calculated-differently-in-every-query&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#background-metrics-that-were-calculated-differently-in-every-query&quot; aria-label=&quot;background metrics that were calculated differently in every query permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Background: Metrics That Were Calculated Differently in Every Query&lt;/h2&gt;
&lt;p&gt;The period right after a game launches is when data requests are at their most numerous and most urgent. There were many sources — snapshots of the game server DB, analytics logs emitted by the client and server, metadata tables — and the conditions were complicated too: which users to exclude from metrics, how to handle games that included bots, which point-in-time values to use. Meanwhile, as many dashboards were built in a short period and analysis requests poured in, each dashboard, and each ad hoc analysis query, was defining these conditions on its own. Each dashboard&apos;s dataset computed the aggregations it needed inside its own query. This was a good way to build quickly, but after a few weeks three problems surfaced.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The same metric was defined with a different formula in each dataset.&lt;/strong&gt; Take win rate alone: the condition for identifying games with a decided outcome differed from dataset to dataset, and the criteria for cleansing abnormal data (excluding operator accounts, excluding abnormal users) were broad in some places and narrow in others. Changing a definition meant editing many places, and the moment we missed one, the numbers across charts stopped matching.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cleansing cost before aggregation was paid again every time a dashboard was opened.&lt;/strong&gt; Game logs often record multiple participants and items for a single event together in nested fields. To calculate a metric, we had to explode these fields into rows, join data from different sources, and filter out duplicates. Because this ran again with every dashboard dataset query, the cleansing query in front of the metric often cost more money and time than the metric query itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The analytics agent defined metrics on its own every time.&lt;/strong&gt; When we asked our in-house analytics agent for &quot;the top 10 cookies by win rate last week,&quot; it answered by reading uncleansed log data. In the process, the agent decided on a variety of criteria by itself — whether to exclude churned users, whether to set a minimum sample size, which criteria to cleanse by, and so on. We did write these conditions down in the guide documents (skills) the agent reads. But as the conditions attached to each metric grew, the skills got longer, every new condition meant editing a skill, and even then we couldn&apos;t fully prevent the tables and conditions from varying slightly from request to request.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The first and second problems could be solved to some extent with human time and effort, but the third could not be solved with our existing data environment alone.&lt;/p&gt;
&lt;h2 id=&quot;what-is-a-metric-view&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#what-is-a-metric-view&quot; aria-label=&quot;what is a metric view permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;What Is a Metric View?&lt;/h2&gt;
&lt;p&gt;A Metric View is a &lt;a href=&quot;https://docs.databricks.com/aws/en/uc-semantics/metric-views/&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;semantic layer feature provided by Databricks Unity Catalog&lt;/a&gt;. Its purpose is to store metric definitions in the catalog as view objects, so that dashboards, SQL, and agents alike query that view and read the same values.&lt;/p&gt;
&lt;p&gt;A semantic layer is a layer where you give names to metrics such as &quot;win rate&quot; or &quot;matchmaking success rate&quot; and define in one place what data each metric aggregates and how. Consumers don&apos;t write the aggregation expressions themselves; they simply select the defined metric fields. It&apos;s a long-established concept, seen in forms like BI tool data models, dbt&apos;s Semantic Layer, and LookML, and its purpose is to have everyone read the same value from a single definition instead of recalculating the same metric in each place it&apos;s consumed.&lt;/p&gt;
&lt;p&gt;Databricks&apos; Metric View is an implementation of this concept inside Unity Catalog. Once you define measures and dimensions in YAML on top of a table, consumers call metrics with the &lt;code&gt;measure()&lt;/code&gt; function and can read the same definition at any granularity just by changing the &lt;code&gt;group by&lt;/code&gt;. With a regular view, the aggregation and grouping granularity are fixed when the view is created; with a Metric View, only the definition is fixed, and the granularity is decided at query time.&lt;/p&gt;
&lt;p&gt;Let&apos;s look at the difference between the two using win rate as an example. With a regular view, you have to decide the aggregation granularity when you create the view.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;sql&quot;&gt;&lt;pre class=&quot;language-sql&quot;&gt;&lt;code class=&quot;language-sql&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;create&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;view&lt;/span&gt; win_rate_daily_by_mode &lt;span class=&quot;token keyword&quot;&gt;as&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;select&lt;/span&gt; base_date&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; mode_id&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; filter &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;where&lt;/span&gt; is_win&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;100.0&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;as&lt;/span&gt; win_rate
&lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; battle_play
&lt;span class=&quot;token keyword&quot;&gt;group&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;by&lt;/span&gt; base_date&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; mode_id&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This view can only answer daily, per-mode win rates. If you need win rate per cookie, you have to create another view with a different group by, and if you take the &lt;code&gt;avg()&lt;/code&gt; of this view&apos;s daily win rates to get a monthly win rate, days with many plays and days with few plays are weighted equally, giving you the wrong value.&lt;/p&gt;
&lt;p&gt;A Metric View, on the other hand, writes the same definition split into dimensions and measures.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Metric view definition&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1.1&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; battle_play
&lt;span class=&quot;token key atrule&quot;&gt;dimensions&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; base_date
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; base_date
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; mode_id
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; mode_id
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cookie_id
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cookie_id
&lt;span class=&quot;token key atrule&quot;&gt;measures&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; win_rate
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; count(&lt;span class=&quot;token important&quot;&gt;*)&lt;/span&gt; filter (where is_win) * 100.0 / count(&lt;span class=&quot;token important&quot;&gt;*)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;sql&quot;&gt;&lt;pre class=&quot;language-sql&quot;&gt;&lt;code class=&quot;language-sql&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;-- Example metric view queries&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;-- Daily, per mode&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;select&lt;/span&gt; base_date&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; mode_id&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; measure&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;win_rate&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;as&lt;/span&gt; win_rate
&lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; win_rate_metric_view
&lt;span class=&quot;token keyword&quot;&gt;group&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;by&lt;/span&gt; base_date&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; mode_id

&lt;span class=&quot;token comment&quot;&gt;-- Monthly&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;select&lt;/span&gt; date_trunc&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&apos;month&apos;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; base_date&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;as&lt;/span&gt; base_month&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; measure&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;win_rate&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;as&lt;/span&gt; win_rate
&lt;span class=&quot;token keyword&quot;&gt;from&lt;/span&gt; win_rate_metric_view
&lt;span class=&quot;token keyword&quot;&gt;group&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;by&lt;/span&gt; date_trunc&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&apos;month&apos;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; base_date&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The win rate definition lives in one place in YAML, and as the example above shows, you can run queries with different &lt;code&gt;GROUP BY&lt;/code&gt; clauses. With the earlier daily view, you couldn&apos;t use that view for a monthly query, but with a Metric View, even a monthly query doesn&apos;t average the daily win rates; it recalculates the win rate from all rows in that month, so the value is accurate.&lt;/p&gt;
&lt;p&gt;As mentioned earlier, the semantic layer concept existed before, but it usually lived inside BI tools. As a result, a definition only worked within that tool, and to see the same metric in another tool, in SQL, or in a notebook, you had to define it again. A Metric View, by contrast, lives not in a BI tool but in the data catalog (Databricks&apos; Unity Catalog), so any tool can query the same definition via SQL, and who can see the metric is managed in the catalog the same way as for any other table. That&apos;s why dashboards, SQL, notebooks, external BI tools, and agents can all read the same definition. In fact, in its Metric View launch announcement, Databricks puts particular emphasis on that last consumer, as shown below.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Analysts, engineers, executives and now AI agents often interpret the same data differently, resulting in metric drift, conflicting reports and declining trust. In the era of agentic AI, where agents reason over data and act autonomously, fragmented definitions do not just create confusion, they scale it.&lt;/p&gt;
&lt;p&gt;Source: Databricks, &lt;a href=&quot;https://www.databricks.com/blog/redefining-semantics-data-layer-future-bi-and-ai&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;&quot;Announcing General Availability and Open Sourcing of Unity Catalog Business Semantics&quot;&lt;/a&gt; (2026)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That last consumer is also the reason we wanted to use Metric Views. If dashboard performance had been the only goal, a data mart would have been enough. But when the analytics agent needed to analyze data along a wider variety of dimensions than the groupings used in dashboards, it had to query data at a lower layer than the data mart, and when it did, it read uncleansed log data and defined metrics on its own, producing values that diverged from those shown on the dashboards. The line from Databricks&apos; launch announcement — fragmented definitions &quot;scale&quot; confusion — was playing out exactly in our environment. So we needed a layer from which the agent could read definitions, and that layer had to share the same definitions as the dashboards we were using.&lt;/p&gt;
&lt;h2 id=&quot;design-definitions-in-one-layer-cleansing-beneath-it&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#design-definitions-in-one-layer-cleansing-beneath-it&quot; aria-label=&quot;design definitions in one layer cleansing beneath it permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Design: Definitions in One Layer, Cleansing Beneath It&lt;/h2&gt;
&lt;p&gt;At Devsisters, we had been managing our data warehouse in layers split into Bronze, Silver, and Gold, following Databricks&apos; &lt;a href=&quot;https://www.databricks.com/kr/blog/what-is-medallion-architecture&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;Medallion Architecture&lt;/a&gt;. So where should Metric Views sit?&lt;/p&gt;
&lt;p&gt;In terms of role, a Metric View is a consumption layer like Gold, but it differs in that it holds only definitions without fixing the aggregation granularity in advance. The source data for a Metric View could also be the Bronze layer, but as I mentioned earlier, we didn&apos;t want to pay processing costs such as handling nested fields over and over at query time. So we decided to put fully processed data in Silver and use Metric Views in a restricted way: like the Gold layer, they query data in Silver.&lt;/p&gt;
&lt;p&gt;So we made Metric Views &lt;code&gt;views that read Silver, not tables&lt;/code&gt;, classified them as a separate layer named Semantic, and placed it on top of Silver, alongside Gold. In the end, we defined each layer&apos;s role to fit our data structure as follows.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bronze&lt;/td&gt;
&lt;td&gt;Raw, as is. Never modified.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Silver&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Data integrity.&lt;/strong&gt; Explodes nested fields, joins records left in different sources, removes duplicates, and attaches cleansing flags. Every value that measure logic reads is produced here. No metrics are calculated here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gold&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Consumption optimization.&lt;/strong&gt; Physical tables precomputed at a fixed aggregation granularity. Built only when metrics that can&apos;t be served from a pre-aggregated cache, such as unique user counts or quantiles, need to be viewed quickly at a fixed granularity; all other metrics go in Metric Views.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic (Metric View)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Business meaning.&lt;/strong&gt; Defines measure formulas and dimensions in one place.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;reducing-query-cost-by-reading-pre-aggregated-results&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#reducing-query-cost-by-reading-pre-aggregated-results&quot; aria-label=&quot;reducing query cost by reading pre aggregated results permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Reducing Query Cost by Reading Pre-Aggregated Results&lt;/h3&gt;
&lt;p&gt;A regular view re-aggregates from the source table every time it&apos;s queried. When Metric Views first launched, they also stored only the definition and not the results, so their default behavior was the same. Since every query read the definition and re-aggregated, we expected that dashboards repeatedly opening the same results would need separate, precomputed Gold tables. However, the &lt;a href=&quot;https://docs.databricks.com/aws/en/metric-views/materialization&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;materialization feature&lt;/a&gt; added later resolved most of this concern. If you specify frequently used dimension combinations, Databricks precomputes and stores the aggregated results for those combinations. I&apos;ll call what is stored this way an &lt;em&gt;aggregated materialization&lt;/em&gt;. When a consumer queries with that combination, it reads the stored results instead of recomputing the definition. By registering the combinations our dashboards use here, most dashboards rendered fast enough.&lt;/p&gt;
&lt;p&gt;That said, this cache doesn&apos;t work for every measure. Just as summing daily play counts gives you monthly play counts, it can only be used for measures whose pre-aggregated values remain correct when combined again. Unique user counts and quantiles don&apos;t yield monthly values when you sum daily values, so such measures are recomputed at query time from an &lt;em&gt;unaggregated materialization&lt;/em&gt;, which stores all the individual, unaggregated rows. As a result, they get slower the longer the query period, so we built Gold tables only for datasets where such metrics need to be viewed quickly and frequently at a fixed granularity. We&apos;ll come back to this distinction under the name &lt;strong&gt;additivity&lt;/strong&gt; in the Rules section, and to the criteria for building Gold tables in the Decision section.&lt;/p&gt;
&lt;h2 id=&quot;rules-distinguishing-additive-from-non-additive&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#rules-distinguishing-additive-from-non-additive&quot; aria-label=&quot;rules distinguishing additive from non additive permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Rules: Distinguishing Additive from Non-Additive&lt;/h2&gt;
&lt;p&gt;The thing we had to be most careful about when designing Metric Views was the &lt;strong&gt;additivity&lt;/strong&gt; of measures. An additive measure stays correct when you aggregate values from a finer granularity to a coarser granularity by summing them: summing daily play counts gives you the monthly play count. A non-additive measure becomes wrong if you do that. Summing daily unique user counts double-counts users who logged in on multiple days, so the result exceeds the monthly unique user count, and averaging daily medians doesn&apos;t give you the monthly median. This distinction determined whether a measure could be pre-aggregated, whether we had to note a summation warning in the measure&apos;s comment, and whether consumers could combine results again.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Example metrics&lt;/th&gt;
&lt;th&gt;Example formulas&lt;/th&gt;
&lt;th&gt;Can it be summed?&lt;/th&gt;
&lt;th&gt;Can it be pre-aggregated?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Additive&lt;/td&gt;
&lt;td&gt;Play count, win count, total wait time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;count(*)&lt;/code&gt;, &lt;code&gt;sum(...)&lt;/code&gt;, &lt;code&gt;count(*) filter (where ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Safe to sum at any granularity&lt;/td&gt;
&lt;td&gt;Yes. Goes in the aggregated materialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-additive&lt;/td&gt;
&lt;td&gt;Unique user count, median wait time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;count(distinct ...)&lt;/code&gt;, &lt;code&gt;percentile_approx(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wrong if summed. Must be recomputed from individual rows&lt;/td&gt;
&lt;td&gt;No. Computed every time from the unaggregated materialization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;define-averages-as-a-sum-and-a-count&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#define-averages-as-a-sum-and-a-count&quot; aria-label=&quot;define averages as a sum and a count permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Define Averages as a Sum and a Count&lt;/h3&gt;
&lt;p&gt;As long as it&apos;s computed at query time, an average defined with &lt;code&gt;avg()&lt;/code&gt; is accurate at any granularity. The problem arises when reusing pre-aggregated results. The aggregated cache in a materialization combines pre-aggregated values into a coarser granularity. But averages can&apos;t be combined. If you average two daily averages, days with many samples and days with few are weighted equally, and you get a value different from the monthly average. So &lt;code&gt;avg()&lt;/code&gt; measures cannot use the aggregated cache and are recomputed from individual rows on every query. Consumers who take daily averages and average them again outside run into the same problem.&lt;/p&gt;
&lt;p&gt;Sums and counts, on the other hand, can each be combined. So we defined an average as a sum measure and a count measure, and added a separate composed measure that divides the sum by the count. Combining the sum and count into a coarser granularity in the cache and then dividing gives an accurate average, and whatever granularity a consumer groups by, the division is redone at that granularity.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;measures&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; wait_time_seconds_sum
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; sum(case when is_success = &apos;Y&apos; then wait_time_seconds end)
    &lt;span class=&quot;token key atrule&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Total wait time (seconds) — successful matches
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; wait_sample_count
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; count(&lt;span class=&quot;token important&quot;&gt;*)&lt;/span&gt; filter (where is_success = &apos;Y&apos;)
    &lt;span class=&quot;token key atrule&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Wait time sample count — successful matches
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; wait_time_seconds_avg
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; measure(wait_time_seconds_sum) / nullif(measure(wait_sample_count)&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; 0)
    &lt;span class=&quot;token key atrule&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Average wait time (seconds)
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; wait_time_seconds_p50
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; percentile_approx(case when is_success = &apos;Y&apos; then wait_time_seconds end&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; 0.50)
    &lt;span class=&quot;token key atrule&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Median wait time (seconds) — successful matches &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;non&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;additive&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id=&quot;use-window-measures-for-shares&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#use-window-measures-for-shares&quot; aria-label=&quot;use window measures for shares permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Use Window Measures for Shares&lt;/h3&gt;
&lt;p&gt;For a metric like cookie pick rate — &quot;this cookie&apos;s play count ÷ total play count for the mode&quot; — the denominator sums over a wider scope than the numerator. Databricks provides window measures for this. If you exclude the cookie axis from the window definition of the denominator measure, the denominator is computed ignoring cookies no matter what granularity the consumer groups by.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;dimensions&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; mode_id
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; mode_id
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cookie_id
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cookie_id
&lt;span class=&quot;token key atrule&quot;&gt;measures&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; mode_total_play_count
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; count(&lt;span class=&quot;token important&quot;&gt;*)&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;order&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cookie_id
        &lt;span class=&quot;token key atrule&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; all
        &lt;span class=&quot;token key atrule&quot;&gt;semiadditive&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; last
    &lt;span class=&quot;token key atrule&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Total play count for the mode — denominator ignoring cookies &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;LOD&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;exclude=cookie_id&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; pick_ratio
    &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; count(&lt;span class=&quot;token important&quot;&gt;*)&lt;/span&gt; * 100.0 / nullif(measure(mode_total_play_count)&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; 0)
    &lt;span class=&quot;token key atrule&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Cookie pick rate (%) — relative to the mode total &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;LOD&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;exclude=cookie_id&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;However, if the dimension excluded from the denominator (here, cookie) isn&apos;t in the consuming query&apos;s group by, the numerator and denominator become equal and the result is silently 100% with no error, so we note that axis in the comment.&lt;/p&gt;
&lt;h3 id=&quot;what-goes-in-a-comment&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#what-goes-in-a-comment&quot; aria-label=&quot;what goes in a comment permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;What Goes in a Comment&lt;/h3&gt;
&lt;p&gt;We decided to write only three things in measure comments: what is being counted, population conditions not evident from the name, and a tag indicating whether it&apos;s safe to combine. We don&apos;t write the formula, because it&apos;s already in the definition itself and repeating it would be redundant, and because a longer comment makes it easier to miss the warnings that actually matter.&lt;/p&gt;
&lt;p&gt;The reason we note &quot;whether it&apos;s safe to combine&quot; separately is that, unfortunately, Metric Views currently have no mechanism to prevent aggregations that shouldn&apos;t be combined. You can group by any defined dimension, and nothing checks whether that combination makes sense for the metric. If you group a unique game count, counted per game, by participants&apos; skill tiers, a single game is counted in multiple tiers and the total exceeds the overall count — yet no error is raised, and you get a plausible-looking number. &lt;a href=&quot;https://docs.getdbt.com/docs/build/measures?version=2#non-additive-dimensions&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;dbt&apos;s Semantic Layer lets you declare measures that become wrong when summed along the time axis with &lt;code&gt;non_additive_dimension&lt;/code&gt;&lt;/a&gt;, and the engine blocks it. As of YAML spec 1.1, Metric Views don&apos;t offer such a declaration for regular measures. So we record this constraint in the comment. People and agents both write queries after reading comments, but this information matters especially for agents. A person can ask the author when the meaning is ambiguous, but an agent writes a query right away based only on the names, types, comments, and formulas of dimensions and measures. The formula alone doesn&apos;t tell you that summing a &lt;code&gt;count(distinct ...)&lt;/code&gt; by month is wrong, so if it&apos;s in the comment, the agent re-groups over the monthly range, and if it isn&apos;t, the agent just makes its own call.&lt;/p&gt;
&lt;p&gt;We used two tags in comments. &lt;code&gt;[non-additive]&lt;/code&gt; means that combining aggregated values again gives the wrong result, and &lt;code&gt;[LOD:exclude=&amp;#x3C;dim&gt;]&lt;/code&gt; means that leaving the axis named in &lt;code&gt;&amp;#x3C;dim&gt;&lt;/code&gt; out of the group by gives the wrong result. We don&apos;t tag additive measures or ratios built from them. The absence of a tag is itself meant to signal &quot;safe to combine.&quot; We put the same tag descriptions in the guide the analytics agent reads, so that the side building Metric Views and the side querying them follow the same rules.&lt;/p&gt;
&lt;h2 id=&quot;decision-gold-table-or-metric-view&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#decision-gold-table-or-metric-view&quot; aria-label=&quot;decision gold table or metric view permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Decision: Gold Table or Metric View?&lt;/h2&gt;
&lt;p&gt;Once you introduce Metric Views, the question naturally follows: &quot;If we&apos;re pre-aggregating anyway, how is this different from a Gold table, and which should we build?&quot; We got this question for every metric too, and the criteria we used are summarized below.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Gold table&lt;/th&gt;
&lt;th&gt;Metric View&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Query speed&lt;/td&gt;
&lt;td&gt;Fastest, since it&apos;s already aggregated at a fixed granularity&lt;/td&gt;
&lt;td&gt;Similar if an aggregated materialization matches; otherwise re-aggregated from the unaggregated materialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dimension flexibility&lt;/td&gt;
&lt;td&gt;Can only be viewed at the granularity chosen at build time. A new axis requires a new table&lt;/td&gt;
&lt;td&gt;Can be viewed in any combination within the defined dimensions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-additive metrics (distinct, quantiles)&lt;/td&gt;
&lt;td&gt;Accurate only at the fixed granularity. Can&apos;t be re-aggregated to another granularity&lt;/td&gt;
&lt;td&gt;Recomputed from individual rows whatever the group by&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumers&lt;/td&gt;
&lt;td&gt;Suited to queries at the same aggregation granularity. Multiple dashboards can share it if the granularity is the same&lt;/td&gt;
&lt;td&gt;Suited to cases where multiple dashboards, analysts&apos; ad hoc queries, and agents share the same definition regardless of granularity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of adding a metric&lt;/td&gt;
&lt;td&gt;Adding a column means reloading that column for the full history, and a new granularity requires a new table&lt;/td&gt;
&lt;td&gt;Adding a measure to the YAML makes it usable immediately without reloading. Changing a dimension recomputes the entire cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relationship with agents&lt;/td&gt;
&lt;td&gt;Columns hold precomputed values, so formulas must be written separately in the agent guide&lt;/td&gt;
&lt;td&gt;The agent reads the Metric View&apos;s definition (formulas, comments) directly, so there&apos;s no need to copy formulas into the guide&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;As the table shows, metrics whose breakdown dimensions aren&apos;t decided in advance, and non-additive metrics that must be recomputed depending on granularity, are a fit for Metric Views. Conversely, for tables with a fixed aggregation granularity where speed matters most, a Gold table is simpler and faster. Looking at dashboards alone, Gold tables are sufficient for most charts. The reason we chose Metric Views anyway was the agent. The agent calls metrics with a different combination of dimensions for every question, so Gold tables with fixed granularity couldn&apos;t prepare for that in advance.&lt;/p&gt;
&lt;p&gt;This also gives us our criterion for building Gold tables. The default is a Metric View on top of Silver. Only when a query that needs speed includes non-additive measures and computing it from the unaggregated materialization isn&apos;t fast enough do we turn that query into a Gold table with a fixed granularity. Queries that use only additive measures are covered well enough by the aggregated materialization, so they don&apos;t need Gold tables.&lt;/p&gt;
&lt;h2 id=&quot;results-comparing-costs-before-and-after-the-migration&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#results-comparing-costs-before-and-after-the-migration&quot; aria-label=&quot;results comparing costs before and after the migration permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Results: Comparing Costs Before and After the Migration&lt;/h2&gt;
&lt;p&gt;After introducing Metric Views, cases of the same metric being calculated with different definitions decreased. But did costs also go down compared to when we read logs directly? Since we had moved our dashboards to Metric Views as decided in the previous section, we compared before and after the move. We ran the original queries and the queries migrated to Metric Views once each, cold, with the same parameters, and compared compute and scan volume. For the heaviest dashboard, summing across its datasets, a single run showed &lt;strong&gt;a 41% reduction in compute and a 6.7x reduction in scan volume&lt;/strong&gt;. The magnitude varied by dataset, though. Where log cleansing had been expensive, compute fell to as little as 30% of its previous level; where queries were already light, it was around 80%.&lt;/p&gt;
&lt;p&gt;What about the agent? After restructuring the domain knowledge our in-house analytics agent references around Metric Views, we compared before and after using the same set of questions. The tokens and cost spent producing answers dropped by about half, and the number of turns taken to reach an answer dropped by 30%.&lt;/p&gt;
&lt;p&gt;The &quot;top 10 cookies by win rate&quot; question mentioned earlier is a representative case. When reading logs directly, the agent first searched for which tables to use, decided on cleansing criteria and win conditions by itself, wrote a long query, and then, suspicious of the results, checked once more. After switching to Metric Views, it finished with a single call to a defined measure. Both answers got the order of the top cookies right, but in the answer from reading logs directly, the agent had chosen the cleansing criteria and win conditions, so the win rate numbers differed slightly from those shown on the dashboard; with Metric Views, the agent produced the same values as the dashboard.&lt;/p&gt;
&lt;h2 id=&quot;follow-up-what-we-learned-operating-metric-views&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#follow-up-what-we-learned-operating-metric-views&quot; aria-label=&quot;follow up what we learned operating metric views permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Follow-Up: What We Learned Operating Metric Views&lt;/h2&gt;
&lt;p&gt;Of the things we learned while revising Metric Views over the three months after deployment, here I&apos;ll summarize only those related to how Metric Views behave.&lt;/p&gt;
&lt;h4 id=&quot;if-a-filter-consumers-always-apply-isnt-in-the-cache-the-cache-isnt-used&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#if-a-filter-consumers-always-apply-isnt-in-the-cache-the-cache-isnt-used&quot; aria-label=&quot;if a filter consumers always apply isnt in the cache the cache isnt used permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;If a Filter Consumers Always Apply Isn&apos;t in the Cache, the Cache Isn&apos;t Used&lt;/h4&gt;
&lt;p&gt;Nearly every dashboard dataset applied the normal-user flag in a &lt;code&gt;where&lt;/code&gt; clause. However, that flag was missing from the aggregated materialization&apos;s dimension list. Databricks&apos; query rewrite considers a materialization as a candidate only if it contains the dimensions used not just in the &lt;code&gt;group by&lt;/code&gt; but also in the &lt;code&gt;where&lt;/code&gt;. When we checked with &lt;code&gt;explain extended&lt;/code&gt;, the pre-aggregated cache wasn&apos;t being applied, and everything was going to the unaggregated materialization. We added a rule to include filter dimensions that consumers always apply in the aggregated materialization.&lt;/p&gt;
&lt;h4 id=&quot;changing-a-dimension-rebuilds-the-entire-cache&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#changing-a-dimension-rebuilds-the-entire-cache&quot; aria-label=&quot;changing a dimension rebuilds the entire cache permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Changing a Dimension Rebuilds the Entire Cache&lt;/h4&gt;
&lt;p&gt;When the definition changes, a materialization recomputes the full history. Editing only measures doesn&apos;t trigger a recompute, but adding or changing a dimension recomputes the full history, so adding a single dimension costs more than you might think. This is because there&apos;s no concept of reloading by date as in a typical data mart. As a result, whether the source is a query that can recompute the full history became a design requirement, and when adding a dimension, we came to first weigh whether that axis is really necessary and what a full recompute would cost.&lt;/p&gt;
&lt;h4 id=&quot;non-additive-metrics-require-a-cache-of-individual-rows-to-be-queryable&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#non-additive-metrics-require-a-cache-of-individual-rows-to-be-queryable&quot; aria-label=&quot;non additive metrics require a cache of individual rows to be queryable permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Non-Additive Metrics Require a Cache of Individual Rows to Be Queryable&lt;/h4&gt;
&lt;p&gt;Unique user counts and quantiles must be recomputed from individual rows whatever the group by, so an aggregated cache can&apos;t cover them. That&apos;s why an unaggregated materialization that stores all individual rows is always needed; a setup with only aggregated materializations and no unaggregated one doesn&apos;t work. Any query that calls even one non-additive measure is computed entirely from the unaggregated materialization. So on dashboards, we separated frequently viewed non-additive metrics into different charts from additive ones. Dashboards can be split this way because which measures they call in which combinations is decided in advance, but the agent calls different measures for every question, so we couldn&apos;t use the same approach there.&lt;/p&gt;
&lt;h4 id=&quot;embedding-a-filter-in-a-measure-removes-consumer-choice&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#embedding-a-filter-in-a-measure-removes-consumer-choice&quot; aria-label=&quot;embedding a filter in a measure removes consumer choice permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Embedding a Filter in a Measure Removes Consumer Choice&lt;/h4&gt;
&lt;p&gt;We embedded the condition &quot;only successful matches with no bots&quot; in the wait time measure. As a result, during periods with a high share of bot matches, we couldn&apos;t see wait times including bots. But pulling every condition out into dimensions would scatter the metric definition, so we decided to keep conditions that belong to the metric definition (successful matches only) inside the measure, and expose conditions that consumers may want to change depending on the situation (whether to include bots) as dimensions.&lt;/p&gt;
&lt;h2 id=&quot;remaining-work&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#remaining-work&quot; aria-label=&quot;remaining work permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Remaining Work&lt;/h2&gt;
&lt;p&gt;Once we finished introducing Metric Views, we found we had far too many measures. There are several measures with similar names, and for some, you can only tell which one to use by opening the definition. Early in the migration, we took the position that every number on a dashboard should be retrievable directly from a Metric View, and used the query results of the existing dashboards as the baseline. Every column in the results had to become a dimension or a measure, and our completion criterion was that values before and after the migration matched column by column. This let us migrate quickly and verify immediately, but as a result, the shape of our Metric Views simply followed the shape of the dashboard results. Starting from a single win rate, we got a new measure for every added condition: &quot;win rate excluding bots,&quot; &quot;win rate for a specific mode,&quot; &quot;win rate based on first place.&quot; Anything derived from other measures, such as an index computed by dividing two measures, also became a measure.&lt;/p&gt;
&lt;p&gt;In hindsight, it probably would have been better to keep these derived metrics outside the Metric View. A conditional win rate, after all, can be obtained by applying a filter to the win count and play count. So we plan to clean this up so that Metric Views keep only base measures that serve as building blocks for other metrics, and non-additive measures that must be recomputed depending on granularity.&lt;/p&gt;
&lt;p&gt;One more thing: the rules we set in this work (additivity checks, comment tags, materialization placement, verification procedures) are hard for people to remember every time. So we&apos;re working on moving the process of creating and editing Metric Views itself into a skill for our coding agent. The goal is to mechanically catch YAML that violates the rules above, and to explicitly request review for items that require human judgment, such as the intent behind a metric definition. If the rules don&apos;t just live in a document but are checked automatically by a skill, we hope we can expect the same quality when applying the structure in this post to other games.&lt;/p&gt;
&lt;h2 id=&quot;closing&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#closing&quot; aria-label=&quot;closing permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Closing&lt;/h2&gt;
&lt;p&gt;When metric definitions are written separately in dashboard queries, in analysis queries, and in the skill documents agents read, there&apos;s no way to stop them from drifting apart little by little. So we gathered our metric definitions into Metric Views and had both dashboards and agents use those definitions as is. The cases where the numbers on a dashboard and the numbers an agent answered with didn&apos;t match, forcing us to check which one was right, dropped noticeably. Consolidating definitions was the goal, but by no longer repeating cleansing and aggregation every time, we were able to cut query costs as well.&lt;/p&gt;
&lt;p&gt;If you&apos;re running into the problem of the same metric returning different values depending on where it&apos;s queried, I&apos;d recommend first looking at how to consolidate your metric definitions in one place before fixing each query. Thank you.&lt;/p&gt;
&lt;p&gt;&lt;we-are-hiring
 title=&quot;[Tech Division] Analytics Engineer (DW)&quot;
 link=&quot;https://careers.devsisters.com/o/239461&quot;
 tags=&quot;Tech Division, Data, Analytics Engineering&quot;&gt;&lt;/we-are-hiring&gt;&lt;/p&gt;</content:encoded></item><item><title><![CDATA[Scaling Kafka Streams on Kubernetes: How We Cut Costs by 80%]]></title><description><![CDATA[While moving a Kafka Streams application from EC2 to Kubernetes, we ran into the limits of CPU-based HPA. This post shares how we switched to KEDA and consumer lag-based scaling and cut infrastructure costs by more than 80%.]]></description><link>https://tech.devsisters.com/en/posts/kafka-streams-keda-scaling</link><guid isPermaLink="false">https://tech.devsisters.com/en/posts/kafka-streams-keda-scaling</guid><pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Hello, I&apos;m Taewan Kim from the Data Engineering Cell in the Tech Division.&lt;/p&gt;
&lt;p&gt;In this post, I&apos;ll share how we designed a scaling policy suited to Kafka consumer workloads while migrating Log Transformer, a Kafka Streams application that ran on AWS EC2, to Kubernetes (k8s), and what came of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Here&apos;s what this post covers:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Why CPU-based HPA is a poor fit for Kafka consumer workloads&lt;/li&gt;
&lt;li&gt;Designing scaling policies around workload-specific metrics with KEDA and consumer lag&lt;/li&gt;
&lt;li&gt;Getting ahead of the midnight traffic spike with a cron trigger&lt;/li&gt;
&lt;li&gt;Running multiple threads per pod to reduce JVM overhead and rebalance events&lt;/li&gt;
&lt;li&gt;The result: &lt;strong&gt;more than 80% lower infrastructure costs&lt;/strong&gt; compared with EC2&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;background-the-limits-of-log-transformer-on-ec2&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#background-the-limits-of-log-transformer-on-ec2&quot; aria-label=&quot;background the limits of log transformer on ec2 permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Background: The Limits of Log Transformer on EC2&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Log Transformer&lt;/strong&gt; is a Kafka Streams application that reads raw logs from multiple Kafka topics, validates and transforms them, and then &lt;strong&gt;publishes the transformed logs to game-specific Kafka topics&lt;/strong&gt;. Because it routes raw logs from multiple source topics into topics split by game and purpose, downstream pipelines can consume only the topics they need.&lt;/p&gt;
&lt;p&gt;It ran on &lt;strong&gt;72 c6g.2xlarge instances&lt;/strong&gt; and had the following limitations.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Instance counts that didn&apos;t follow the traffic pattern&lt;/strong&gt;: Originally, we attached a cron-based schedule to the ASG (Auto Scaling Group) so that it switched between 36 and 72 instances depending on the time of day. This approach was unstable, though, so we ended up pinning it at 72, the peak capacity, and kept that size even during low-traffic hours.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rigid scaling&lt;/strong&gt;: Keeping up with traffic changes meant someone had to change the ASG settings by hand each time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A complex deployment pipeline&lt;/strong&gt;: Spinnaker-based EC2 deployments had many stages and took a long time to roll out, so even a simple configuration change was a burden.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of these limits came down to the lack of autoscaling that reacts to traffic in real time and of fast, simple deployments. On k8s, autoscalers such as HPA and KEDA can react to traffic changes automatically, and declarative manifests and GitOps deployments reduce the burden of configuration changes. So we decided to move Log Transformer to k8s.&lt;/p&gt;
&lt;p&gt;As the first step of the migration, we split the single application that consumed multiple source topics together into independent Kafka Streams applications, one per source topic. Each topic has a different traffic pattern, so we wanted to apply scaling policies independently and isolate failures and deployments per topic. With that split done, we got down to designing the scaling policy.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;first-attempt-why-cpu-based-hpa-failed&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#first-attempt-why-cpu-based-hpa-failed&quot; aria-label=&quot;first attempt why cpu based hpa failed permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;First Attempt: Why CPU-Based HPA Failed&lt;/h2&gt;
&lt;p&gt;After moving to k8s, the first thing we tried was a basic CPU-based HPA (Horizontal Pod Autoscaler). The replica counts and thresholds in the YAML in this post are example values for illustration.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;apiVersion&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; autoscaling/v2
&lt;span class=&quot;token key atrule&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; HorizontalPodAutoscaler
&lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;transformer&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;hpa
&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;minReplicas&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;maxReplicas&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;10&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;metrics&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Resource
      &lt;span class=&quot;token key atrule&quot;&gt;resource&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cpu
        &lt;span class=&quot;token key atrule&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;token key atrule&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Utilization
          &lt;span class=&quot;token key atrule&quot;&gt;averageUtilization&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;70&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The result wasn&apos;t what we expected. Within a consumer group, each partition is assigned to only one consumer (here, a pod), so load spreads evenly only when partitions are divided evenly among consumers. In practice, partitions weren&apos;t distributed evenly across consumers, so while some pods hit 100% CPU, the average CPU stayed below the target and HPA never reacted.&lt;/p&gt;
&lt;h3 id=&quot;partitions-that-dont-divide-evenly-and-concentrated-load&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#partitions-that-dont-divide-evenly-and-concentrated-load&quot; aria-label=&quot;partitions that dont divide evenly and concentrated load permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Partitions That Don&apos;t Divide Evenly, and Concentrated Load&lt;/h3&gt;
&lt;p&gt;Kafka Streams groups partitions into tasks and distributes them with &lt;code&gt;StreamsPartitionAssignor&lt;/code&gt;, handing out tasks in proportion to the number of stream threads each instance has. The problem comes next. If the number of tasks isn&apos;t divisible by the total number of threads, the leftover tasks have to go somewhere, so some consumers get one extra task each and take on that much more load.&lt;/p&gt;
&lt;p&gt;This isn&apos;t a weakness of any particular assignment strategy. As long as the number of tasks isn&apos;t divisible by the total number of threads, the imbalance remains no matter which assignment strategy you use.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;[Example: 6 partitions, 4 consumers]

6 / 4 = 1 remainder 2 → 2 consumers each take one extra partition.

Consumer A: partition 0, 1  → CPU 80%
Consumer B: partition 2, 3  → CPU 80%
Consumer C: partition 4     → CPU 40%
Consumer D: partition 5     → CPU 40%

Average CPU: 60%
HPA target: 70%
→ Scaling condition not met. Consumers A and B fall behind.&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id=&quot;the-assumption-behind-average-cpu&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#the-assumption-behind-average-cpu&quot; aria-label=&quot;the assumption behind average cpu permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;The Assumption Behind Average CPU&lt;/h3&gt;
&lt;p&gt;Here, &lt;strong&gt;lag&lt;/strong&gt; means the number of messages in a topic that consumers haven&apos;t processed yet. On consumers that were assigned more partitions, processing falls behind and lag builds up, and those pods&apos; CPU shoots up to 100%. But with many pods, the average CPU stays at or below the target, so HPA never reacts.&lt;/p&gt;
&lt;p&gt;Average CPU is a valid metric only on the assumption that the workload is spread evenly across all of an application&apos;s pods. That assumption doesn&apos;t hold for workloads like Kafka consumers, where load is tied to partitions.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;solution-scaling-on-consumer-lag-with-keda&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#solution-scaling-on-consumer-lag-with-keda&quot; aria-label=&quot;solution scaling on consumer lag with keda permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Solution: Scaling on Consumer Lag with KEDA&lt;/h2&gt;
&lt;p&gt;The core of the problem was that CPU didn&apos;t properly represent the real load of this workload. We needed to scale on lag, the metric that directly reflects the load. HPA does support external metrics, but that requires attaching and operating a separate metrics adapter that exposes Kafka lag. So we adopted &lt;a href=&quot;https://keda.sh/&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;KEDA (Kubernetes Event-Driven Autoscaling)&lt;/a&gt;, which can use a wide range of event sources as scaling criteria.&lt;/p&gt;
&lt;h3 id=&quot;adopting-keda-and-setting-lagthreshold&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#adopting-keda-and-setting-lagthreshold&quot; aria-label=&quot;adopting keda and setting lagthreshold permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Adopting KEDA and Setting lagThreshold&lt;/h3&gt;
&lt;p&gt;KEDA&apos;s Kafka scaler queries a consumer group&apos;s lag directly and scales based on that value.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;apiVersion&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; keda.sh/v1alpha1
&lt;span class=&quot;token key atrule&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ScaledObject
&lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;transformer
&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;scaleTargetRef&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;transformer
  &lt;span class=&quot;token key atrule&quot;&gt;minReplicaCount&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;maxReplicaCount&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;10&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;triggers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; kafka
      &lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;bootstrapServers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; kafka&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;broker&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;9092&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;consumerGroup&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;transformer&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;group
        &lt;span class=&quot;token key atrule&quot;&gt;topic&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;topic
        &lt;span class=&quot;token key atrule&quot;&gt;lagThreshold&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&apos;100&apos;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;lagThreshold&lt;/code&gt; is the amount of lag one replica is expected to handle. KEDA scales replicas to the total lag divided by this value, and the replica count never exceeds the number of partitions.&lt;/p&gt;
&lt;h3 id=&quot;benefits-of-lag-based-scaling&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#benefits-of-lag-based-scaling&quot; aria-label=&quot;benefits of lag based scaling permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Benefits of Lag-Based Scaling&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Based on workload-specific metrics&lt;/strong&gt;: Decisions are made on a metric that comes from the Kafka consumer workload itself rather than a general-purpose one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reacts even when partition counts are unbalanced&lt;/strong&gt;: Because it uses the total lag rather than individual consumers&apos; CPU, scaling decisions still happen when partitions are distributed unevenly and the average CPU looks low. If traffic concentrates on a single partition, however, adding replicas won&apos;t increase that partition&apos;s throughput, and the partition design itself needs another look.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supports scale-to-zero&lt;/strong&gt;: During hours when no data arrives on a source topic, the pod count can drop to zero, and pods start up to process messages once they begin to pile up again. We applied this to development-environment topics with intermittent traffic to eliminate idle costs, while production topics keep a minimum number of replicas to avoid processing delays.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;refinement-the-midnight-traffic-spike-and-the-cron-trigger&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#refinement-the-midnight-traffic-spike-and-the-cron-trigger&quot; aria-label=&quot;refinement the midnight traffic spike and the cron trigger permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Refinement: The Midnight Traffic Spike and the Cron Trigger&lt;/h2&gt;
&lt;p&gt;After applying KEDA, the application ran reliably most of the time, but there was one window where lag-based scaling alone responded too late.&lt;/p&gt;
&lt;p&gt;Many games reset features such as daily missions and daily check-ins at midnight (00:00), and at that moment related batch jobs and daily aggregation logs pour in all at once, causing a traffic spike. Consumer lag-based scaling reacts only after lag has built up, so it inevitably lags behind such a sharp traffic increase. On top of that, new pods only start processing after they become ready and the consumer group rebalance completes, plus JVM warm-up time. Until the scale-out finishes, lag keeps climbing, and log delivery to the downstream pipelines is delayed accordingly.&lt;/p&gt;
&lt;h3 id=&quot;getting-ahead-with-a-cron-trigger&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#getting-ahead-with-a-cron-trigger&quot; aria-label=&quot;getting ahead with a cron trigger permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Getting Ahead with a Cron Trigger&lt;/h3&gt;
&lt;p&gt;In addition to the Kafka scaler, KEDA supports a cron trigger. We solved the problem by increasing the pod count in advance, just before midnight.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;apiVersion&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; keda.sh/v1alpha1
&lt;span class=&quot;token key atrule&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ScaledObject
&lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;transformer
&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;scaleTargetRef&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;transformer
  &lt;span class=&quot;token key atrule&quot;&gt;minReplicaCount&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;maxReplicaCount&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;10&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;triggers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; kafka
      &lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;bootstrapServers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; kafka&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;broker&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;9092&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;consumerGroup&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;transformer&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;group
        &lt;span class=&quot;token key atrule&quot;&gt;topic&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; log&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;topic
        &lt;span class=&quot;token key atrule&quot;&gt;lagThreshold&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&apos;100&apos;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cron
      &lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;timezone&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Asia/Seoul
        &lt;span class=&quot;token key atrule&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&apos;50 23 * * *&apos;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;# scale out at 23:50&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&apos;30 0 * * *&apos;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;# end the cron trigger&apos;s active window at 00:30&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;desiredReplicas&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&apos;8&apos;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;When the Kafka lag trigger and the cron trigger are used together, KEDA takes the higher replica count of the two. It scales out to 8 in advance at 23:50, absorbs the midnight traffic, and after 00:30 scales in naturally based on lag.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;optimization-fewer-rebalance-events-through-multiple-threads&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#optimization-fewer-rebalance-events-through-multiple-threads&quot; aria-label=&quot;optimization fewer rebalance events through multiple threads permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Optimization: Fewer Rebalance Events Through Multiple Threads&lt;/h2&gt;
&lt;p&gt;With the scaling policy and the midnight response in place, we finally redesigned the pod configuration itself. A simple setup with one Kafka Streams thread per pod is easy to operate, but JVM startup overhead grows linearly with the number of pods, and pod lifecycle events (restarts, rolling deploys, evictions) directly trigger consumer group rebalances.&lt;/p&gt;
&lt;p&gt;To reduce this, we configured each pod to run multiple Kafka Streams threads at once. Kafka Streams supports running multiple stream threads per instance (= pod) through the &lt;code&gt;num.stream.threads&lt;/code&gt; setting, and each thread is independently assigned tasks (groups of partitions) to process (&lt;a href=&quot;https://kafka.apache.org/documentation/streams/architecture&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;Kafka Streams Architecture&lt;/a&gt;). From the consumer group&apos;s point of view, each thread behaves like an independent consumer, so increasing the number of threads per pod leaves partition distribution and processing consistency unchanged. In other words, this change kept the total number of consumer threads the same and reduced only the number of pods, which let us lower the number of JVM instances, the memory overhead, and the absolute frequency of lifecycle events all at once.&lt;/p&gt;
&lt;p&gt;However, as more threads are bundled into a single pod, the blast radius of a single pod failure grows. So we didn&apos;t increase threads per pod without limit, but capped it according to the CPU allocation rule below.&lt;/p&gt;
&lt;p&gt;To avoid CPU contention between threads, we set the thread count at &lt;strong&gt;one CPU core per thread&lt;/strong&gt; and sized the pod&apos;s CPU request/limit to match. For example, a pod running 6 threads is allocated 6 cores. Kafka Streams&apos; &lt;a href=&quot;https://docs.confluent.io/platform/current/streams/sizing.html&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;capacity planning guide&lt;/a&gt; also recommends sizing the number of stream threads to the available CPU cores; we ran somewhat conservatively at one core per thread to make throughput predictable. This keeps per-pod throughput stable, and &quot;a single pod&apos;s capacity to process the backlog,&quot; which lag-based scaling relies on, also converges to a predictable value.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;results-resources-that-follow-traffic-and-lower-costs&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#results-resources-that-follow-traffic-and-lower-costs&quot; aria-label=&quot;results resources that follow traffic and lower costs permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Results: Resources That Follow Traffic, and Lower Costs&lt;/h2&gt;
&lt;h3 id=&quot;cost-savings&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#cost-savings&quot; aria-label=&quot;cost savings permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Cost Savings&lt;/h3&gt;
&lt;p&gt;Compared with running fixed EC2 instances, moving to k8s + KEDA cut infrastructure costs by more than 80%.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;EC2&lt;/th&gt;
&lt;th&gt;k8s + KEDA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;Fixed at 72 (scheduled adjustment stopped)&lt;/td&gt;
&lt;td&gt;Automatic (lag-based + cron)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Fixed 24-hour cost&lt;/td&gt;
&lt;td&gt;Cost proportional to traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational burden&lt;/td&gt;
&lt;td&gt;Infra work such as adding instances when a game is added&lt;/td&gt;
&lt;td&gt;Done by deploying a config change&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;More important than the 80% figure itself is the change in how we operate. On EC2, we had to keep instances running at peak capacity at all times, but with KEDA the deployment shrinks to 2 pods overnight and scales out only at peaks, allocating just as many pods as the load at that moment requires. The 80% was measured under the current traffic pattern; the key point is that resources follow the traffic even as the pattern changes.&lt;/p&gt;
&lt;h3 id=&quot;less-operational-burden&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#less-operational-burden&quot; aria-label=&quot;less operational burden permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Less Operational Burden&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Adding a game only requires adding values to the Helm chart.&lt;/li&gt;
&lt;li&gt;Scaling policies are managed in the Helm chart, so they can be reviewed and their history tracked.&lt;/li&gt;
&lt;li&gt;Deployments go through ArgoCD with just an image tag and environment variable change, which greatly reduced deployment preparation and rollout time compared with the previous Spinnaker-based EC2 pipeline.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;remaining-work-distributing-partitions-evenly-down-to-the-thread-level&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#remaining-work-distributing-partitions-evenly-down-to-the-thread-level&quot; aria-label=&quot;remaining work distributing partitions evenly down to the thread level permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Remaining Work: Distributing Partitions Evenly Down to the Thread Level&lt;/h2&gt;
&lt;p&gt;Lag-based scaling works stably in the current setup, but the underlying problem of partitions not being distributed evenly across consumers remains.&lt;/p&gt;
&lt;p&gt;KEDA&apos;s &lt;code&gt;ensureEvenDistributionOfPartitions&lt;/code&gt; option addresses this by limiting the replica count to divisors of the total number of partitions. However, this option only checks &lt;code&gt;partitions / replicas&lt;/code&gt;. In a setup where each pod runs multiple threads, the units that actually divide up the partitions are threads, not pods, so the condition that has to hold is whether &lt;code&gt;partitions / (replicas × threads per pod)&lt;/code&gt; is an integer. Because we had already split deployments by source topic, each pod is dedicated to a single topic, so the number of threads per pod here is the number of threads processing that topic.&lt;/p&gt;
&lt;p&gt;If 24 partitions are processed with 4 threads per pod, the option allows replica counts of 1, 2, 3, 4, 6, 8, 12, and 24 (the divisors of 24), but only counts of 1, 2, 3, and 6 distribute tasks evenly across threads. The other values show two symptoms.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;4 replicas&lt;/strong&gt;: Each pod gets an even 6 tasks, but 24 tasks are split across 16 threads in total, so 8 threads get 2 tasks and the other 8 get 1. Throughput between threads can differ by up to 2x.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;8 replicas or more&lt;/strong&gt;: The number of threads exceeds the number of tasks. With 8 replicas, 8 of the 32 threads, and with 24 replicas, 72 of the 96 threads, receive no tasks at all and just spin through empty poll loops.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same goes for the replica upper bound. KEDA limits the replica count to at most the number of partitions, but this upper bound is also calculated without knowing the number of threads per pod. Ultimately, extending even distribution down to the thread level requires KEDA to know the number of threads per pod when deciding replicas.&lt;/p&gt;
&lt;p&gt;To fill this gap, we opened an &lt;a href=&quot;https://github.com/kedacore/keda/issues/7577&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;issue proposing a new option&lt;/a&gt; in the KEDA repository, and a &lt;a href=&quot;https://github.com/kedacore/keda/pull/7765&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;PR implementing an option named &lt;code&gt;threadsPerConsumer&lt;/code&gt;&lt;/a&gt; is now under review. If you set this value to the number of threads per pod together with &lt;code&gt;ensureEvenDistributionOfPartitions&lt;/code&gt;, KEDA picks replicas from the divisors of &lt;code&gt;partitions / threadsPerConsumer&lt;/code&gt; rather than of &lt;code&gt;partitions&lt;/code&gt;. In the earlier example, only the divisors of 6 instead of 24, that is 1, 2, 3, and 6, become candidates, guaranteeing even distribution at both the pod and the thread level.&lt;/p&gt;
&lt;p&gt;More fundamentally, we&apos;re also writing a component in Rust that will replace Log Transformer, written in Scala, and take on an expanded role. We expect lower JVM startup overhead and GC latency, reduced CPU and memory usage, and higher throughput. We&apos;ll share the results in a separate post once they&apos;re in, along with the progress of the &lt;code&gt;threadsPerConsumer&lt;/code&gt; option.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;closing-scaling-metrics-that-fit-the-workload&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#closing-scaling-metrics-that-fit-the-workload&quot; aria-label=&quot;closing scaling metrics that fit the workload permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Closing: Scaling Metrics That Fit the Workload&lt;/h2&gt;
&lt;p&gt;What this migration reconfirmed for us comes down to one thing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose scaling metrics to match the load characteristics of your workload.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;CPU utilization is a perfectly valid metric for workloads whose load spreads evenly across pods. But for workloads like Kafka consumers, where load is tied to partitions, average CPU alone made it hard to catch real processing delays. By using consumer lag as the criterion, we were able to make our scaling more accurate.&lt;/p&gt;
&lt;p&gt;Beyond Kafka, KEDA provides scalers for a wide range of event sources, including RabbitMQ, AWS SQS, and Prometheus metrics. Being able to easily bring in metrics other than CPU as scaling criteria was its biggest advantage for us.&lt;/p&gt;
&lt;p&gt;If you&apos;re wrestling with similar questions about scaling Kafka consumers, we recommend looking into consumer lag-based scaling.&lt;/p&gt;
&lt;p&gt;Thank you.&lt;/p&gt;
&lt;we-are-hiring /&gt;</content:encoded></item><item><title><![CDATA[Scala for Game Server Development]]></title><description><![CDATA[Why Scala is a great match for developing game servers.]]></description><link>https://tech.devsisters.com/posts/scala-for-game-server-development</link><guid isPermaLink="false">https://tech.devsisters.com/posts/scala-for-game-server-development</guid><pubDate>Mon, 24 Oct 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;알림: &lt;a href=&quot;/posts/scala-for-game-server-development-kr&quot;&gt;한국어로 번역된 글을 보시려면 여기를 클릭하세요.&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;At Studio Kingdom, we use the &lt;a href=&quot;https://www.scala-lang.org/&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;Scala programming language&lt;/a&gt; to bring our hit game &lt;a href=&quot;https://www.cookierun-kingdom.com/&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;CookieRun: Kingdom&lt;/a&gt; to life. Scala is the language used for developing the game servers. Even though it is not the most popular language out there, we think that it’s a perfect fit for us and game server development in general. Let’s see why.&lt;/p&gt;
&lt;p&gt;In this article, we will discuss several aspects of game development where Scala shines particularly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Game Logic correctness&lt;/strong&gt; is very important. There is nothing worse for a player than seeing their efforts wasted by a programming bug. Depending on the game, this logic can be particularly large and complex.&lt;/li&gt;
&lt;li&gt;Games are &lt;strong&gt;fast-evolving&lt;/strong&gt;, and players are always eager for new features. That means the game server codebase needs to constantly grow and evolve. Being able to bring features at a steady pace is primordial in order to stay on top.&lt;/li&gt;
&lt;li&gt;Designing a multi-player game means dealing with &lt;strong&gt;concurrency and scaling issues&lt;/strong&gt;. Game servers will receive a lot of concurrent requests, some of them dealing with the same entities.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;game-logic-correctness&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#game-logic-correctness&quot; aria-label=&quot;game logic correctness permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Game logic correctness&lt;/h2&gt;
&lt;h3 id=&quot;the-power-of-types&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#the-power-of-types&quot; aria-label=&quot;the power of types permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;The Power of types&lt;/h3&gt;
&lt;p&gt;Scala is a &lt;strong&gt;statically-typed&lt;/strong&gt; language, which means that types are defined and known at compile-time. This prevents a whole range of runtime errors common with dynamically-typed languages (such as Javascript’s &lt;code&gt;TypeError&lt;/code&gt;). But compared to other statically-typed languages, Scala uses types to express a lot of different things.&lt;/p&gt;
&lt;p&gt;A simple example of that is the type &lt;code&gt;Option[A]&lt;/code&gt; that is used to represent an optional value of type &lt;code&gt;A&lt;/code&gt;. Rather than returning &lt;code&gt;A&lt;/code&gt; that can be &lt;code&gt;null&lt;/code&gt; and add the proper null-handling logic everywhere at the risk of missing some places, the idiomatic Scala way is to return &lt;code&gt;Option[A]&lt;/code&gt;. That forces the developer to always handle the case where &lt;code&gt;A&lt;/code&gt; is missing, using pattern matching or functions such as &lt;code&gt;getOrElse&lt;/code&gt; or &lt;code&gt;fold&lt;/code&gt;.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// this returns Option[GnomeResearchLevel]&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; currentLevel &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; getGnomeResearchLevel&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;researchDataId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// we have to handle the case where there is no current level&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// in this case, we take the initial level&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; nextLevel &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; currentLevel&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;map&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;_&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;up&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;getOrElse&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;GnomeResearchLevel&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;initial&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;We push this idea even further by using &lt;strong&gt;refined&lt;/strong&gt; types to add constraints to the values types can have. For example, a “Kingdom Level” is an integer that can only be 1 or more. Instead of simply using &lt;code&gt;Int&lt;/code&gt;, we use type &lt;code&gt;PosInt&lt;/code&gt; that enforces that the value is positive. We know for sure that the value will never be 0, so we don’t need to handle that case or to be careful with things like division by 0.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;type&lt;/span&gt; PosInt &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt; Refined Positive &lt;span class=&quot;token comment&quot;&gt;// alias for a positive Int&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; a&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; PosInt &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; b&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; PosInt &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// will fail to compile&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The compiler is able to detect if static values are valid or not. For values coming from the client, an error will be raised immediately after parsing the client request if a value doesn’t respect the type constraint.&lt;/p&gt;
&lt;p&gt;Using &lt;code&gt;PosInt&lt;/code&gt; is great, but if a “Cookie Level” is also a &lt;code&gt;PosInt&lt;/code&gt;, there is a risk that we pass the wrong level to a function. To avoid that, we use a &lt;strong&gt;newtype&lt;/strong&gt;, which is basically a wrapper around our &lt;code&gt;PosInt&lt;/code&gt; to give it a special meaning.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token annotation punctuation&quot;&gt;@newtype&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; KingdomLevel&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;value&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; PosInt&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; receiveKingdomLevelRewards&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;level&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; KingdomLevel&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; List&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;Reward&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Now, the compiler will enforce that when we call &lt;code&gt;receiveKingdomLevelReward&lt;/code&gt;, we pass a &lt;code&gt;KindomLevel&lt;/code&gt; and nothing else.&lt;/p&gt;
&lt;p&gt;In summary, by using types that describe precisely what they hold, we avoid a whole range of silly bugs and improve the quality of our code.&lt;/p&gt;
&lt;h3 id=&quot;types-as-documentation&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#types-as-documentation&quot; aria-label=&quot;types as documentation permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Types as documentation&lt;/h3&gt;
&lt;p&gt;Just like returning an &lt;code&gt;Option&lt;/code&gt; tells you that a function may or may not return a result, we use types that describe the kind of computation that a function runs. Here are a few examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Option[A]&lt;/code&gt;: a function that may or may not return a result of type &lt;code&gt;A&lt;/code&gt;. It does not have any side effect.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Either[E, A]&lt;/code&gt;: a function that may either fail with an &lt;code&gt;E&lt;/code&gt; or succeed with an &lt;code&gt;A&lt;/code&gt;. It does not have any side effect.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ZIO[R, E, A]&lt;/code&gt;: a function that requires an environment &lt;code&gt;R&lt;/code&gt; and may either fail with an &lt;code&gt;E&lt;/code&gt; or succeed with an &lt;code&gt;A&lt;/code&gt;. It may have some side effects (such as I/O). It is equivalent to &lt;code&gt;R =&gt; Either[E, A]&lt;/code&gt; + the notion of side-effecting code.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ZPure[W, S1, S2, R, E, A]&lt;/code&gt;: a function that requires an environment &lt;code&gt;R&lt;/code&gt; and an initial state &lt;code&gt;S1&lt;/code&gt; and may either fail with an &lt;code&gt;E&lt;/code&gt; or succeed with an updated state &lt;code&gt;S2&lt;/code&gt; and an &lt;code&gt;A&lt;/code&gt; along with a log with entries of type &lt;code&gt;W&lt;/code&gt;. It does not have any side effect. It is equivalent to &lt;code&gt;(R, S1) =&gt; (List[W], Either[E, (S2, A)])&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We use these various types to reflect exactly what a function is doing. You may have noticed that there are a lot of type parameters. But in practice we use type aliases to make it much shorter and convenient. Here are a few examples.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// a function that increments a metric&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// the return type means it is side-effecting, and can never fail&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; incrementInFlightMessagesMetric&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; UIO&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;Unit&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// a function that converts protobuf data into kingdom level&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// the return type means that it may fail with a non-empty list of errors&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// EitherNel[E, A] = Either[NonEmptyList[E], A]&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; extractKingdomLevel&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;raw&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Protos&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; EitherNel&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;ExtractionError&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; KingdomLevel&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// a function that returns the current pvp (a.k.a. &quot;Kingdom Arena&quot; in the game) state of the user&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// the return type means it may access the user state but not modify it&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; getCurrentPvpState&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; KingdomReader&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;Pvp&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Not only do these types enforce the behavior of a function, but they give you a precise idea of what a function is doing just by reading its type signature. All possible errors are clearly declared in return types (as well as the absence of error, which is just as useful), so we are never surprised by an exception thrown unexpectedly. The same goes with side effects.&lt;/p&gt;
&lt;h3 id=&quot;make-your-own-dsl&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#make-your-own-dsl&quot; aria-label=&quot;make your own dsl permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Make your own DSL&lt;/h3&gt;
&lt;p&gt;We’ve seen a few examples where we use types to constrain what a value can be or what a function can do, and rely on the compiler to catch any programming mistake early on. We can go even further and make the whole code constrained to only allow “valid” operations. We do that by creating &lt;strong&gt;custom DSLs&lt;/strong&gt; for specific operations. Because Scala is both powerful and very concise, it excels at creating sub-languages. You can push it pretty far by making the code read almost like a spec document.&lt;/p&gt;
&lt;p&gt;Without going that far, creating a DSL is useful when you want to restrict some parts of your code to only a small subset of operations. Let’s see an example: in our game, we support transactions between multiple entities. That means we can send commands to two different users (or a user and a guild), and commit all changes only if both commands are handled successfully.&lt;/p&gt;
&lt;p&gt;We created a small DSL for transactions, with only the following operations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;identify an entity by ID: &lt;code&gt;kingdom(userId)&lt;/code&gt; , &lt;code&gt;guild(guildId)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;send a command to an entity: &lt;code&gt;entity ! command&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;combine sending operations: &lt;code&gt;&gt;&gt;&gt;&lt;/code&gt; for sequential, &lt;code&gt;&amp;#x26;&amp;#x26;&amp;#x26;&lt;/code&gt; for parallel&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We can then write simple transactions like this:&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;transaction &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; context &lt;span class=&quot;token keyword&quot;&gt;=&gt;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;kingdom&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;context&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;userId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;!&lt;/span&gt; SendFriendRequest&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;friendId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&amp;amp;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;kingdom&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;friendId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;!&lt;/span&gt; ReceiveFriendRequest&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;context&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;userId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;transaction&lt;/code&gt; block defines where our DSL can be used. We won’t be able to use “normal code” within the boundaries of this block, but only what our DSL allows. It gives us access to a &lt;code&gt;context&lt;/code&gt; that contains information about the current user. We will then send &lt;code&gt;SendFriendRequest&lt;/code&gt; to the current user (that will check that this user is allowed to send a friend request, and save the pending request), and &lt;code&gt;ReceiveFriendRequest&lt;/code&gt; to the other user (which will save the received request). These 2 operations will be done in parallel, and if any one of those 2 fail, nothing will be persisted.&lt;/p&gt;
&lt;p&gt;This little transaction system does not support other types of operations. If we tried to persist something to Redis or Kafka within the &lt;code&gt;transaction&lt;/code&gt; block, it wouldn’t be rollbacked in case of error. But because we use a custom DSL, it is actually &lt;strong&gt;impossible&lt;/strong&gt; to call that kind of code (we would get a compiler error), because we are restricted by our DSL operations.&lt;/p&gt;
&lt;p&gt;Most of our business logic is implemented using a DSL whose basic operations are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;succeed with a result&lt;/li&gt;
&lt;li&gt;fail with a &lt;code&gt;ValidationError&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;access the environment (that is a large object containing all the game configuration: the list of characters, levels, events, etc)&lt;/li&gt;
&lt;li&gt;access the user state&lt;/li&gt;
&lt;li&gt;lift an event (we use &lt;a href=&quot;https://www.eventstore.com/blog/what-is-event-sourcing&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;event sourcing&lt;/a&gt;), which will automatically update the user state&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Thanks to this limited set of operations, it is impossible to do something unexpected in the middle of our business logic.&lt;/p&gt;
&lt;h2 id=&quot;evolving-fast&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#evolving-fast&quot; aria-label=&quot;evolving fast permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Evolving fast&lt;/h2&gt;
&lt;h3 id=&quot;focus-on-whats-important&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#focus-on-whats-important&quot; aria-label=&quot;focus on whats important permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Focus on what’s important&lt;/h3&gt;
&lt;p&gt;As we’ve just seen, writing DSLs constrain our code to a smaller subset of valid operations. It has another huge benefit, combined with the conciseness of Scala: it makes the code super short and readable.&lt;/p&gt;
&lt;p&gt;Creating a DSL is a good way to &lt;strong&gt;hide the complexity&lt;/strong&gt; of our program to focus on what really matters which is the business logic. Considering our transaction example above, it actually involves complex low-level code. We need to locate and send messages to entities that may be hosted on different nodes, using a particular messaging protocol. The persistence layer also needs to combine all writing operations into a single transaction that can be committed or rollbacked, communicating with an actual database. But we don’t want to care about any of this when we write our business logic.&lt;/p&gt;
&lt;p&gt;The general idea behind this is to separate the “technical” layers from the business logic so that both of them can be implemented and tested entirely separately. That allows implementing business logic, which represents the majority of the work of adding new features, without any kind of interference, which is much more efficient.&lt;/p&gt;
&lt;p&gt;Here’s a simplified example of business logic function used when a user joins a guild: we get some metadata from the game configuration, get the guild state and check that the request is valid. On success, we lift an event to record the fact that this user joined the guild. Note how we don’t care about messaging protocols, persistence, transactions, locks or whatever technical challenges that actually exist under the hood.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; joinGuild&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; KingdomProgram&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;Unit&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
	metadata    &lt;span class=&quot;token keyword&quot;&gt;&amp;lt;-&lt;/span&gt; inquireGuildMetadata
    requesterId &lt;span class=&quot;token keyword&quot;&gt;&amp;lt;-&lt;/span&gt; inquireRequesterId
    memberCount &lt;span class=&quot;token keyword&quot;&gt;&amp;lt;-&lt;/span&gt; getGuild&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;_&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;members&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;size&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;          &lt;span class=&quot;token keyword&quot;&gt;&amp;lt;-&lt;/span&gt; assertThat&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
                     memberCount &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; metadata&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;maxMemberCount&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
                     ValidationError&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;GuildFull
                   &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;          &lt;span class=&quot;token keyword&quot;&gt;&amp;lt;-&lt;/span&gt; liftEvent&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;GuildMemberJoined&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;requesterId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;An interesting consequence of this is that it’s easy to onboard new developers to our team: because there are only a few operations available, they will be able to understand the DSL and start writing code easily, without necessarily understanding how the DSL is implemented. They can focus on learning about the domain rather than technical concepts.&lt;/p&gt;
&lt;h3 id=&quot;death-to-boilerplate&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#death-to-boilerplate&quot; aria-label=&quot;death to boilerplate permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Death to boilerplate!&lt;/h3&gt;
&lt;p&gt;Implementing new features usually means writing a lot of code: exposing new APIs to the client, implementing the persistence layer, converting data types between different layers, etc. One thing Scala is great at is keeping that boilerplate to a minimum, thanks to a powerful &lt;strong&gt;macro engine&lt;/strong&gt; and the ability to write &lt;strong&gt;compiler plugins&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;As it is commonly done in many languages, we generate server code from protobuf file definitions. However we don’t use the generated code in our business logic, because this generated code doesn’t use our refined types and we sometimes use more convenient data types than the ones generated (collections are usually an area where the choice of data type is quite significant). It means that we need to convert the proto-generated objects that we receive from the client into our own. We are talking about thousands of objects, which could become a huge source of boilerplate. However, thanks to Scala macros, these transformations are done automatically for the most part, and we can verify it at &lt;strong&gt;compile-time&lt;/strong&gt;!&lt;/p&gt;
&lt;p&gt;All we have to do is make sure we have an instance of &lt;code&gt;Transformer&lt;/code&gt; for each data type. A macro is used to generate a &lt;code&gt;Transformer&lt;/code&gt; automatically between 2 classes, as long as their field names are matching and that we have a &lt;code&gt;Transformer&lt;/code&gt; between each corresponding field types.&lt;/p&gt;
&lt;p&gt;Let’s see an example. We have a protobuf type defined like this:&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;protobuf&quot;&gt;&lt;pre class=&quot;language-protobuf&quot;&gt;&lt;code class=&quot;language-protobuf&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;message&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;CraftProduction&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt; id &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;repeated&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;int32&lt;/span&gt; cookie_data_ids &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token builtin&quot;&gt;int32&lt;/span&gt; completed_job_count &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The generated code will look like this:&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; CraftProduction&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
  id&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  cookieDataIds&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Seq&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  completedJobCount&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;But our own class with “enhanced” types is more like this:&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; CraftProduction&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
  id&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; ActivityId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  participants&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; NonEmptyList&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;CookieDataId&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  completedJobCount&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;All IDs use more specific types such as &lt;code&gt;ActivityId&lt;/code&gt; or &lt;code&gt;CookieDataId&lt;/code&gt; so we don’t mix them up, we also use a &lt;code&gt;NonEmptyList&lt;/code&gt; because there must be at least one participant, and finally the &lt;code&gt;cookieDataIds&lt;/code&gt; field is named differently because it makes more sense in the game server code.&lt;/p&gt;
&lt;p&gt;We’ll need transformer for the following types:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;Transformer&lt;/code&gt; between &lt;code&gt;String&lt;/code&gt; and &lt;code&gt;ActivityId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;Transformer&lt;/code&gt; between &lt;code&gt;Int&lt;/code&gt; and &lt;code&gt;CookieDataId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;Transformer&lt;/code&gt; between &lt;code&gt;Seq&lt;/code&gt; and &lt;code&gt;NonEmptyList&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Transformer for IDs are very simple and can be created in a generic way since IDs are just wrapper types. A &lt;code&gt;Transformer&lt;/code&gt; between &lt;code&gt;Seq&lt;/code&gt; and &lt;code&gt;NonEmptyList&lt;/code&gt; is also simple, failing if the &lt;code&gt;Seq&lt;/code&gt; is empty, otherwise wrapping it into a &lt;code&gt;NonEmptyList&lt;/code&gt;. The last problem we have is that the field &lt;code&gt;cookieDataIds&lt;/code&gt; was renamed into &lt;code&gt;participants&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In this case, we can create a transformer like this:&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;implicit&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; transformer &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;
  Transformer
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;define&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;entities&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;CraftProduction&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; protobuf&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;CraftProduction&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;withFieldRenamed&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;_&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;participants&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; _&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;cookieDataIds&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;buildTransformer&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The macro is able to convert all fields automatically, and since we defined a mapping between the &lt;code&gt;participants&lt;/code&gt; field and the &lt;code&gt;cookieDataIds&lt;/code&gt; field, it is able to generate a transformer. Most of the time, fields are named the same way so we don’t need to write this kind of code at all. The great thing about this is that if anything is missing, we will get a compile error telling us which field couldn’t be transformed and why. We can be sure that if the code compiles, it will work as expected.&lt;/p&gt;
&lt;p&gt;Another example of relying on macros and code generation to cut down the boilerplate is our usage of GraphQL. Macros allow us to generate a GraphQL schema from our server data classes automatically, and a compiler plugin automatically generates client code powered by &lt;a href=&quot;https://www.scala-js.org/&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;Scala.js&lt;/a&gt; (Scala code that is converted into JavaScript). We get both a client and a server from the same code with almost no effort!&lt;/p&gt;
&lt;p&gt;Most of the macros and compiler plugins mentioned here were not even developed by us: they are available in open source libraries. The Scala OSS ecosystem is very dynamic and offers a lot of options for cutting down the boilerplate.&lt;/p&gt;
&lt;h3 id=&quot;safe-refactoring&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#safe-refactoring&quot; aria-label=&quot;safe refactoring permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Safe refactoring&lt;/h3&gt;
&lt;p&gt;An important concept in the world of functional programming with Scala is &lt;strong&gt;referential transparency&lt;/strong&gt;. A function that is referentially transparent means that we can replace that function with its value without changing the behavior of the program.&lt;/p&gt;
&lt;p&gt;Let’s see an example.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; add&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;a&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; b&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; a &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; b

&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; sum &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; add&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Everywhere in our code, we can use &lt;code&gt;sum&lt;/code&gt; or &lt;code&gt;add(1, 2)&lt;/code&gt; interchangeably. It won’t change anything in the code whether we use one or the other.&lt;/p&gt;
&lt;p&gt;On the other hand:&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;object&lt;/span&gt; Test &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt; total &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; add&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;a&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Int&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
	total &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; total &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; a
	total
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; sum &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Test&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;add&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Using &lt;code&gt;sum&lt;/code&gt; or &lt;code&gt;Test.add(2)&lt;/code&gt; will lead to totally different behavior: calling &lt;code&gt;sum&lt;/code&gt; will always return the same result, however calling &lt;code&gt;Test.add(2)&lt;/code&gt; will trigger another modification of &lt;code&gt;total&lt;/code&gt; and return a different result each time. In this case we can say that &lt;code&gt;add&lt;/code&gt; is NOT referentially transparent.&lt;/p&gt;
&lt;p&gt;Why does this matter? If we are able to replace pieces of code by functions interchangeably, it means we can do heavy refactorings without risking to break the code’s behavior. In addition to that, static typing will ensure that moving code around always make sense and the compiler will tell us if types don’t match. The ease of refactoring means that our code will be easy to maintain and extend in the feature, allowing it to grow and evolve together with our feature set.&lt;/p&gt;
&lt;p&gt;Referential transparency is not specific to Scala, but this style of programming where all functions are referentially transparent and use immutable data types is heavily encouraged and followed within the Scala community and its library ecosystem.&lt;/p&gt;
&lt;h2 id=&quot;multi-player-challenges&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#multi-player-challenges&quot; aria-label=&quot;multi player challenges permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Multi-player challenges&lt;/h2&gt;
&lt;h3 id=&quot;local-concurrency&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#local-concurrency&quot; aria-label=&quot;local concurrency permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Local concurrency&lt;/h3&gt;
&lt;p&gt;When building a game server for a multiplayer game, we usually need multiple concurrent requests to access the same data. We sometimes need to run tasks concurrently and gather the results. We also need to run tasks in the background for the whole lifecycle of our application. The list goes on. We need to be able to perform a lot of actions &lt;strong&gt;concurrently without exhausting the resources&lt;/strong&gt; that we have.&lt;/p&gt;
&lt;p&gt;Scala comes with the concept of &lt;code&gt;Future&lt;/code&gt; but there are even better alternatives offered by libraries such as ZIO (which is what we use), Cats Effect or Monix. They all have in common to be built on top of &lt;strong&gt;fibers&lt;/strong&gt;, which are lightweight “green threads” managed by a runtime system. Unlike traditional threads, they are very cheap to create and make concurrent programming super simple.&lt;/p&gt;
&lt;p&gt;These libraries provide a bunch of operators to let you run computations in parallel: you can &lt;code&gt;fork&lt;/code&gt; a computation to run it in a different fiber, &lt;code&gt;join&lt;/code&gt; a fiber to wait for its result, &lt;code&gt;interrupt&lt;/code&gt; a fiber, &lt;code&gt;race&lt;/code&gt; multiple fibers, etc. Most of the time, you don’t even need to manipulate them as you can simply use high level operators such as &lt;code&gt;ZIO.foreachPar&lt;/code&gt; or &lt;code&gt;ZIO#zipPar&lt;/code&gt;. There are many convenient data types built on top of fibers such as &lt;code&gt;Promise&lt;/code&gt;, &lt;code&gt;Schedule&lt;/code&gt; or &lt;code&gt;Queue&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Let’s see a simple example. Whenever a player open our game client, a gRPC stream is opened to the server and maintained for as long as the user uses the game. While this stream is alive, we want to regularly update the “last connection date” of the user in the database.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;updateLastConnectionDate&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;userId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;repeat&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;Schedule&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;spaced&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; hour&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;fork&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That’s it. Running this code means that the &lt;code&gt;updateLastConnectionDate&lt;/code&gt; function will be called every one hour. Because we use &lt;code&gt;fork&lt;/code&gt;, the function will immediately return and run in the background. By default, a fiber is terminated when its parent fiber is terminated, so that background process will automatically be stopped when our gRPC stream is disconnected. If we wanted to keep the fiber running even after the termination of its parent, we could have used &lt;code&gt;.forkDaemon&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Another super useful data type from ZIO is the &lt;code&gt;Hub&lt;/code&gt;. Unlike a &lt;code&gt;Queue&lt;/code&gt; where each item is given to a single consumer, items that are published to a Hub are broadcasted to all subscribers. Let’s imagine that we have global messages that we want to send to all connected users through that gRPC stream we just talked about. On each game server, we can start one fiber that consumes messages from Kafka (where global messages come from), and publishes each received messages to a hub. Then, each connected gRPC stream can simply subscribe to this hub and it will receive all the messages.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; createHub&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;consumer&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Consumer&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; UIO&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;Hub&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;Message&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;
	Hub&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;unbounded&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;Message&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;flatMap &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; hub &lt;span class=&quot;token keyword&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// create a hub with no max size&lt;/span&gt;
    consumer
     &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;read                                &lt;span class=&quot;token comment&quot;&gt;// read from Kafka&lt;/span&gt;
     &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;mapZIO&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;event &lt;span class=&quot;token keyword&quot;&gt;=&gt;&lt;/span&gt; hub&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;publish&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;event&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// publish all messages to the hub&lt;/span&gt;
     &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;runDrain                            &lt;span class=&quot;token comment&quot;&gt;// consume the stream to the end&lt;/span&gt;
     &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;fork                                &lt;span class=&quot;token comment&quot;&gt;// do this in a separate fiber&lt;/span&gt;
	 &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;as&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;hub&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;                             &lt;span class=&quot;token comment&quot;&gt;// return the hub&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// create a stream that will contain all messages published to the hub&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; stream &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; ZStream&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;fromHub&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;hub&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;At the end, our gRPC stream is just the combination of various streams coming from different sources like this one and merged together. This &lt;code&gt;Hub&lt;/code&gt; data type is so convenient that we created our own variant, &lt;code&gt;IndexedPubSub&lt;/code&gt;, that allows subscribing to particular keys only. Thanks to &lt;code&gt;Hub&lt;/code&gt; but also &lt;code&gt;Ref&lt;/code&gt;, &lt;code&gt;Promise&lt;/code&gt;, &lt;code&gt;Queue&lt;/code&gt; and a few others, local concurrency problems become really trivial with ZIO.&lt;/p&gt;
&lt;h3 id=&quot;distributed-concurrency&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#distributed-concurrency&quot; aria-label=&quot;distributed concurrency permalink&quot; class=&quot;anchor before&quot;&gt;&lt;span&gt;#&lt;/span&gt;&lt;/a&gt;Distributed concurrency&lt;/h3&gt;
&lt;p&gt;In a real application, concurrency problems are often not only local but also distributed. We want to able to scale out our game servers by simply adding more nodes. Having multiple nodes causes some problems: for example, if a guild only has 1 spot left but 2 players send a request to join that guild at the exact same time, only the first one should succeed and the other should fail.&lt;/p&gt;
&lt;p&gt;A typical way to solve that is the &lt;strong&gt;single writer principle&lt;/strong&gt;: at any given time, there should be only a single instance of each entity that can modify its state. In other words, there should be a single process handling messages for any given guild at any time. That way, this process will handle the 2 messages sequentially, succeeding the first one and failing the second.&lt;/p&gt;
&lt;p&gt;There are several ways to implement this, and the Scala ecosystem is rich with options. One of them is using Kafka consumer groups to ensure only a single consumer handles all messages of a given entity ID. In our case, because of the “synchronous” nature of our workflows (the client expects an instant response for every request), we went another route and use the &lt;strong&gt;actor sharding&lt;/strong&gt; pattern. This pattern was popularized by the &lt;a href=&quot;https://akka.io/&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;Akka&lt;/a&gt; library which is ideal for getting started (we are now using &lt;a href=&quot;https://github.com/devsisters/shardcake&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;our own custom implementation&lt;/a&gt; that follows similar principles).&lt;/p&gt;
&lt;p&gt;An actor is basically a tiny process that consumes messages sequentially from a queue (called “mailbox”). Sharding means that actors are distributed across a group of nodes and that you can send messages to an actor just by knowing its ID. This concept is known as &lt;strong&gt;location transparency&lt;/strong&gt;, meaning that you can send messages to an actor without knowing where the actor is actually located (it might be on the same node or another one).&lt;/p&gt;
&lt;p&gt;Following the DSL introduced earlier to send message, 2 different nodes can call the following code concurrently:&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;scala&quot;&gt;&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;guild&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;guildId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;!&lt;/span&gt; JoinGuild&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;inviteId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Under the hood, the sharding system will find on which node the actor in charge of &lt;code&gt;guildId&lt;/code&gt; is located, and send a request to that node containing the payload (&lt;code&gt;JoinGuild&lt;/code&gt;). That node will forward those messages to the local actor handling this guild ID. The actor will handle the message that arrived first and respond with a success. It will then handle the second message and respond with a failure.&lt;/p&gt;
&lt;p&gt;Thanks to a few powerful libraries, Scala makes it really easy to handle distributed and concurrent payloads without resorting to things like distributed locks that might clog response times and create issues like deadlocks. The actor pattern, whether it is implemented with Akka or ZIO, makes our life easier once again.&lt;/p&gt;
&lt;p&gt;Through a handful of practical and simple examples, we’ve seen that Scala is a really interesting choice for building a game server. Combining its powerful type system with the best practices of functional programming, we are able to minimize the risk of errors and focus entirely on the business logic. Thanks to Scala’s rich ecosystem of open source libraries, we can cut down the boilerplate and resolve all kinds of concurrency problems.&lt;/p&gt;
&lt;p&gt;Sounds attractive to you? Want to know more? &lt;a href=&quot;https://www.notion.so/88bcb5f7325c4610a5e728b6b899e9ec&quot; target=&quot;_blank&quot; rel=&quot;nofollow noopener&quot;&gt;We are hiring&lt;/a&gt;!&lt;/p&gt;
&lt;we-are-hiring /&gt;</content:encoded></item></channel></rss>