TimescaleDB Course – PostgreSQL for Time-Series Data

#TimescaleDB #PostgreSQL #Time-Series Data #Hypertables #Columnar Storage #Continuous Aggregates #Hyperfunctions #Data Retention #Tiered Storage #AI Agent Telemetry #IoT Fleet Management #Vector Search
💬 Chat with this Video
Ask anything about this video…
two databases on the same laptop, each with 10 million request records. We're asking both the same question. How many requests arrived each hour and what was their average response time? On the left, PostgreSQL calculates the answer from the individual requests. On the right, time scale DB reads an hourly summary we've prepared ahead of time. So, let's run them. We can see the raw query took 1.6 6 seconds or 1,682 milliseconds. And the right query only took 10 milliseconds. And if we check, they both return the same answers. The difference is the amount of work. One query reads individual events. The other reads results we already calculated. Building that summary took time, too. And ordinary PostgreSQL can store summaries. What time scale DB adds is a way to keep timebased summaries updated without rebuilding the entire history every time. That's one of the things we're going to build in this course. Hello, I'm Bo KS and in this course I'll teach you how to use time scale DB to work with time series data in PostgreSQL. We'll look at how to organize growing tables, reduce storage, and keep dashboard queries responsive. We'll also cover the constraints and settings you need to understand before using this in production. Tiger Data provided a grant to make this course possible. They're the company behind Timecale DB. They have a fully managed cloud platform called Tigercloud. It's essentially enterprisegrade PostgreSQL with Timecale DB built in plus cloudnative features like automatic data tiering to cheap S3 storage. one-click database forking and a visual dashboard built specifically for time series operations. We're going to use Tigercloud later in the course for the second project. But for the first part of this course, we're going to start completely local inside Docker from scratch. I want you to understand exactly how this engine works under the hood before we move to the cloud. You can keep using familiar Postgregql tools and SQL. Some table designs and queries need changes and we'll work through those as we go. So what is time series data? Time series data is data you analyze in relation to time. You might ask what happened in the last hour, compare this with last week or calculate response times throughout the day. That includes HTTP requests, financial prices, sensor readings, application logs, and the model and tool calls made to an AI agent. These workloads often add new records continuously. Much of the data stays unchanged and the questions usually concern a particular time range. PostgreSQL can handle large data sets, but as an event table grows, a few costs become more noticeable. Large active indexes can create more disk work. Deleting old rows creates cleanup work. And dashboards can repeatedly scan the same historical events to calculate the same totals. PostgreSQL already gives us tools for some of this including partitioning. Time scale DB adds automatic chunk management, column oriented storage, and continuous aggregates. We'll see where each helps and what it costs to maintain. In that first demo, we focused on continuous aggregates, storing hourly results so the dashboard doesn't calculate them from scratch. The initial calculation happen before we start the timer. Later, we'll look at refreshing those summaries when new data arrives or other records change. We'll also examine storage and query plans so we can explain the results. The runtime results you saw are the results from this laptop and this data set. They're not a promise for every query. Your schema, hardware, filters, and data distribution all matter. We'll measure as we go, and you can repeat the examples on your own machine. So, we'll apply this to two projects. The first is is an AI agent flight recorder. It records model and tool calls with their duration, cost, and outcome. We'll use it to answer questions like which agent is spinning the most, which tool has the slowest calls, when did the error rate change, and when something goes wrong, we can inspect the recorded steps in a particular run. This project runs locally in Docker and a a simulator supplies data without requiring a paid model API. The second project is EV charger fleet telemetry. We'll model 2,000 chargers, each reporting every 10 seconds with one row per report. That's 200 rows per second and about 17 million rows per day. We'll use Tigercloud for this project to explore managed operations and moving older data into into object storage. That event rate doesn't require cloud hosting it by itself. The point is to see how the operational and storage choices work. Chapters are in the description if you want to jump to a particular topic. Let's start with the PostgresQL concepts we'll need. So the single most important thing to understand about time scale DB is that it's just Postgress. It's not it's it's not a fork of PostgresQL. It's not a read what write. It's just an extension. The same mechanism that gives you postJIS for geospatial data or PG vector for embeddings. So that means that you already everything you know about Postgress still applies and everything in your stack still works. So let me demonstrate. I have Postgress running in the terminal here and I'm going to run this command that will show that uh we're seeing that time scale DB is sitting in the same extension list next to PostgresQL which ships with every Postgress install on Earth. So it's super easy to install just create extension if not exist time scale DB. I already have it existing on this machine, but this one line is the entire installation. Once the binary is on the machine uh per database, that's the part people miss. If you create a new database, you run that again. The practical consequence is that your Django or Rails or Prisma migrations work. Your connection pooler works, PG dump works, your monitoring works, your team doesn't have to learn a new query language because there isn't one. It's just SQL. Okay, we're going to use some example data. So, I just want to talk about what's in the example data data so you can understand. This matters more than it sounds like it does. Every query in this course runs against one table. And if you don't know what's in it, the SQL is just noise. So, here's the scenario. Imagine you run a service that sits in front of a lot of websites like a CDN, a hosting platform, an API gateway. Every time somebody's browser asks for a page, that request passes through your service and you write down a line about it. Who asked, what they asked for, how long it took, whether it worked. That's it. That's the data. Just one row per per HTTP request. Millions of them. If you've ever looked at a web server log file, this is that in a data center table instead of a text file. I mean, a data database table instead of a text file. So, let me show you the shape of it. So here's the table and I did just slashd requests. Uh slashd is a postgrql command not SQL. It's a shortcut the postsql client gives you and it means describe this table. You get every type column and its type. So there's a lot of columns here. So let me just group them because they fall into five buckets. Uh the time is when it happened, the time stamp of the request and this is the most important column in the whole course. We're going to do a lot of sorts, filters and groups by this column. And then we have some about who asks for what like the server ID, the URL domain, session ID, and then we have stuff about what happened, the status. This is the HTTP status code. Then how slow it was. It's all about the duration here. and then everything else like where the request came from, what browser, what operating system, how many bytes moved. So this is pretty abstract. Let me show you one actual row. So first I'll do slash x on that's going to flip SQL into expanded mode which prints one column per line instead of one row per line for a table this wide. That's the difference between readable and unreadable. And then we'll do select everything from request limit one. Let me just zoom out so we can see everything on one screen here. So you can see we have the time, URL, domain, status, duration, total. And those are going to be some of the most important ones. So there's one request. Somebody in some city asked for some page and it returned a status and it took some numbers some number of milliseconds. Every row is that there are millions of them. So, let me just do /x off to turn off extended display. And then I'm going to do a query that's going to tell us how many rows and what time period do they cover. So, this is the number of rows and here's the time period here. So, that's your data set and the the newest time stamp. We can see the data ends at roughly the moment um uh we loaded the data. that matters for the next few queries because when I say the last 24 hours, I mean the last 24 hours of this data, which is the day leading up to when we ran the generator. Now, let's start going over SQL. I'm going to talk to you about the 20% of SQL you need, which is basically all you'll need for this course. I'm going to show you four things. If all four are familiar, part one is a refresher and you can move fast. If any are new, this is the floor. Basically everything later in the course is built out of these. So here's some basic SQL code that we're going to cover and I'm going to be running it on PostgreSQL right over here. Every query in this course starts by narrowing to a time range. So let's get that out of the way first. So I'm going to select max time from request. This is the max time and the time range. I'm going to save that time, that value, and reuse it because I want every query in this section to point at the busy end of the data rather than at wall clock time. So, I'm going to run this command right here. Select max time and we can see right here slashg set at the end. This is a postql trick. It takes the result of that query and stores it in a variable called newest. So I can write colon newest in later queries instead of typing a timestamp. So here's the filter we're going to use. So first select count. We're going to count the rows where the time is at or after 24 hours before the nearest row. So we're going to from the request where the time is at or after and we have interval 24 hours. So 24 hours after the newest row. The thing to notice here is that postgrql has real date arithmetic. So this interval 24 hours or you can look at the colorful one right over here. This is a genuine value with a type and you can subtract it from a timestamp and get a time stamp like interval. You could use interval 7 days, interval 15 minutes, interval 1 month. These all work. You'll see this constantly in every part of this course. So I'm just going to run this command and we can see how many how many uh how many rows were within the last 24 hours. Now let's talk about aggregation and grouping. So basically I'm going to be using this code here. I'm going to copy this and paste it right over in our post P post SQL PostgresQL there. There's the full thing here. So let's read this one clause at a time because the order Postgress does the work is not the order you read it. So here we have from requests. We're going to start with this table, the request table. And then we have the where where then we have basically this means to throw everything outside, throw away everything outside the last 24 hours. And then we're going to group by status, which is pretty important. Take the surviving rows and sort them into piles. One pile per distinct status code. Like all the 200s in one pile, all the 404s in another. Then we have up here we're going to get the status and we're going to get the count. So the count with the asterric and the average duration total they run once per pile not once per row. That's the mental shift. So this count everything is how many rows in this pile and then the average duration total is the average duration of the rows in this pile. Then order by request de or descending sorts output biggest pile first and this as requests up here just renames the column to the out to so the output is readable that's all as ever does okay I will submit this command here and we can see what we get here so mostly 200s so we have the 200 we have all these different status codes here and we can see it's you can see it's mostly 200s like I said um that's what you want. There are some 404s and there's some 500s. Those are the ones that mean something is broken and finding them fast is basically the plot of this entire course. Now we're going to talk about this filter section here. So filter is for counting different things at once. I have the code already over here in the in the PostgreSQL. So here's a here's a real question. What's my error rate? To answer it, you need two numbers from the same row. The total and how many of them failed? You could run two queries, but don't. Postcrest has filter. So we have this filter wear. So this attaches a condition conditions to a single aggregate. The wear at the bottom which is right here applies to the entire query. But this filter up here only applies to one count it's attached to. So count filter where status is more than or equal to 500 means count the rows but only the ones that that were server errors. So four different counts one pass over the data. If you've been writing some case when then one l0 end and a lot of people have myself included this is the same thing faster to read and faster to run. So if we run that we can see server errors not found and okay here we can also easily turn it into a percentage and get an error rate. So here we're um basically running a calculation to turn it into a percentage and calling it air rate percentage. And then we see we have a 25% air rate percentage. So here we have this last thing we're talking about is percentiles. So here what you can see we're using percentile count and percentile count 0.5 n5.99 and I'll just submit this. So we can compare the average milliseconds to the average P99, P95 and percent. These are percentiles percentile 50. The P99 should be several times larger um which it is. So look at the gap between the average and P99. Same request, wildly different story. The average is the number that makes you feel good, but the P99 is the number your angriest users is living in. The syntax is admittedly odd where it says percentile count 0.95. This says which percentile you want. Then within group, then we're going to order by duration total. This says which column to compute it over. Postgress has to put the values in order to find the 95% mark. That's what you're telling it to sort by. And remember this, it's exact, which means it sorts every single value to find that mark. On a few thousand rows, fine. But on a 100 million, that's a problem. And in the next part, we replace it with something that gives you a nearly identical answer without the sort. So another thing is types. There are three of them that matter here. The type of timestamp TZ. Always use this. Never plain time stamp. The TC TZ is time zone. So time stamp TZ stores an absolute moment and converts it to the reader time zone on the way out. Plain's time stamp stores a wall clock reading with no idea where it was taken. Our time column is timestamp TZ and every hyper table in the next part is partitioned on a column like it. The JSON B which is binary JSON uh this can be indexed for the longtail attributes that don't deserve their own column and UU ID. Uh it's a 128 bit identifier. The long hyphenated strings and request ID are the are UU ID. It looks like a boring choice right now, but in the indexing section coming up, it's going to be pretty important. And there's uh important, I guess we could say, gotcha uh waiting there that we'll talk about later. So these things that we just covered are basically the 20% of SQL that you'll need. Everything is kind of built on these interval, group by, filter, percentile count. We're going to need to know all these things as the course progresses. Okay, I want to tell you about another command called explain. So if you take one skill from this section, make it this one. Almost every claim time scale DB makes is verifiable with explain. And almost every performance mystery is solved by reading it. So let's actually learn to read it, not just glance at it. So this is the the the plain version which doesn't run the query. just so it shows the plan. So I'm going to put explain and then select count from request where and then just basically where the time is in the last day here from the the end of the results. So we can now see the query plan. So this is the plan of what it's going to do. So it hasn't actually run the command. It's just what the what the command is going to do. What the select command is going to do. So So first it's going to filter. Then it's going to do the parallel seek scan on request partial aggregate and gather. Basically, it's going to start from the bottom and go up. And that's what it's going to do here. And there are two numbers per line. Uh we have you can see this line here cost. Cost is an arbitrary unit. It's not milliseconds. It's not anything physical. It's only meaningful for comparing two plans of the same query. And then rows here is the planner's estimate, which is where the interesting failure lives. Now I'm going to run a similar command, but we're going to do explain analyze buffers. So analyze actually executes the query and reports what really happened. Buffers reports how much data it had to touch. That second one is the one nobody uses and really everyone should use it. So you can see it hit four. The shared hit equals four. And we can see that the true information workers plan workers launched. It's basically reporting what actually happened when it run ran the command. So when it says sik scan here that means a a sequential scan. So let me talk about a sequential scan versus an index scan. A se a sequential scan reads the entire table. That's not automatically bad. If you're aggregating most of the table, it's the right choice and it's faster than an index scan would be. It's bad when you wanted a handful of rows and got a full read. And here we can see for every line you can see basically we it's showing here the actual time and the actual rows. So if the plan get a 100 rows and it got a million every decision downstream is built on a lie and you'll get a bad plan. That usually means stale statistics. So you should run analyze on the table. And then here's buffers. We see that it got shared hit. This means the data was already in memory. Shared read means it came off the disk. Disk reads are the thing that actually cost you wall clock time. And this is the number we'll watch drop dramatically when we get to reordering and column store. And then you sometimes you want to look at the planning time versus execution time. If they disagree wildly, the planner is confused about something. So let me show you something else. basically the pathology because it's more instructive than the happy plan. So here we're selecting everything from request where URL path equals SLC checkout. So there's no index on URL path. So we have a sequential scan and a huge uh shared red count and the execution time is dominated by pulling pages off discs that we then pull away. So I'm going to just compare that to another command here. We are going to create we're going to create an index on the requests. I mean an index on the URL path and then we're going to run the same command again where the URL path equals/checkout. And now we can see we have an index scan. So if we see the the shared if we go up here and see the shared red we have 106,533 in the buffers here we have 3001. So you see that's a very that's basically an order of magnitude lower. So that's what we want. We want to touch fewer pages and it'll make things run a lot quicker. But now I'm going to run the drop index command because I I want the slow version back for later. Now one here's a one habit worth building. I'm going to run slashtiming on. This will turn on timing. Now every request reports its wall time. It's the uh psqlrc file in the repo along with a couple other settings I use. I mean it's in in that file if if you check the repo on GitHub. This is the section that explains most of time scale DB's design. So stay with me even if it feels low-level. Postgrql does not read rows from disk. It reads pages. And a page is 8 kilobytes. Always every read, every write, every index lookup in units of 8 kilobytes. So if your rows are say a 100 bytes, you get somewhere around 50 to 100 rows in a page depending on overhead. And when you ask for one row, the database reads all 8 kilobytes and hands you the one you wanted. That has a consequence that becomes the entire argument for the column store later. If the rows you need are spread across many pages, you pay for every page. 10 rows in one page is one read. 10 rows in 10 different pages is 10 reads. Same 10 rows. Hold on to that. It comes back in about uh in just later in this course and it's the key to two different features. Now the second half of this which is about rights. Postgress uses MVCC multi- version concurrency control. The short version, when you update a row, Postgress does not overwrite it. It writes a new version of the row and marks the old one as dead. When you delete a row, it doesn't remove anything. It just marks the row dead. Why? So that other transactions already reading that row don't have the ground move under them. It's a genuinely good design and it's why Postgress readers never block writers. But it means dead rows accumulate. They still occupy pages. Your table gets bigger even as you delete from it. And then the command vacuum has to come along and clean up. So let's make that concrete because it's easier to understand once you see it. So, I'm just going to create a new table called dead row demo and just uh generate some items for the row. And let's look at the size. It's 72 megabytes because so we generated it and then we uh figure out the size of the the table. Okay. So now I'm going to delete a lot from the table and then we'll get the size again. You can see the size is the same. We deleted 80% of the rows and the table did not get smaller by a single bite. So now I'm going to run another command. Select inlive tup and in dead tup from the table from the dead row demo. Now I'm going to run vacuum full and we can see now the size has gone down. So vacuum full took an exclusive lock on the table. Uh nothing could read or write it while that ran. On a 500,000 row toy table that's instant. On a 200 million row production table it can take a long time. So here's a trap that catches everyone. You've got a huge events table. You decide to keep 90 days of days of data. So you write a nightly cron job like delete from events where time uh in the last 90 days. Basically that job creates millions of dead rows every night. Vacuum has to chase them. Your table doesn't shrink. Your indexes get bloated and fragmented. The delete itself takes longer every night as the table grows. and it competes for I/IO with your actual traffic. The time scale DB answer to this and I'm pre previewing it because it's such a clean win is that dropping old data becomes deleting a file. Not deleting rows, removing an entire chunk of the table as a unit. Milliseconds, no dead rows, no vacuum pressure, no bloat. That's the retention section that we're going to talk about later. Now an object an object an objection Postgress already has partitioning built in. Since version 10, you can split a table into pieces by range. So why do we need an extension? Well, let's build it by hand and find out. This is the most useful five minutes here because in this section because once you've done it manually, you understand exactly what time scale DB is automating. Okay, I'm going to create this table events native. And the table can't hold anything yet. It's a router with no destinations. And we have these three fields, time, device, and value. But if we try to insert into something, it it's not going to work. We can see that there is no partition of relation needed found for now. So I have to create the partition first. So this is how I'll create the partition. I'm going to create a partition. I'll do create table and then this is going to be the name partition of events native for these values and then a partition of events native for these other values. Okay, we've created those tables. Now the insert should work. Yep, we insert it into the table. And this genuinely does buy you something real. The planner can skip partitions entirely. So I'm going to run explain and check out the plan. So we can see the plan that it's that it has here. One partition in the plan. And you can see we got this partition here. Um, the August table isn't even considered the other partition. That's partition pruning and it's the same core idea time scale DB uses. So, what's missing? Well, there's four things that native partitioning doesn't do. One, you have to create every partition yourself before the data arrives. Forget one and your inserts start failing in production at midnight on the first of the month. People solve this with cron jobs or pgartman and it's just one more thing that can break. Two, no automatic sizing. You pick the boundaries. Get them wrong and you either have 4,000 tiny partitions which makes planning slow because the planner considers all of them or three enormous ones which defeats the purpose. Three, no life cycle management. There's no built-in compressed partitions older than a week or drop partitions older than a year. You write that yourself. Four, and this is the big one, no time series features. Partitioning is storage organization. It doesn't give you the incrementally updating aggregates or column nar expression or compression or gap filling or percentile sketches. Those are the features that actually make analytics fast and they're the bulk of the next section. So a hypert is a partitioned table. Time scale DB does not replace that machinery. It builds on it. What you're paying what you're paying the extension for is the automation and the analytics layer on top. Okay, that table is just an uh example for now. So we're going to drop that table. The last piece of foundation and it sets up the single best feature in uh time scale DB. Your dashboard query is slow. So the obvious fix is don't compute every time. Compute it once, store the answer, read the stored answer. Everybody's first instinct and it's a good instinct. Postgress gives you materialized views for exactly this. So here's how I'd set up a materialized view. So the request hourly MV materialized view and then we have the different fields that we're adding to it. So uh while that runs notice what it's doing. It's reading the entire table to build the summary every row from the beginning of time. And then we're we'll query it which is instant. Obviously it's a small precomputed table. But the problem, let's say we want to refresh the data. We'll do refresh materialized view. And this can take some time. That refresh recmputed everything from scratch, all of history to pick up the last 5 minutes or so of new data. So materialized views have two problems. Basically, two deal deal breakers. The cost of refreshing grows forever because it's proportional to your total data, not to what changed. And the plain refresh takes a lock that blocks reads. There is a refresh concurrently, which doesn't block, but it needs a unique index and it's even slower. So, people go one level down and hand roll basically make it themselves. a real summary table updated by a trigger on insert. That's actually worse, but it'll be like this. We create this request summary table. We create this function that's going to update it. Uh, it looks reasonable, but let's think about what happens under load. Every insert into requests has to update a row in the request summary. And for any given hour and domain, that's the same row. every single request for example.com this hour contends for one row row level locks concurrent inserts queue up behind each other and the there here's the thing about queuing it doesn't degrade gracefully it's fine it's fine it's fine and then you cross a threshold and it collapses because each waiting transaction is holding resources while it waits with each new query the waiting time increases until the database becomes comes overwhelmed by a mess of blocked queries waiting to run. That's the failure mode. Not slow, but stuck. And even if you solve the contention, you've now got a trigger to maintain backfield logic for historical data, correction logic for updates and deletes on the source table, and a whole new class of bug where your summary and your raw data disagree. But there's another option. Time scale DB's continuous aggregates solve exactly this. They keep a materialized summary, but the refresh is incremental. It tracks which time buckets actually changed and recomputes only those. So no triggers, no lock contention on write, no full recomputee, and it handles updates and deletes to the source. We'll be talking about that more uh next. and it's a great feature in the extension, but you had to see why the obvious approaches fail or the incremental thing just sounds like a a nicer materialized view. It's not. It's a different mechanism. So, let's just drop all those tables and functions that we just created. Okay, let's go on to the next part and talk about the problem for this section. This section is inspired by the book Make Analytics Fast by Tobias Petri. Before any features, we need a problem worth solving and data to solve it on. We're going to use a web analytics scenario for all of this part because it's something everyone understands and it has exactly the right shape. So, picture this um and this is the scenario that we uh use for some of the sample code earlier also. But let's say you work on a service that fronts a lot of websites, a CDN, a hosting platform, an API gateway, it doesn't matter. Every HTTP request that passes through gets logged. Your customers log into a dashboard and want to see their traffic like requests over time, response times, error rates, which which paths are slow, broken down by domain and by service. It works great for a year, then it doesn't. The dashboard that took 200 milliseconds now takes 40 seconds and support tickets are piling up. Just imagine your colleague who set everything up has vanished into thin air. So you know somebody built this. It worked. They left and now it's yours and you don't know why any of the decisions were made. So let me show you the actual data. This is what I showed you earlier. Just the slashd requests and it's going to show all this data. This is the table. It's wide on purpose, one row per HTTP request with everything about that request as a column. Timestamp, the domain, the service, the server that handled it, the path, the HTTP status, the response duration broken into components, geo info, user agent details. So we can just see a few more things here. And now I'm going to show you the count. So count from requests and let's get the total relation size. So we can see that there are uh over 5 million rows and it's 16 kilobytes. So that's what I've seated on this computer. But the repo in the description has a generator script with flags. So you can scale this to whatever your machine tolerates and test on different uh different sizes of the table. Now the dashboard query, this is the one we're going to make fast. So we're going to run this and we're we're this is the one we're going to make fast. We're going to come back to it after every single feature. So we're going to just run this many times and see how it changes. Basically, we're selecting we're getting all these different pieces of information from our our table and we're checking where URL domain equals example.com and the interval was in the last 7 days. And then we're going to group by hour and then order by hour here. So, we can see all the results here and we can see this was 2 thou uh 2341 milliseconds. Now I'm going to run that three times. So I'm just going to run it right now again. And I want to explain before why I'm doing this because this is a good habit. Uh so you can see it's already showing a different number and they're possibly they're going to possibly differ a lot. So the first run pays for the reading cold data off disk. the second and third, here's the third one here, are going to find RD in memory. That's not a trick. It's the difference between a cold cache and a warm one. And if you benchmark without knowing which one you're looking at, you will draw confident conclusions from noise. So this third one is the the number we're actually going to use. This is going to be our baseline for this query. 2,211 milliseconds. That's how we're if we run the query a few times, we can get a more accurate number. So this is our baseline, but the wall clock time is the soft number. So let's also grab the hard one, the one that doesn't move between runs. So here we're running explain with analyze buffers and then we are getting the hour and the count of the requests and we're grouping by hour. So we can see this is giving us a lot of information here. We can see um also the planning time the execution time down here. But what I want to look at is the this buffers this shared hit which is 230 235,761. So this is how many 8 kilobyte pages this query touched. We're reading an enormous amount of data to produce about a 168 rows of output. And here's why buffers beat milliseconds as a measure. That page count barely moves between runs. It doesn't care whether your cache is warm, whether Docker is throttling or whether something else on your laptop laptop woke up. It's a direct measure of how much work the database did. And every optimization in this course is fundamentally about doing less work. Milliseconds are what your users feel. Buffers are what you can actually reason about. We'll track both. And when they dis disagree, believe the buffers. And there are three separate problems tangled together in that number, which is why there's no single fix. We're reading rows we don't need. Every row for every other domain because the rows for hexample.com are scattered everywhere. Hyper tables and the column store fix that. We're reading columns we don't need. This query touches four columns out of dozens, but pagebased storage drags all of them along. The column store fixes that. And we're recomputing an answer that cannot have changed. Last Tuesday's hourly counts are fixed forever and we recomputee them on every dashboard load. Continuous aggregates fix that. And one more thing, this is a migration, not a rewrite. The important note on scope before we start because it's a real question people have, you do not move your whole database to time scale DB. Your users table, your orders table, your products table, those stay exactly as they are normal Postgress tables. They're small. They get updates up update updated constantly. They need real foreign keys and unique constraints. And they are not time series data. Only the fire hose tables become hypert. The requests, the events, the metrics, the traces, everything else is untouched. And this is the good part, they all live in the same database. So you can still join them which we'll do in uh one of our projects. All right, first feature hypert. So let's talk about hyper tables here. A hypert looks like one table and is physically many tables. Your query requests you insert into requests. As far as your application, your OM, your SQL are concerned, there's only one table called requests underneath. Time scale DB is splitting it by time into pieces called chunks and routing every row to the right chunk automatically. So why chunks? Well, there are three payoffs and they map directly onto the three problems from part one. Queries skip irrelevant chunks entirely. You ask for the last seven days, the planner excludes every chunk outside that window before reading a single page. Not scans them efficiently. It doesn't even open them. And indexes stay small. This is the subtle one and it's the biggest deal for write throughput. Every chunk has its own index. So the index you're actively writing into only covers the current chunk, which means it fits in memory, which means inserts don't hit disk to maintain the index. Combat. Compare that to one giant index covering two years of data, which cannot fit in memory. So every insert becomes a random disk. Right. Also dropping old data is a file deletion. The retention problem from the previous part solved by construction. You drop a chunk not rows. So let's see the whole uh conversion. I've got a copy of the table schema without the hyper table applied. So we can do this live. So this is the the copy here. And to convert we just do select create hypert from the request ht request hyper table and we're going to uh separate it by uh the range by intervals of one day. So that's it. That's the feature that's how to create hypert. So three things about that call. This by range here is the modern dimension builder syntax. If you read older tutorials, you'll may see a different method of uh creating different dimensions, which still works, but it's the legacy form. The column has to be not null because every row must route somewhere. And you'll typically do this on an empty table, though there is a migrate migrate data flag that you can do, an option for converting a table that already has rows in it. It works, but it's slow and it locks. So plan for maintenance time. Now let's watch what happens as data arrives. So right now there's nothing here. It's an empty table. No chunks. Unlike native partitioning, we did not have to create anything in advance. So now we're going to insert into our new hypert uh information from our original table where time is in basically the last 3 days. So we inserted a bunch of records. It took us 21 seconds. And now we are going to show the chunks here. And now we can see that there are four different chunks. They appeared on demand. One per day created as as rows arrived. That's the automation you're paying for. No crown jobs, no PG part man, no pager alerts at midnight on the first of the month. So let me show you their real tables. We're actually going to just do a query from them. And so this is a query right from these chunk tables. And we can see we're actually able to query. They're real tables with names and they have a schema. You can query one directly if you ever need to, but you normally never need to. And let me show you the pruning. I'm going to do an explain from request ht for the last day. And you can see here that we are only accessing just one of the chunks. So the others aren't in the plan at all. And now I'm going to show the failure case. So I'm going to explain select count from request ht where your domain equals example.com. And you can see here that we're actually accessing accessing from every chunk. So no time filter. So no pruning is possible. it has to look everywhere. So this brings us to the next point. Always filter by time. This is the number one rule of using time scale DB and it's the number one reason people say I installed it and it wasn't faster. If your query doesn't filter by the time column, you get none of the benefit. Chunk exclusion is the found is the foundation everything else sits on. when you're writing queries, when you're writing OM code, when you're designing an API endpoint, the time range is not optional. It's the price of admission. So, write it down or I mean or remember this rule basically because we're going to be coming back to this. So, how big should a chunk be? This is the one real decision you have to make and both directions are wrong in different ways. So basically they can be the chunks can be too small and then you get thousands of them. Every query's planning time goes up because the planner has to consider and exclude each one. Metadata overhead grows and you burn a connections worth of memory on planning for a query that returns four rows or your chunks can be too large and then you lose the two things chunks are for. pruning get pruning gets coarse asking for one hour reads a month and the active chunks index stops fitting in memory which kills your insert throughput. So there's two guidelines you should be thinking about. One, keep it under about a thousand chunks per hyper table. That's a soft ceiling for planning overhead. uh if you're projecting past it, widen the interval or add a retention policy so old chunks get dropped and the count stays bounded. Also, keep the active chunks indexes to under about 25% of your RAM. This is the important one. You're only ever inserting inserting into the most recent chunk or two, and those indexes need to live in memory. The rest can be on desk because you touch them rarely. So let me do the arithmetic because it depends is useless without a worked example. So we're going to run this command here and we're going to get the rows uh the total rows for the last three days and also the bytes per row which is 406 here. So we're we're so suppose we're taking a 100 million requests a day which is a reasonable midsize service. That's about,00 a second. 100 or two 400 bytes a row is 40 gigabytes of table per day. Say indexes add 30% and now we're at 52 gigabytes per day total. If I have 32 gigabytes of RAM, 25% of that is 8 gigabytes for the active chunks indexes. My daily index volume is three gigabytes. So a 1-day chunk uses three of my 8 g gigabytes budget. It's comfortable, but a oneweek chunk would be 21 g gigabytes of index. Uh way over. So daily chunks. Then check the count daily chunk chunks and I keep 90 days 90 chunks well under a thousand. If I kept five years with no retention, that'd be 800 chunks and I'd have to reconsider either weekly chunks or a retention policy. And the retention policy is the the better answer. So, if you don't want to do arithmetic, here's the cheat sheet. High volume, tens of millions of rows a day or more, one day. Moderate, millions a day, one week. Low volume, thousands a day, one month. And when genuinely unsure start with a day, it's the most common answer in practice. And it's what both of our projects that we're going to do in this course use. So, you can change it with a command like this, set chunk inter time interval, and you can set it to uh seven days here. So an important caveat that only affects new chunks. Existing chunks keep the interval they were created with forever. There's no rewrite. So you can end up with a hyper table that has daily chunks for its first year and weekly after that. And that's fine. It works. But it means you should think about this a little upfront rather than planning to fix it later. And you can partition by more than time. This is another capability and I'm mainly mentioning it so you know it exists and when not to use it. You can add a second partitioning dimension, usually a tenant or device identifier. So each chunk is split further by hash, but I would say that you probably don't need this. It was much more important in the old multi-node deployments where it distributed data across servers on a single node with its main use is paralyzing IO. across multiple physical discs or splitting an enormous pre-chunk row count. And it has a real cost. It multiplies your chunk count by the number of hash partitions. Four hash partitions turns 90 chunks into 360. Now you're closer to that thousand chunk ceiling for a benefit you may not be getting. The column stores segment by setting which is coming up later in the course gets you most of what people want uh from space partitioning which is data grouped by tenant. So query skip other tenants without the chunk multiplication. So note it exists and reach for segment by first. Okay, let's just drop that copy because from here on out we'll work on the real request table which I will convert now. So we're going to create a hypert from the requests table and actually I already did it. It's so it's already a hyper table but it can take a while the first time you do it. And let's show the chunks. We have four chunks. And before we measure anything, let's talk about one thing that people sometimes forget, which is to um analyze. So I'm going to run analyze requests. Uh converting a table to a hyper table moves the data into new physical chunks and those new chunks have no planner statist statistics yet. Postgress is planning against guesses until you run analyze. So do this after every conversion. If you skip it, you get bad plans and you can blame the wrong thing. So now let's rerun the baseline dashboard query. So here's the query we ran before, but now we have a hyper table and we're going to see how long it takes. And we're going to run this a few times because the the first will likely be dramatically slower. The migrate data just rewrote every row into new chunks. So nothing is in cache. So if we run it again, we can basically see a settled number. Sometimes it's good to run it three times. So we can see that this number is um it's not a clear win. It's similar, but maybe just somewhat slower. the so the the the first time we ran it um it's was the old cache because we just rewrote every row on disk into the new chunks and so here is ba basically just a bit worse like I said and that's the honest result and it's the most useful thing that will happen in the section. So look at what we asked for which was that every row in the last seven days. If I kind of show this, we're trying to get everything in the last seven days and how much and we have seven days of data. So we ask for the entire table. Chunk exclusion can only skip chunks that fall outside your time filter. Our filter covers all of them. So we skip nothing. We read exactly as much as before and we've added a little work because the planner now plans across eight chunks and appends eight scans instead of running one clean sequential scan. So let's also check the the buffers. So remember we checked buffers before and we can see the buffers is essentially unchanged now. It's almost the same number. Uh basically it's the same pages. We not save the data database one single unit of work and the buffer count says so regardless of what the clock said. So partitioning is not specifically a speed feature. It's not really a performance feature. Chunks make it possible to skip data. They don't skip anything on their own. If your query asks for everything, you get everything plus a little overhead. This is the single most common disappointment with time scale DB. Somebody converts a table, reruns their dashboard query, sees no improvement, and concludes the extension doesn't work. But the extension works fine. The query asks for all the data. So now, so let's ask a question a dashboard would actually ask. Here we're going to look for the data in the last six hours instead of the last seven days. Now we see that there is only one chunk in the plan. And if we point to the buffer count, it's very small. It's just three now. So it's just a small fraction of the baseline. So there it is. One chunk in the plan instead of eight and a fraction of the pages read. Same table, same data, a time filter that lets the planner throw away seven, like tons of the table before it reads a single row. And notice I'm pointing at pages, not milliseconds. On a data set this small, the whole thing may fit in memory and the clock times will be could be close together. The buffer count shows the real difference. And on a production table where the data doesn't fit in RAM, that difference in pages is the difference in seconds. So that's the shape of every win in this course. It's not it's magically faster, but we gave the database a way to not do the work. So next up, making sure we find rows efficiently inside a chunk. So let's talk about indexes and chunk skipping. Chunk exclusion got us from the whole table down to the right chunks. Now we define rows efficiently within a chunk. And indexing on a hyper table has one structural difference from indexing a normal table. When you create an index on a hypert, you don't get one index. you get one index per chunk automatically including on chunks created in the future. So here I'm going to create an index on requests um URL domain and time descending. So this is going to build per chunk and now it's done here. So now we're going to basically count the indexes. And now we have eight here, one per chunk. And this is why hyper tables can sustain write rates that a normal table can't. Think about the insert path. On a normal table with two years of data, that index is enormous, bigger than RAM. Every insert has to find its place in that giant B tree. and the pages it needs are probably not in memory. So, it's a random disk read followed by a random disk write. Random IO is the slowest thing in a that a database does on a hyper table. Inserts only go to the current chunk. That chunk's index is one day of data. It's small. It's hot. It's in memory. Insert is a memory operation plus a sequential write to the w. That's the difference between thousands of inserts a second and hundreds of thousands. So on the index def definition itself, I put URL domain first and time second. And that order is deliberate. Uh the rule of thumb is equality columns first then the time column. You filter URL domain equals example.com. That's equality. It narrows to a specific point in the B tree. Then time is more than or equal to something. It's a range scan from that point. If you flip the order, you scan a huge time range and filter domains out afterwards, which is much more work. And the time descending is because time series queries almost always want recent data first. It makes order by time uh descending limit 100 the show me the latest query. It makes it nearly free. Now here's the mistake that will get you and it follows directly from that always filter by time rule that I talked about earlier. So I'm getting an ID for this next example here. So let's say I want to one specific request by its ID. So I'm selecting request where the request ID equals um the ID that I just got. And look at that. It checked every chunk. We can see here this chunk, this chunk, this chunk. Um each one has an index on request ID. So each lookup is fast, but did that look once per chunk because nothing told it which chunk the row is in. with if we had 90 chunks that's 90 index probes to fetch one row. It'll still return in milliseconds on a small data set. So it looks fine in development but with two years of daily chunks it's 730 probes and it does not look fine anymore. So here's the fix. First I'm going to get the request ID and also a time for that request. And then I will call where the request ID and put in the request ID and also put in the time here. And now we can see only one chunk one probe. So the design rule whenever you look up a row by ID pass its timestamp also which sounds annoying but think about where that ID came from. It came from a list view, a search result, a web hook payload, a log line, and all those already had the timestamp right next to the ID. You just have to carry it through your API and your URLs. If you genuinely can't say the ID comes from a third party who only gives you an ID, there's an escape an escape hatch. Now the actual limitation on hyper tables the thing you should know before you commit to this everything else in this course is upside but there is one genuine constraint basically there's no global unique index so if I try to create unique index on requests we got an error so reject it the reason is structural and not really an oversight a unique index has to be able to answer does this value exist anywhere in the But there's no single index spanning the hyper table. There are 90 separate perch chunk indexes. To enforce global uniqueness on insert, Postgress would have to check all 90 on every single insert. That would destroy exactly the right performance that hyper tables exist to provide. So the rule any unique index on primary key or a hyper table must include the partitioning column. So we're going to do alter table request add primary key time and request ID. And that works because now uniqueness is only enforced within a chunk. The time value determines the chunk. So a single chunks index is sufficient to check. And here's what you're basically giving up. Two rows with the same request ID at different timestamps are now legal. Usually that's fine, but sometimes it isn't. If the ID is a payment reference, you want hard global uniqueness. There's a genuinely elegant solution to that and it's my favorite trick. The idea is to use an ID that contains its own timestamp. Then the ID alone is enough to find the chunk and you get global uniqueness for free. That's UU ID V7. same 128 bit UUID format everyone already uses, but the first 48 bits are a Unix millisecond timestamp followed by random bits. So, UUID V7s sort chronologically, and you can extract the creation time from the ID itself. So, I just ran this select UU ID V7 a few times and look at the leading characters. They're all going to start with the same characters. You see, actually even goes up through 722. So the leading characters are nearly identical. That's the timestamp and generated a sec. They it's they were generated a second apart and then they sort in order. So then we can here we're generating that again but then we're calling this UUD timestamp and we can pull the timestamp back out. That function is provided by time scale DB. Postgress 18 also ships a native UUIDV7. So depending on your version you may have two available and either is fine. Just be consistent. Okay, let's talk about this uh check the check constraint. So, this is going to tie everything together. And so, I'm creating the t this table events v7 with an ID time JSON. And then I'm going to create a hyper table. But, uh see what we're doing is I'm running check. It's doing something clever. So basically it guarantees that the time stamp embedded in the ID equals the partitioning column which means given only an ID you can compute the time which means you can always find the chunk which means the ID alone is now a global unique key in practice. A duplicate ID would have to have the same embedded timestamp. So it it would land in the same chunk where the perch chunk unique index catches it. You get global uniqueness and single chunk lookups from one constraint. So let me just uh insert into this events v7 table. Um we're passing in so we're having the time stamp and then hello world. That's the data. So that generates two different UYU ids. So let me do it uh properly with new id as and then select uidv as id insert into events the id time data select the id and then uh from new id. So if we run that uh in the application code this is cleaner. You generate the UID in your app, derive the timestamp from it, and pass both. That's exactly what one of our projects later is going to do, and you'll see the Python for it. So, I'm going to run this to get the recent ID that we put into the table. So, let's get an ID from that table, and then we can look it up where we're going to pass in the ID here. And the timestamp is going to be the exact same ID here. So we're basically putting in the same UYU ID twice. And then we can see this query plan here. One chunk from an ID alone. But one thing to keep in mind, and it's a real one that will cost you an afternoon if you if you just don't understand this. um UUID V7 timestamps are millisecond precision. Timestamp TZ is microssecond precision. So if you generate a time stamp any other way now for instance and compared to the one embedded in UU ID v7 that will not be equal and your check constraint will be reject the row. So always derive the time stamp from the UIU ID using UUID timestamp never generate them independently and hope. So, one thing you should know is that indexes are not free and on a high ingest table, they're expensive in a way they aren't on a normal table. Every index has to be updated on every insert on a table taking a 100,000 rows a second. One extra index is a 100,000 extra B tree insertions a second. I It's possibly could add six indexes just in case and wonder why and just fell off a cliff. So find the ones you're not using. So with this code, we're selecting the indexes and we're index scan equals zero. That means that the index has never been used since the stats were last reset. On a system that's been up a while, the those are candidates for deletion. Check that it's not a unique constraint doing integrity work first. And one thing you should know which I want to uh make sure you understand is that once we get to the column store, you'll need dramatically fewer indexes than you think. Uh because columnar batches have their own built-in skipping. Some of the indexes you'd add to a normal analytics table become pure overhead. So let's measure with the index in place and let's measure the narrow query uh the six-hour one because that's the one an index can actually help. So the shared hit is three. Now we can see the the 7-day interval, the 7-day baseline, and we can see the shared hit 2,198 and the read 1,100 or 11,655. So, two different stories in those two numbers. And the difference is the whole point. The narrow query got faster. The seven day query didn't. And an index was never going to fix it. When you're reading most of the table, the index scan is more work than just scanning. The planner knows that, which is why it likely ignored our new index entirely. That query needs a different kind of fix, and it's coming up in a little bit here. So, something interesting has happened. We have chunk exclusion. We have a good index. And the query is faster, but it's still not fast. And the reason it's no the reason is no longer that we can't find the rows. So the bottleneck is no longer identifying the rows for the analysis but loading them. Any big analytics calculation struggles with the same issue. We know exactly where the rows are. Loading them is the problem. So here's why and this is where uh the previous parts 8 kilobyte page lesson pays off. Rows are stored in insertion order. requests arrive interle from every domain. One for example.com, three for other.com, one for example.com again. So within any page, the rows for a single domain are a small scattered fraction. Say a page holds 80 rows and example.com is 1% of your traffic. Then each page contains roughly one row you want. To read a million rows for that domain, you read a million pages, 8 gigabytes. to extract about a 100 megabytes of data you actually asked for. And it's worse than the volume suggests because those page reads are random. Sequential reads are 10 to 100 times faster than random ones. Even on SSDs, you're doing the slowest kind of IO for data you mostly throw away. The fix is data locality. Physically rearrange the rows so a domain's data sits together. then reading a million rows for one domain is a few thousand sequential page reads instead of a million random ones. So let's see it in action. Create index request ordering index on request with the domain URL domain and time. That index defines the desired physical order grouped by domain and within each domain ordered by time. Then we rewrite the chunks in that order. So this command here rewrites every chunk which will definitely take some time while it runs one nice property. It rewrites each chunk into a fresh copy and swaps it one chunk at a time. So it doesn't need a giant lock on the whole hyper table and it fully rebuilds the indexes as a side effect which removes bloat. It's the kind of it's kind of like the useful cousin of vacuum full. Okay, so that finished. It took 38 seconds. Now we'll run the six-hour query again unchanged. This is the third time we've want run it. That's the points or control. And the shared read is three again. I mean there's not that many to read here. the so reordering is going to pay off on the narrow index driven query, not the 7-day scan. And it shows up in pages read far more clearly than on the clock. But let's do the 7-day scan here. And we can see the hit uh is and the read are up here. 163,000 73,000. So same rows, same index. Uh but what we didn't make the search smarter, we just made the data denser. Okay, let's talk about the column store. This is the big one. If you take one feature away from this course, take this one. So let me start with a question. Our request table has what maybe 15 columns. Well, real production analytics tables often have 50, 100, more. Now look at this query. Select average duration total from request ht where time now minus interval 3 days. So so look at this query time or select average duration total from request where time is between the last three days. One column that query needs exactly one column the duration total. How many columns did postresql actually read off disk? Well, all of them because of how how row storage works. So, but remember from the previous part, the smallest unit Postgress can read is an 8 kilobyte page and a page holds complete rows. Every column of every row on the page comes into memory whether you asked for it or not. You wanted one number per row and you paid for a 100. That's the first problem. Here's the second. Postgress processes those values one at a time. Fetch a row, extract the column, add it to a running total, next row. 10 million rows, 10 million trips through that loop. Modern CPUs can do arithmetic on eight or 16 numbers in a single instruction, but only if the numbers are sitting next to each other in memory. In row storage, they're 100 bytes apart. So you read data you don't need and you process the data you do need in the slowest possible way for finding one row which is what row storage is designed for. This is exactly right for aggregating 10 million rows. It's wrong. Every dedicated analytics database on Earth solves this the same way. Store the data by column instead of by row. Time scale DB's column store takes a chunk and rewrites it. Instead of a thousand rows sitting next to each other, you get all thousand duration total values sitting next to each other in one compressed batch. Same for every other column. Now the average query reads one column's worth of data. And because those numbers are contiguous, the CPU can chew through them 16 at a time. That's called vectorized execution, and it's why the speed up is so much bigger than the IO savings alone would suggest. But here's the thing that people sometimes don't expect. Column NAR storage doesn't just make queries faster, it makes the data smaller, dramatically smaller. And that's because a column of similar values compresses far better than a row of wildly different ones. So think about it. In our table, URL domain for a thousand consecutive rows might be the same handful of values. Time for a thousand consecutive rows is a thousand timestamps that are all within a few seconds of each other. Status code is basically all 200s with uh the occasional 500. That's enormously compressible, but only if you put them next to each other first. So there are four compression techniques and they're worth knowing because they explain what compresses well and what doesn't. A repeated value is stored once. Run length encoding. A thousand rows of service equals shop. It becomes shop time 1,00. Frequent strings get shortened using an internal dictionary. Instead of storing the literal string checkout service a thousand times, store it once in a dictionary and store a small integer reference. Numbers are stored as deltas in the smallest data type that fits. Consecutive timestamps differing by a few hundred milliseconds don't need eight bytes each. So the first one then two byte deltas. Booleans become a single bit not a bite but a bit. So by applying these optimizations, Times scale DB often reduces a table size by more than 90%. That's not a typo and we're going to verify on our own in a in uh later in this course. So in one example, a table went from 72 1/2 GB to 33. But look at the indexes 10.6 GB down to 400 megabytes. So we'll come back to why the index number is so extreme. Now if columnar is so great, why isn't everything columnar? Because columnar storage is bad at the thing rows are good at writing one row, changing one value, deleting one record. So time scale DB does the obvious smart thing. New chunks are row store, fast inserts, easy updates, normal postgrql. Once the chunk has settled down, once you're not actively writing to it, it gets converted to column store fast reads tiny footprint. This is the part where a real analytics database would tell you that tell you the data is now read only. But time scale DB doesn't. With Timescale DB's column store, you can still insert new rows or modify and delete existing ones, which is not the case with other analytical databases. You still have a normal SQL table. Update works, delete works, insert works. It's slower than on a row store chunk, and you shouldn't design a workload around hammering updates into compressed data. But late arriving events, corrections, GDPR deletions, all that just works. And there's a uh counterintuitive bonus. Inserting into a column store chunk is also the fastest way to insert many rows. With the direct to column store option, you can insert more than five millions uh more than five million rows each second. Postgress can't do that. Five million rows a second. If if you're doing bulk backfill, writing straight into column store format is faster than writing rows and converting later. So let's do it. Three settings. So I run that and it says warning column request ID should be used for segmenting or ordering. Now Postgress is telling us hey request ID is a unique key and because it isn't in segment by or order by looking up a compressed row by ID won't get pruning benefits for analytics queries. aggregating by domain and service over time. That's exactly the trade-off we want. So the enable column store here turns the feature on. The other two are the ones that matter and I want to spend some time on them because this is where people get bad results and conclude the feature doesn't work. So the segment by is the one that matters. Segment by decides how rows are grouped into batches. rows sharing the same segment by values go into the same batch. And this is the point. A query filtering on that column can skip every batch that doesn't match without decompressing anything. It doesn't scan and reject. It just never touches them. So you choose segment by by asking what do I always filter on? For us, every dashboard query filters on URL domain and usually service. That's a tenency column and tenency is the mo is the most common right answer in real systems. Customer ID, project ID, device ID, account ID. Now the constraint that trips everyone up. A batch holds up to a thousand values. You want batches full. A batch with three values in it wastes more of the benefit because compression works on volume and you get one batch's overhead for almost no payoff. So the rule for any given segment by value you want at least a thousand rows in a chunk ideally many thousands which means high cardel cardality columns are poison here. Let me show you rather than assert it. So I'm going to say select count distinct UR URL domain as domains and then we're going to count the request ids and then count the rows. So we got just uh nine domains millions of distinct request ids. And if I set segment by equals request ID, every batch holds exactly one row. I get all of the overhead and none of the compression. and we're going to measure that soon. So order by controls the sort order within each batch. Two things come from it. First, it's the data locality win from the reordering section for free as part of compression. That is why reordering becomes redundant. Second, and less obvious, time scale DB stores the minimum and maximum of each order by column per batch. So a query with where time between X and Y checks a batch's min and max timestamps and skips the whole batch if there's no overlap. That's why you put the timestamp first in order by you always filter by time range. So you always want that skip available. We use time uh deesc uh descending URL path. Uh so time first because it's always in the filter URL path second so that within a given second paths group together which helps our per path queries. So right now actually uh we haven't uh gotten any there's no compression we haven't saved any space yet. So you shouldn't just think oh there's no improvement and give up. Activating the column store for a hyper table doesn't automatically convert its chunks to column store chunks. This misconception often leads to underwhelming performance during initial performance testing. Enabling the column store configures it. It doesn't convert anything. Existing chunks stay exactly as they are until you either convert them by hand or add a policy that does it for you. So here's how you could convert it by hand. So, we're going to check out what the chunks are. And then we're going to do call convert to column store. And I'm just going to take one of these chunks and then put it in here. Okay, it's converted. And it is reversible, which is important later. So, basically, I run the same command, but instead of convert to column store, I can say convert to row store. If you're on an older time scale DB, you may see compressed chunk and decompressed chunk in the docs. Uh it's the same operation but older names. The convert to um column store row star store procedures are the or the current API. Now we can also add a policy add column store policy on the request table and the policy is interval 1 hour or after that. Okay. So any chunk whose data is more than an hour old gets converted automatically by a background job. So why the delay? Why wait an hour at all given that column store chunks accept inserts? Well, two reasons. Inserts into a row store chunk are cheaper and real pipelines have lag. Cute events, retried web hooks, mobile clients that were offline. Giving the newest hour a grace period means the bulk of your rights land on the cheap path. It's not a correctness requirement. It's a throughput choice for our 3-day data set. An hour means almost everything converts immediately. Let me force the whole table so we can measure. So let me force the whole table so we can measure. So everything this was already a column store and now let's see it. So I'm going to see the before compression total bytes the after compression total bytes and it went it we basically saved 88% for uh how much uh space uh this is. So that is pretty good. Now I'm going to turn on timing for this section. And now we're going to run this that we've run before where we're getting the average duration from the last three days and we're at 60.8. So remember that benchmark number we have indexes going from 10.6 gigabytes to 400 megabytes. Here's why it's so extreme. Column store chunks don't have your indexes at all. And your first reaction uh that you may be wondering about that. So let me explain why it's fine. An index exists to avoid reading rows you don't need. The column store achieves the same goal in a different way in two steps. The step one, your segment by filter eliminates whole batches instantly from metadata. A thousand rows gone per batch and no decompression. Step two, the order by minmax ranges eliminate more batches, so no overlap with your time range. You can skip it. So between these two, a typical dashboard query has already discarded most eving a single value. An index would be redundant and it would cost you the 90% size win to maintain. But what about a point lookup like one specific row by ID? For that, there's a third mech mechanism. Time scale DB keeps small index-like structures per batch like minmax ranges for numbers and timestamps. Bloom filters for strings and uyuids. A bloom filter is a tiny probabilistic structure that answers is this value definitely not in here very cheaply. If the answer is definitely not skip the batch if it's main if it's maybe decompress and check. So uh bloom we can here we're doing a bloom filter on geo country because we do equality lookups on it then min max on duration total because we filter it by range a request slower than 5 seconds. One big caveat and it's the same shape as the misconception from earlier. These structures are built when a chunk is converted. Setting compressed index on an already converted chunk does nothing to it. If you need to add one retroactively, convert to row store then convert to column store on a big table that's expensive. So think about this before you convert a year of data. And the limitations uh because I don't want you deleting your index and then filling a bug. So they only help equality checks. So where status equals open is great. That's what a bloom filter answers. They do not help in lists. Where mode in 245 gets no benefit. So equality yes range on a min max column yes. Multialue in no. So three things to remember from this section. One enabling the column store converts nothing. You need to add a policy. Two, segment by is your filter column, usually tenency, and it needs over a thousand rows per value. And three, set compress index before you convert and don't expect it to help in lists. That's the column store. 90% smaller, dramatically faster, and it makes the reordering section obsolete, which is exactly um what I was telling you earlier when we were going through that at the end of that section. So next um premputing results so the query doesn't have to aggregate anything at all. So we've made the status smaller and the scans faster. There's one move available and it's uh this next one is the biggest of all. Every dashboard query we we run has one thing in common. It computes the same answer over and over. Somebody loads a dashboard. We aggregate yesterday's data. They refresh. We aggregate yesterday's data again. Yesterday's data has not changed. We are doing identical work repeatedly forever. So you could go further. You you could further speed up queries by by accessing precomputed results instead of aggregating rows each time a query is executed. You already know why the obvious implementations fail because we did both in the previous part. A materialized view has to recomputee everything. So refreshing it gets slower every day. A summary table maintained by triggers hits low-level lock contention. So with each new query, the waiting time increases until the database becomes overwhelmed by a mass of blocked queries waiting to run. That's the death spiral we watched in earlier. What we want is the concept of a materialized view but incrementally maintained. Recomputee only what actually changed. That's a continuous aggregate. So here's what I just run here ran here and let me walk you through it because most of this is ordinary SQL and exactly two things are special. So we have create materialized view that standard postgressql syntax and then the with time scale db continuous that's special the thing that's the special thing number one and it's what turns a regular materialized view into an incrementally maintained one then a normal aggregate query we have uh time bucket um interval 15 minutes time now this is Another special thing sort of it's a time scale DB function that rounds a time stamp down to a fixed interval. Every event between noon and 12:15 gets bucket 12:00. That's what makes the grouping stable and reagregatable. Everything I grouped by a dimension I want to filter or slice by later. Everything I aggregate is a number I want on a chart. Uh notice that I chose count min max and not average. That's deliberate and I'll explain in a little bit. It's and it's a pretty interesting thing. So now the refresh policy select add continuous aggregate policy request 15 minutes. The schedule interval start offset null. End offset null. Every five minutes, a background job updates the aggregate. Start offset and end offset are null, meaning consider all of time. And we'll change both of those later. And querying it is just querying a table. So we can query it just like we would do a normal table. And now we've counted all the requests here where URL domain equals example.com and service equals shop. Now we'll make sure timing is on for this next one and we'll select and then we have the time bucket with this interval. We're summing requests from request 15 minutes and we're again looking for example.com. So I'll do this and we can now read the the time number read we can read the timing and compare it to the same query then compared to the same query against the hyper table. So I asked for hourly numbers from a 15minute aggregate by reagggregating four buckets at a time. That works because uh time bucket boundaries nest cleanly. You can always go coarser. You can never go finer. So here's the mechanism and it's why this feature works. Time scale DB tracks which buckets have been invalidated since the last refresh. You insert a row, that row's bucket is marked changed. You update or delete a row. Same thing. When the refresh job runs, it looks at the list of changed buckets and recomputes only those. Even with years of data, the update process remains quick because every time only a few buckets may have changed since the last run. That's the difference. A materialized view refreshed co uh cost grows with your total data. A continuous aggregate refresh cost grows with your change rate, which for time series data is basically constant because you're only ever writing to the present. So I want to flag this one specifically. These are background jobs. If they start failing uh like bad permission, a schema change, disc pressure, they fail quietly. Your dashboard doesn't air out. It just serves sta numbers and you find out when someone asks why yesterday looks empty. So you want to basically run this and um check to see if it's empty. There is a problem with what we just built. That refresh job runs every 5 minutes. So the aggregate lags reality up to 5 minutes. For a daily report, it's fine. But for an operations dashboard where someone is watching a deploy go out, not fine. The fix is to combine precomputed history with live data on the fly. So, let me uh rebuild this. First, we'll just drop that table. We've already created the materialized view. And then I'll and then we'll create it again. And then we'll create this again. and then add the policy. So there's three changes uh in the way we did it this time. So let me tell you about the different changes in this way we created it. First of all, materialized only is equals false. This turns on the union. When you query the view, time scale DB reads premputed buckets for the older range and reads the raw hyper table for the recent range, then stitches them together. You write one query and get both then with no data. That's how we ended our creative creation line. Without this cring the view computes the entire history immediately on a big table. That's a long blocking operation at the worst possible moment. With that creation is instant and the policy fills things in progressively. Then in offset equals more is more than equal to internal 30 minutes. This defines the boundary. Anything older than 30 minutes is precomputed and anything newer is read live which means the numbers on your dashboard are current to the second and the expensive part is still premputed. So um how big should in office offset be? Well the rule rule of thumb is at least twice your bucket interval. We have 15minute buckets so 30 minutes. The reason is that you don't want to premp compute a bucket that's still filling. You'd store a partial result and then have to invalidate it immediately and increase it if your data arrives late. If events come through a queue that can break back up or from mobile clients that sync when they reconnect, a 30 minute window might close before the data lands. Then that data goes into a bucket that's already been materialized, which still which does still work. it gets invalidated and recomputed, but you're paying rework for something for something a wider offset would have avoided. Now, let me show you a technique that makes a scale to years of data. I want hourly numbers, too. The naive approach is a second continuous aggregate reading the hyper table. But I already have 15-minute buckets. Four of them make an hour. So, let me build the hourly aggregate from the 15minute aggregate. So I can run this this code. It's very similar but you can see we're getting from the request 15 minutes uh a bucket 1 hour from bucket 15 minutes where I've created a materialized view and then I'm going to add the aggregate policy. So look at the from clause the uh request 15 minutes it's not request and look at the aggregates we have sum it sum of the count min of the minimums max of the maximums I'm already I'm combining already computed results. So basically there are two reasons this is better. Um the reason one is refresh pressure to update an hour. This reads four precomputed rows instead of scanning potentially millions of raw ones. Each level of the ladder reads a small level below it. Then the reason two is the subtle one is query speed. Both views are real time. When you query the hourly view for right now, its live portion doesn't scan the hyper table. It reads the 15minute real-time aggregate which is itself mostly precomputed. Even the live part of the query is cheap. and the end offset is two hours, not 30 minutes. Same rule, twice the bucket interval and the bucket is now an hour. So let's build the rest of the ladder. So now this is basically creating the materialized view for request daily. And notice I dropped the server ID and URL path at the daily level. Nobody asks which path was slowest on March 4th from my yearly chart. And dropping dimensions collapses the row count enormously. Coarser time, coarser dimensions. So that's the pattern. So we can create that and add that policy. So you can see here kind of the the ladder between the basic request. Now mine's called request not request ht 15 minutes hourly and daily. And they're each having different uh dimensions here. So let's try the payoff the 12 months of traffic from the top of the ladder. So doing time bucket interval one month from request daily. And we can see we don't have um too much information in our table right now but uh you can see it's giving this month here. Now we see the time 148 milliseconds. Now let's do it against the raw data from requests. If we do that same thing now we're seeing uh 1,646 milliseconds. So, same answer. Well, we see the sum is the same but wildly different costs and the gap widens every single day the system runs because one of them reads a fixed handful of rows and the other reads everything you've ever collected. So, um one more thing they're just hyperts. So, while continuous aggregates and real-time aggregates may appear magical, they are just hyperts managed by time scale DB. As a consequence, you can also add indexes and activate the column store to improve the speed of finding aggregating row and aggregating rows. This unlocks a lot and everything that we've been talking about recently applies to your aggregates too. For instance, we can create an index. We can compress them and an aggregate ladder with the column store on every rung. Old summaries cost almost nothing to keep which is exactly what makes the retention strategy uh that we'll talk about later possible. Um also some other things worth knowing. We can do a manual refresh just like this for back fills and you can change the settings later with ultra materialized view including flip flipping materialized only without rebuilding. So continuous aggregates are the single biggest performance tool in time scale DB. Premputee incrementally, add materialize only equals false for live numbers. Stack them into a ladder and treat each rung as the hyper table it actually is. Now remember I said I chose count min and max deliberately and avoided average. Well, we're going to find out why. Hyperfunctions are time scale DB's extra aggregate functions. There are dozens and I'm not going to read you a catalog. I'm going to teach you the three ideas that matter and then point at the rest. First one and it's the most immediately practical thing in this section which is filling the gaps. So we got this code here where we are using this time bucket of interval 5 minutes from request example.com geoc country JP Japan and looking in the last 30 hours here so look at the timestamps there are holes five minute buckets narrow filters some buckets had zero matching for instance. So a look at these buckets. Look at the timestamps 35 40 45 50. So we can see these timestamps and we can imagine that there could be some holes. So we have five minute buckets and narrow filter. There could potentially be zero matching requests for some of them. So group group I could produce no row at all. So, uh, that's a real problem because your charting library will connect the dots before the gap, uh, straight to the dot after it. A five minute outage could render as a smooth line and it won't really be accurate. So, here's the fix I have. It shows time bucket gap fill. So, here's the fix. We see time bucket gap fill right here. This needs an explicit start and end. It can't invent a range out of nothing. It has to be told what window to fill. And I will submit this. And then there are going to be three columns because there are three honest answers to what was the value during the gap. And if there is going to be in this example, there's always a count. But if there was a period of time where there is nothing actually here's an example right here we have um we have null basically no data which is truthful and correct when the gap means we genuinely don't know and this locf this is going to carry the last value forward the last obs observation carried forward correct for things that hold their value between readings like a thermostat setting a device state a configuration the sensor didn't report because nothing changed. Now interpolate this draws a straight line between the neighbors which is correct for continuously varying measurements like a temperature a fuel gauge or a slowly climbing value. So choose based on what the missing data means not to which chart looks nicer for request count. A gap means zero request happening. So honestly the the coales count asterk zero is the right answer for us. So now I want to kind of explain something I mentioned earlier. I sort count min and max and pointedly I did not use average. And here's why. Imagine I had stored um average of total duration as duration average. Now I want the hourly average. So I reagregate four buckets. What do I write? So why didn't I store average? Well, calculating sum duration average doesn't make sense and neither does average duration average because the approach doesn't take into account that each average represents a different number of values. Think that through because it's the kind of bug that would you could potentially ship because you don't understand. Sum of averages is meaningless. Average of averages is plausible looking and wrong. It treats a bucket with three requests as equally important as a bucket with 30,000. So your dashboard shows a number and the numbers incorrect, but there's no errors. So min, max, count, and sum all reagregate cleanly. average does not neither does variance, standard deviation, median or any percentile. The workaround is to store this in pieces. So basically here's I'm going to create a materialized view with the I'm with a time bucket sum count group by the bucket 15 minutes and then I'm going to get the sum divi the sum of the sum and the sum of the duration count as the average duration. Now, in this case, it's showing nothing here because of the the with no data here, but sum of sums over sum of counts is a properly weighted average. But it's manual and it's easy to get wrong and only works for average. There's no pair of columns you can store that lets you reconstruct a median. So, let's talk about what fixes all that at once. Aggregating values is a two-step process for any database which is hidden from you. First all values are processed by calculating an internal state. After that the rest is calculated from the state. Every aggregate works this way. Average doesn't magically know the average. Internally it keeps a running sum and a running count and divides at the end. That intermediate thing is the state. Normally the database throws it away and hands you the final number. Hyperfunctions hand you the state itself. And a state unlike a result can be stored in a row and merged with other states later. So here's the code that shows that we got the state the stats a that's not going to return a number. It returns a state object holding everything needed to complete descriptive statistics. So I will do that and then we'll call refresh continuous aggregates and then we can use this code here and so we have for average and variance we have the rollup of the duration. So rollup merges the states then average variance and uh standard dev are accessors. They pull different answers out of the merge state. One stored column, three statistics correctly weighted and basically any granularity that I want. So we can see all the the results here when we run that. So this is a gamecher for continuous aggregates as you can now store aggregate states and rows to re you re reuse them later. uh hierarchial aggregates become trivially correct. Every rung stores states, every rung rolls up the rung below. No manual sum and count bookkeeping, no wrong averages, and you get variance and standard deviation for free because they were already in the state you stored. So in an aggregate, store states, not results. That's the rule. Now, some answers can't be stored in a small state. They some air grids fundamentally need every single value. So imagine a function int with a column and number that returns the seventh largest value. There's no small summary that can answer that. You'd have to keep everything. Percentiles have the same problem. A media needs to know the the middle value which means knowing the distribution. Count distinct is worse. To know how many unique sessions ids you've seen, you have to remember every session ID you've seen. So there's a second family of hyper functions, approximate aggregates. They keep a small bounded state and give you an answer with a known error margin. Instead of remembering 10 million values, they remember a few kilobytes of cleverly structured summary. So the histogram is exact. There are four arguments um the the column the minimum the maximum and the number of buckets. So from 2 seconds to 10 seconds in from see 2 seconds to 10 seconds in four buckets meaning 2 to 4, 4 to 6, 6 to 8, 8 to 10 but you get you get six numbers back not four. So uh you can see the result right here. But let's imagine the result had these numbers here in in blue here. Basically the array annotated with which element is which. You can see which element is which down here. The first element is everything below the minimum. 121 requests faster than 2 seconds. The last element is everything at or above the maximum. 43 requests slower than 10 seconds. The four in the middle are your requested buckets. So don't skip the outer two. Those tails are usually the interesting part that and that 43 over here in this example is the problem. The next thing I want to tell you is the approximate percentile. So this is median, the value separating the faster half from the slower half. Same two-part pattern as before. the percentile a right here builds the state the approximate percentile reads an answer out of it and once you have the state any percentile is free so for example now I'm getting these percentiles the 50th percentile 95th percentile and the 99th percentile so um P95 the 95th percentile is the number that matters. This is the one you actively want for latency, and it's worth uh a little time to explain why. An average hides your worst experiences. A few very slow requests barely move in. The P95 says 95% of requests were faster than this, which means 5% of your users had a worse day than that number. That's the number to alert on. Averages make outages invisible. Under the hood, there are two algorithms. the UD sketch or UD sketch and t digest the percentile a picks a sensible default and you can choose explicitly explicitly if you have a reason to okay I'm going to show you another thing which is the count distinct approximate first I'll make sure timing is turned on and then we'll select count distinct session ID from this is to be requests and then we have 250,000 And then the time we can see 2,13 34 milliseconds. Now instead of doing select count distinct, we're going to do select distinct count and then do approximate count account count distinct with the session ID. So you can see this is a very close number and it was a way quicker it was way quicker to run. So that's hyper log log. It's a slightly different number tiny state and very importantly it's storeable in a continuous aggregate. Exact count distinct can't be premputed because the states can't be merged. The same visitor appearing in two buckets would get counted twice. Hyperlog log states merge correctly. So here I'm doing select distinct count hyperlo log 1024 with a session ID here the first argument trades accuracy against size. So more buckets more precision more bytes hyper log state merge exact ones don't. So what else is in the toolkit? So there's a bunch in the toolkit uh group by what you're measuring. I just want you to review them really quick. You can also search for them later to learn more. But monotonic monotonic counters like counter then rate and delta on top for anything that only goes up by serve energy delivered to requests. It handles counter resets which is the whole reason you don't do this be by hand. Um gauges like gauge egg for values that go up and down. Time weighted averages. Time weight. If your readings are irregularly spaced, a plain AVG average overweights the bursts. This weights by dur This weights by duration instead. State machines. State egg. Duration end. State timeline. How long was this thing in each state? Livveness. Heartbeat. A then uptime and downtime. Num gaps. Did this thing stop reporting? Finance like candlestick a VWAP. Uh down sampling for charts. LTB and ASAP smooth draw a month of data in 500 points without distorting the shape. And then there's the heavy hitters and top K like freak a top in, min, max in. It's worth considering whether all your statistics require exact accuracy or if a small margin of error is tolerable. As your data grows, the performance benefits of precomputing approximate results will become increasingly important. So I want to close the section on that because it's a mindset set shift more than a technique. Engineers often want exact but nobody makes a different decision because unique visitors was 48,214 instead of 48,27. So if a half percent error buys you a,000fold speed up and let you premputee something you otherwise couldn't premputee at all, take the trade. Just make it deliberately and know which of your numbers are estimates. Now, let's talk about data retention. Everything so far has been about making data faster. This section is about not keeping all of it, and it's more important than it sounds. So, first, let's talk about what growing data actually costs you. Here's what happens when a t table grows without limit, and it's rarely the thing people expect. Disk is cheap, so that's not the problem. Your backups get slow and enormous every night and you keep several restores also take forever which matters exactly once at the worst possible moment. Your recovery time objective your recovery time objective quietly becomes a lie and you hit disc ceiling um basically at the worst possible time. So, every stored backup will be large and together can be a significant factor in your monthly costs, which is an often overlooked aspect in cost calculations. That's the part people miss. You're not paying for one copy of the data. You're paying for the live copy plus a replica plus however many nightly snapshots you retain. One gigabyte of table can be five or 10 gigabytes of spend. and old data becomes less important. And then the observation that makes it all tractable, which is the tr the truth is that old data becomes less important over time. Nobody queries individual HTTP requests from 14 months ago. They query summaries of 14 months ago, which means the raw rows are pure cost with essentially no value. And we already built the summaries. So you may consider adding a background job that can drop any chunk whose data is entirely older than 3 months. And it's worth understanding why this is so much better than a nightly delete. A delete of a 100 million rows has to find each row, mark it dead, write that to the right head, write ahead log, and leave the space to be reclaimed later by vacuum. It's IO heavy. It bloats the table. It fights with your live traffic. And afterwards, you often need a vacuum full that takes an exclusive lock. Dropping a chunk deletes a file. The chunk is a table. The whole table goes away. No row scanning, no dead tpples, no vacuum, no blow. It's essentially instant regardless of how many rows were in it. So chunks are all or nothing. One consequence to internalize, a chunk can only be dropped when every row in it qualifies. With daily chunks and a three-month policy, you drop roughly a day's worth each day. Tight. With monthly chunks, a chunk only goes when its newest row crosses the line. So, you can carry up to an extra month of data. So, chunk interval and retention interval interact. If precise retention matters for like a compliance deadline, your chunks need to be small relative to the retention window. So, here's a useful pattern in time scale DB. We'll put the retention together with the aggregate ladder because this combination is the single most useful architectural pattern in time scale DB. Every rung of the ladder gets its own retention policy. Fine detail expires quickly. Course summaries live a long time and they're small enough that forever is genuinely affordable. So basically you can add your retention policy every 15 minutes, hourly and daily. Uh if for daily you may just want to keep things forever. So what you can still let's talk about what you can still answer after 5 years. Uh uh monthly traffic trends u yes from daily. Which service degraded in Q3 of three years ago? Yep. From daily. What happened minuteby minute during the last Tuesday's incident? Yeah, from RAW. What happened minuteby minute during an incident four years ago? No, but nobody's asking about that. You get the answers people actually need at a tiny fraction of the storage. But uh one warning is the order matters. Uh and the or this will bite you if you get backwards. Your aggregates refresh window must not extend past your raw retention window. If a continuous aggregate is configured to refresh material older than three months and the raw chunks for that period have been dropped, the refresh recomputes those buckets from nothing and writes zeros over the history. So that's what the start offset is for. You set it narrower than your retention interval. It's also why our policies use null earlier. uh it's fine on a just a few day demo data set but it can be dangerous in production alongside retention retention. So, the last feature in this section is tiered storage. And it answers the question you should be asking after that retention section. Uh, what if I'm not allowed to delete? Because that can happen. Financial records with a 7-year requirement, medical data, anything where an auditor might ask a question about 2019. You can't drop those chunks. But keeping them on fast local storage uh is like if when that you're replicating that you're backing up nightly is not it doesn't make sense for data nobody reads. So there's a third tier we've already seen two row store for recent column store for older tiered storage adds object storage s3 class dramatically cheaper for the truly like cold stuff that you're not going to be using. So the most fascinating aspect of tiered storage is that you can still query all your data. The chunks are still part of your hypert. So you don't have to do anything different. And that's the whole pitch. The chunk moves out of your database's local storage and into a bucket. And it is still part of the hypert. No archive table, no separate query path, no restore process. The same select reads it. It's slower. It's going to going over the network to object storage, but it's transparent and correctness is unaffected. So, uh, what I just talked about is only a feature of Tigercloud. It isn't in the self-hosted extension you install from Docker because it requires the object storage infrastructure and the metadata plumbing to be managed for you. So I'm going to describe the API now and then later we'll run it for real on a tiger cloud service on real data and see a chunk move. So here is the taring API. So we can first we're we can add tiering then tier chunks older than a threshold. Here it's older than a year or we can move a specific chunk immediately or we can inspect what has been tiered. Select everything from time scale DB OSM tiered chunks. So same mental model as the column sort policy a threshold and a background job. So uh what should you use retention column stored or taring? Uh it's basically simple once you frame it as a who needs this data and how fast. uh if it's queried regularly and needs to be fast local storage column store if it's older than a day or so. Um, if it's rarely queried but must be retained, tar it. Uh, cheap, slow, still queryable, still part of the table. And if nobody will ever need it and no rule requires it, drop it. your retention policy. And and these compose our the our production shape would be raw data local for 3 months tiered from 3 months to 7 years dropped after seven average local the whole time because they're small. So the full life cycle. So that's the complete picture and I wanted to leave it on screen for a second because it's the thing that makes time scale DB different from bolting an analytics database onto Postgress. One table, one query interface data automatically walking down a cost gradient as it ages and finally expiring all that declared in about six lines of policy. Okay, let's talk about all the features. Uh just a summary of the features basically. So this will um if you remember the the shape just like the basics you can always look up the syntax. Hypert hyper tables partition by time automatically and chunk interval is your one real sizing decision. Indexes work as they always did with a uyuid v7 trick to keep them timeordered. Chunk reordering existed to give you data locality and the column store made it obsolete. The column store is the big one. 90% smaller vectorzed and segment by is the setting that decides whether it works. Continuous aggregates premputee incrementally, stack into ladders and stay live with materialize only equals false. Hyper functions give you gap filling storeable aggregate states and approximations you can premputee. Retention drops chunks instead of deleting rows. Tiered storage puts cold data in object storage without leaving the table. You now know more about timel than most people running it in production. What you don't have yet is the muscle memory. A schema you designed, a pipeline you wrote, queries you you tuned. So the next part of this course is focused on building. Okay, time to see how to set up our local environment. Now a lot of courses put setup at the beginning, but putting it here provides the necessary context. So you already understand the purpose of settings like max worker pro processes for background jobs. So now we're going to turn that basic container into a proper environment with a real project layout, tuned configuration, seated data, and essential to tooling. So everything from here on out uses what we build in this section. So in the other section, I just ran a bare docker run to get moving, but let's set it up the way you'd actually keep it for a project. So uh first we need to be in a terminal and create a dedicated directory for this course. Then get inside it. I already got in there. So now we are going to pull the high availability image. So I'm going to use docker pole timecale timescale db and then h a uh uh colon pg18. Basically this image right here is going to include time scale DB the toolkit hyperfunctions and PG vector and then you can tag it with the Postgress version you want and it could take some time to finish installing okay instead of memorizing terminal flags let's create a configuration in a docker composey file so I'll just do touch docker composey yiml to create the file. Now I'll go to I'll just uh edit the docker compose file and I've already added this to it. Uh four things are worth calling out. These are all the the setup stuff. So the t-stune memory tsune numbum CPUs um the image runs time scale db tune at startup which sets shared buffer workmen effective cache size and the parallel worker settings based on what you tell it. So we're going to give it real numbers what we actually allocated to docker and you you get a sanely configured postgress for free. leave it out and you get defaults meant for a just a really small workstation basically. And then we have a named volume um time scale db- a and then um oh actually the name volume is down here. So uh without this the data lives in the container's writable layer and then vanishes the first time you recreate it. So we have the path which is where the um the dash ha image keeps this data directory. So that's the image right up here. Um that's different from the plain postgress image and mounting the wrong path silently gives you a non-persistent database. We also have the SHM size 1 GBTE. The Docker's default shared memory is 64 megabytes. Parallel queries use shared memory for worker coordination and a big parallel aggregate which is most of what we do will fail with could not resize shared memory segment. So we need to raise it. We also have restart unless stopped. So it comes back after reboot and you stop wondering why your laptop lost its database. So I can save that and then we'll exit. And then we just do docker compose up-d it. And this is already starting actually or it's already started. And then we can actually um check the logs. Docker compose logs. Um and then once we see that uh we can see that the database system is ready to accept a connection. So I'll do Ctrl + C. And now we can set the settings that matter for time scale. So here I'm going to run docker compose exec which is going to execute a command directly inside the running DB container. We're telling it to open psql as the postgress use. So we have the docker compose exec which is going to execute a command directly inside the running db container. We're telling it to open the the psql as the postgress user inside the course db database. So now we are inside the um postgrql we can start using uh typing commands. So now that we have the SQL prompt and we can enable time scale DB and the toolkit. So we're going to do a create extensions if not exist timelb and then also times db toolkit and then we can do slashdx to see what has been enabled here or what we have here. Now we can review the time scale db specific configurations. So I'm going to check four critical settings that govern govern extension performance. We have the max background workers that we want at least one per policy so jobs don't quue and run late. Max worker processes we want to the this is the global ceiling covering background parallel and replication workers. Max parallel workers determines how many cores a single big query can use. And the shared buffers should be roughly 25% of RAM to keep active chunk indexes cached. So, uh, depending on what your max background workers is, we already have ours to the correct one, 24, but you can change it with alter system set and then max background workers set to whatever thing you want, which we do want 24. Now, we can um quit this with / Q um because we altered system level workers, the database requires a restart. And now we're going to restart the container with Docker Compose restart DB. So now we want to configure our psqlrc file. So we're just going to use cat and go right to our file that's going to be directly on the local home directory. And then psql will read this every time it launches. So we're going to make sure timing is on here. So every query reports duration. So you can track performance /x auto. Uh wide rows automatically flip to a vertical key value layout instead of wrapping into unreadable mush on small screens. Uh we're going to set the pager to less- sfxr. This stops line wrapping so you scroll sideways and exit immediately if output fits on one screen. Then we also have this um pet set uh line cell unic code and border 2. This adds proper box drawing borders for a cleaner look. Okay, here's a project we're going to build. So, here's the problem for the project. So, let's say you've built a an AI agent. It calls a model. The model asks for a tool. You run the tool, feed the result back, repeat until it's done, and it works great in development. Then it's in production and someone says something like our OpenAI build tripled or it keeps failing this one customer and then you just have logs. You have tons of logs, text logs, thousands of lines per run. No way to ask a question across all of them. So what we're going to do is build a flight recorder for agents. Basically, it's like the thing that aircrafts have. Every step an agent takes emits a span, a timestamped record of one operation with its duration, its outcome, its token count, its cost, millions of them. On top of that, live dashboards, P95 latency per tool, cost attribution per customer, erite error rate alerting, and the ability to replay an individual run to see exactly what happened. So, this is going to be a pretty good time scale DB project. Um the reason why I picked this is because uh agent telemetry has every property that makes time scale DB the right answer. It's high volume one agent run is dozens of spans. It's appended mostly. Uh spans are facts and you don't update them. Every question is time ranged like last hour, this week versus last week. It's multi-tenant which gives us natural segment by um also old detail stops mattering but the summary is never due which is the retention ladder exactly and every single query is an aggregate over window we'll use hyper tables uu ID a v7 primary keys column store with two segmenting a three-level real-time aggregate ladder storing stages like stat approx count distinct state a we'll also use gap filling retention ladder and So basically everything about times skill DB in one system and you also don't need an API key. So here's just a practical note. There's a real instrumented agent in the repo and I'll show you its code but there's also a simulator that generates 72 hours of realistic traffic with no API key and no cost. So um basically you can follow along completely free. So let's talk about what the dashboard like what data we need what what questions does this need to answer for a dashboard. So basically because that uh the like what the dashboard needs to say determines everything downstream. So let me list them because these come back as literal SQL later. Cost per agent per hour. P95 and P99 latency per tool. Air rate over time. Which tools fail most? Unique sessions per day. the distribution of runs of run durations, how long a run spends thinking versus calling tools and uh replay one specific run. So the question two is what is a row? This is just about how we're going to build our database. So um what's one row? The finest useful grain is one span, one LM call, one tool call, one retrieval. We could go finer like one row per token, but that would basically be useless. No one asked token level questions and it would multiply val volume by a thousand it. So corser one row per run that loses the ability to attribute latency to or to a specific tool which is uh question two on the list. So one row per span which is basically the finest grain that anyone actually queries. Next question we want to ask about our data is are we going to use one table or several? Well, here's where I'm going to deviate from what uh basically I want to show you the reasoning rather than the conclusion. An LM call has tokens and a model name. A tool call is a tool name and arguments. A retrieval as a query and a query count, different shapes, but um that would sometimes say separate tables. But if we look closer, they share 80% of their columns. Time, run, project, agent name, status, duration. The differences are two or three fields each and importantly u all the important queries like air rate latency and the runwaterfall they need to look at all span kinds together which would mean a three-way union all on every query excuse me so we're going to use one table with a discriminator so why it's okay is because one table with a like a span kind column and this the kind specific speific extras in JSON B. This is the exception to um the rule I talked about earlier. And the test for when to make the exception is do your queries treat these as one thing or as different things. So here here they're one thing a span that happens to come in flavors. Three tables will be normalizing along an axis. Nobody queries along. The tokens in, tokens out, and cost us columns that we're going to create will be zero for tool spans. That's okay. They're eight bytes of nullish data that run length encodes. So nothing after compression. So now the table and so let's actually see it in the file. Okay, this is going to be in our migrations folder 002 tables and we can see what we have in it. Every column here is a decision. Uh let me justify the ones that matter. So we have timestamp TZ. That's the first column. That's the um partitioning key right here. And agents run in every time zone. And there's no upside to ambiguity. And we this the span ID is a UU ID v7. So we see that here. Um agents generate span ids client side. They have to because the span exists before it's written and children reference their parent. So we need globally unique IDs generated without a round trip. And u v7 means they're timeordered. So the index stays compact and appends to the right edge instead of scattering rights across the whole B tree. random UU ID V4s here would be a genuine performance problem at volume. We also have the primary key span ID. Um so the partitioning column must be in the primary key time first. So the index is useful for range scans on its own. Then we have the parent ID here. Um that's a tenency column. I mean no the the parent ID makes the span makes the spans a tree which is what lets us render a waterfall. It's the project that's the um the dependency column. So therefore our segment by was we're going to use this for our segment by low cardality dozens maybe hundreds of projects every dashboard query filters on it. And then we have the status um that is going to be a text rather than a boolean because there are more than two outcomes. Okay, air timeout refused. That distinction matters and refusal isn't a failure of your system. And then we have the um attributes which is a JSON B. It's the long tail, the model's temperature, the tools argument shape, the retrieval result count. So basically things we might look at but never uh uh that we're not going to filter on daily. So uh now the arithmetic about this chunk interval. So the a busy agent platform say 200 agents runs a minute 30 spans each. That's 6,000 spans a minute. Call it 8 and a half million spans a day. Our row is roughly 160 bytes with the JSON being mostly empty. So about 1.4 4 gigabytes of a table a day plus ind indexes about 1.8. Index volume is the number that matters most. Roughly 400 megabytes a day. On a 16 GB instance, 25% is 4 GB for the active chunks indexes. A day of indexes is well inside that. A week would be under 3 gigabytes. Also fine actually. So why daily instead of weekly? Well, retention granularity. We're keeping rows raw spans 14 days with weekly chunks. A chunk can't be dropped until its newest row is 14 days old. So, we'd carry up to 21 days. With daily chunks, retention is precise and chunk count is bounded. Uh 14 days of raw data is 14 chunks and nowhere near a thousand. So, daily chunks when retention is tight, chunk interval follows retention. So I'm creating a new file uh hyper tables. So we'll create this hyper table agent span in our hyper tables file. And then um I want to talk about the metadata tables. So these are just basically two small relational tables because um we'll need these for what we're doing and these are already in the um table file. So create project create model price. So we have monthly budget USD it's numeric because it's money and we have the duration milliseconds. Oh I mean and then we also have the the model price. Now this is just an example here because um this is going to change but we can store the price table for reference but we do have to we we'll compute the the cost at right time and store in on the span and then we're also going to have some examples for um an another uh function or to insert into these items into the pro into the project. So we're going to have just three indexes. So we'll add this to our indexes file. So I create this indexes file and we have agent span or we have create index on agent span uh the project. Uh basically this is for the dashboard's main path. Then we have it time descending. Then we have the run id end time for the run replay which is the which is a point lookup by run and then a partial index on failures only because errors are a small fraction of rows. So an index covering only them is tiny and show me recent errors is the single mostrun query in any observability tool. So I'm not creating an index on spank kind or on status generally. Uh it's low cardality and after the column store conversion those become batch skipping metadata anyway. So one more table which is going to be runs. So I'll create this runs.sql file. So we're going to keep track of the agent runs. So we have span count and cost USD are derable from the spans. But I'm storing them anyways. And because uh the run list is the most loaded screen in the whole app and I don't want to aggregate a million spans to render it. So we write once at run close and then we can read that forever. Okay, the scheme is done. Now something has to write to it. So I do want to be clear about what we're building because the instrumentation pattern here is the reusable part. It works for any agent framework and really for any event stream at all. An agent does work. Every unit of work is a span, an LM call, a tool call, a retrieval. We wrap each unit, time it, record that what happened, and hand it to a buffer. The buffer flushes to PostSQL in batches. The agent never waits on the database. So, I just made this recorder.py file. And remember all this code. You can check out the link in the description to get this code. So this is a process queue and the col the column list once it's once so the writer and the recorder can't drift apart. And if we go down we can see this span function. Here's the things I want you to notice. We have this use UID7 and then we're going to take the a timestamp here and remember the check constraint on the table the UID timestamp span ID equals time. So remember this check constraint on here's the check constraint on the table uids matches time. So that's why I'm doing this. If if I had generated UI ID from now and the time stamp from a different now and those two would differ micro by microsconds and every in insert would fail the check. So we just generate the time stamp once and then we derive both from it. So this helps and then we have this finally block and the span gets recorded whether the work succeeded, failed or blew up. observability that only records successes is worse than none because it can make you too confident. And then we can see that the exception handlers raise we classify the failure and then get out of the way. A recorder that that swallows your exceptions is uh not good. So we have a copy batch function and down here we have the writer loop function. So basically we're going to write a batch with copy and this is you can just use either copy or insert. Basically it's just going to write right into our database. And these are all helper functions or that we'll use later. But one thing to kind of focus on is our batch roads and batch seconds. So this is how long we're going to just flush everything. So the flush uh condition is the the do line which is just right here in the writer loop. So the length uh we're we're using the batch rows or the batch seconds as we just talked about. And so it's either going to be 500 rows or 1 second whichever one comes first. A busy system in a busy system you get full batches and maximum throughput. But in quiet a quiet system, you still get your data within a second. Without the time limit, a low traffic agents spans would sit in memory indefinitely and your dashboard would look broken. And note the reconnect on failure down here. It's going to reconnect on failure because background writers must survive a database restart because they will experience one. So basically together the copy batch and the writer loop functions here decouple the caller's execution speed from database latency which can transform thousands of tiny events into massive bulk writes. And to kind of give a little more information about the the writer loop, this acts as an asynchronous background worker that decouples the main thread from database writes and it buffers events in memory until they hit a specific size or time threshold. So you can see it has a non-blocking architecture and uh we already talked about how the dual trigger batching with by size or time and the to kind of just talk a little bit more about the copy batch. This streams in an entire batch of in-memory rows directly into time scale DB using PostgresQL's native streaming protocol. That's the the copy that we talked about and it by bypasses the standard SQL parsing overhead. Now copy just to talk a little bit more about copy which is right here the copy command. Uh it's pretty important for time scale DB because time scale DB hyper tables route data into underlying chunk tables based on time partitions. So using copy feeds rows directly into the storage engine in bulk allowing times table DB to handle thousands to hundreds of thousands of rows per second with minimal lock contention. Now we'll come to back to this file but I'm going to create another file pricing and just paste in code. Now we already saw our pricing in our tables where we're adding some pricing in there but um here we're just kind of hard coding these in again. Now, these numbers are illustrative and they will basically be out of date by the time you watch this. So, you're going to have to actually just check the vendor's page for real ones. But what matters is the shape. Input and output are priced differently. Output usually costs several times more. And that the asymmetry is why make the model more concise is a real cost lever. Now, we're also going to have a file called agent.py. Now, this is a small tool using agent uh with three tools. Now, we're not going to go through all of these because we're mainly focused on the database section, but we have a a web fetch, a calculator, and a database lookup. And it fails about 8% of the time on purpose. That flaky tool is basically a valuable line of code in this project because it manufactures the errors that make the air rate dashboard show something. Now, this is going to basically require an OpenAI API key, but you can uh test this out without one by just using the simulate.py file which is going to be in the repo. And if we go here on the run um we can see that this is the whole integration surface with one width block around the call we already making with three assignments from the response. Tool calls are the same shape with kind equals tool. Here's the kind equals tool. So basically we are adding either the LM calls or the tool calls to our database. And here's our simulate.py that's just going to simulate if we don't have an API key. I'm going to just run this simulate file to get in some data here. But first we actually have to make sure our database is set up correctly. So I already have my Docker container running and then I'm going to create my database docker compos exec and then we're going to run psql and then we're going to create the database flight rack. That's the one that our code uses. And then we already created all those migration files. So, I'm just going to create this loop that's going to run every one of the migration files. Okay, I got everything created. Now, I can run our command, our simulate command. So, this is going to take a little bit. And it's not just creating random noise, which would teach us nothing. There are four things built in. It's basically creating three projects, eight agents, 72 hours of history at 40 spans a second. So we have this simulated data and it's going to have this dal pattern. Basically traffic peaks midday, troughs at night. Any real dashboard would show this. And if your test is flat, you won't notice when your gap filling is wrong. It's also going to use a realistic latency distribution. Um the it's log normal not uniform. Most cells fast a long right tail and that tail is why the P95 exists. Also air bursts rather than evenly sprinkled failures. Real systems fail in clumps and one planted incident. The specific tool gets dramatically slower for a 40minute window at a known time stamp. And uh in the query section we're going to find it using nothing just using aggregate queries. and then we can explain it from raw spans. So, um I would recommend using simulated the simulated data if you're following along. Okay, I've created the data and now I'm going to go run go where we can access the database which is called flight wreck. Okay, let's just count everything here. And so we now see that there are over 9 million spans and we have a size of 48 kilobytes. So 3 days of history but nothing is tuned yet. So let's fix that. Okay, we are going to make it faster. So baseline first. Never optimize before you've measured the thing you're optimizing. So let me turn on timing. I mean, I should already be on, but we'll just make sure. Now, we're going to do select project agent. We're going to count uh everything as spans, then get the average duration, the we're going to get the cost uh everything in the last 24 hours. Okay. So, here is our result here. The projects agent spans and we can see how many spans are for each project and agent. And this took 665 milliseconds. So we will compare that after we make some changes here. So now I'm going to set create a new migrations file and we're going to set up the column store. We're going to go straight to nine and we're going to fill in the rest later. Okay. So here's our column store file and let me show you what how we're setting this up. So we have segment by project and agent. Project is the tenency column. Every single query filters on it. Agent is the second most common filter and there are eight of them. So batches stay comfortably over a thousand rows. I did not use name even though we filter on it a lot. There are a dozen of of there's a dozen of tool and model names and combined with project and agent that would shatter the batches into fragments. We also have order by here and we are using time first always because every query has a time range and that gives us minmax batch skipping and then we have span kind. So lm span and tool spans cluster separately. Uh most panels ask about one kind at a time. And then we have the compress index with the bloom and minmax. A bloom filter on name because show me spans for the DB lookup tool is an equality check and name isn't in segment by minmax on duration ms because spans slower than 5 seconds is a range filter and that's literally how we we hunt the planted incident that we will use later. And remember this rule there are builtin conversion times. So set them now before we convert anything. So we have this column store policy. Um for interval is two hours not one agent spans can arrive late and a run that hangs for 10 minutes uh writes its parent span 10 minutes after it started. So I want a wider grace window on the row store side and because our and then I also create some indexes here materialized view and a column store policy. So I'm going to set this up and we're going to do a test. So first I'm going to run the migration file and we're going to create these later. So I'm not going to worry about that. And then I'm going to go back into psql and because the data is historical we're going to force the backlog. So convert to column store anything older than uh from show chunks the agency older than two hours. So first I'm going to try to find the job ID and then we can run it. So we did this because both convert to column store and run job are postgress procedures rather than plain functions. So we can invoke them with the call command. And this basically kicks off the backlog conversion. And then we'll run the same command we ran before we set up the column store. Okay, now it's only 215 milliseconds. So we've saved some time. We got the same query, same answer, dramatically faster and a fraction of the disk. So that's one alter table and one policy. But we can do better than a faster scan because we shouldn't be scanning at all. Okay. Now I just created this new file 06 span 5 minutes and we are going to be talking about hyperfunctions. So we're basically creating a materialized view. This is going to basically be the last five minutes. And if we look at the aggregates list, every entry is a very deliberate choice. We have the count and we have a filtered count here. And they are non spans. So two integers and from them the error rate of any granularity because counts will reagregate. And then we have these sums here. The sum of the cost and the tokens sums reagregate. Then we have the states. We have um basically three states. We have the stats. A is the first state for average and standard deviation. Then the percentile egg for P95 and P99 and approximate count district for unique sessions. None of those could be stored as finished numbers. Averages an average of averages is wrong. Percentiles percentiles is meaningless and count distinct double counts across buckets. stored as states all three will merge correctly. There's no average column anywhere in this view. Then if you scroll down here we are going to do a add continuous aggregate policy but the schedule interviewable is 1 minute. This feeds an ops dashboard in offset the end offset is 10 minutes which is twice the five minute bucket per the rule. The start offset you can see is seven days and that one is not arbitrary. Uh raw spans are retired are retained 14 days. So a 7-day refresh window is safely inside that. So it's basically a rung rungs on a ladder from 5 minutes hourly and daily. So we are each row above is built from the run rows below. And so we're going to create the other ones now. So here's my span hourly. So we're going to have these sums and we are going to roll up the states. And that's pretty much it. So the file is um not very long because it's built from the previous file. And now I have my span daily file. And you can get all this code um from the uh GitHub repo. So we've dropped name at the daily level. So because nobody's going to ask which individual tool was slow on a yearly cost chart. And then this is dropping name is going to collapse the row count by more than an order of magnitude. So that's going to have coarser dimensions. And now we can actually use these in a file that we've already created, the column store file. So that's what um we got here. So here we're actually using these files. We're creating an index using these uh things that we just created and we're creating a materialized view and then the column. store. So the these are basically all using these files that we just created. And now I'm going to make one final migration for retent for retention and we're going to create the retention policies. Now I'm going to run this command in my terminal to run all the migration files. Some of them already exist so won't create them again. Now go into the psql. Now let's look at what this aggregate ladder actually costs on disk. The one we just set up. But to do that we have to write a slightly weird query. So before I run this let me explain why we can't just ask for the size of the views directly. When you write a continuous aggregate time scale DB creates a hidden internal hyper table behind the scenes to store those precomputed buckets. If you run a standard Postgress size check on span five min, it misses the actual data. So this query does a union. The top half gets the size of our raw agent span hyper table. The bottom half queries time scale with DB's metadata to find the system generated names of those hidden hack hidden backing tables. It circulates it calculates their true sizes and stacks them together so we can compare them side by side. So I'm just going to run this and then here we get the results here. So look at the size gradient. So the it goes from 494 106 49. So it's slowly going down and then we expect the daily to be the largest here. So this is basically the whole architecture in this one output. Our raw data is almost 500 megabytes, dropping down to five minute buckets, cuts down to roughly a fifth of the size. An hourly is a fraction of that. And the daily summary, which contains exactly what we need for a year-over-year chart, is half a megabyte. It's kind of just like a rounding error on your disk. So, raw data is big and shortlived, but summaries are small and live forever. Now let's ask it some questions. So let's find the unique sessions per day. So we can see there's 900 on each of these days. And it took us 29 milliseconds here. And we're going to count it in a different way here. And this time we're getting basically the same number of unique sessions, but it took a lot longer. Uh 300 3,647 milliseconds. That's because this first example ran our aggregate ladder which is what we created to make things faster. The distinct count rollup sessions. Uh so remember when we built the five minute aggregate we didn't save a number for sessions we saved the state using approx count distinct under the hood. That creates a hyperlo log sketch. The roll-up function merges those five minute sketches into daily sketches and distinct count extracts that final number. And so this was the more traditional SQL way of getting the data where we got the same data but it took a lot longer. And the the speed isn't actually the most important difference here though. It's that the fast query is architecturally possible at all. You cannot premputee a standard count distinct into a materialized view and then roll it up later. If a user logs in during the 9:00 a.m. bucket and and the 10 a.m. bucket and you try to sum those buckets later, you double count that user. The only way to get an exact distinct count across a wider time window is to go all the way back to the raw data and count again. Hyperlog log states solve this. The sketches can be merged together mathematically without double counting. This is the difference between a metric you can put on a fast live dashboard and one you can't. If you need the exact number for a monthly invoice, run the slow query once a month. For a dashboard that uh nobody stares at to six digits because it doesn't have to be so exact, the hyper log log estimate is going to be a good engineering call. So everything so far has been aggregate queries. But when an agent misbehaves, you need to see the actual sequences of what it did. And so that's basically the raw hyper table where the full fidelity data still lives. Okay, I'm looking for when the project is acme and the status is error and I want to get one of these run IDs. So the point of this is we're trying to find the actual sequence of what a specific agent did that had an error that misbehaved. So we need to go to the raw hyper table. And so this is one of the runs that failed here. So let's try to reconstruct its exact execution waterfall. So I'm going to paste in some SQL code that we're going to do. So let me break down what this query is doing. To draw a waterfall UI, we need to know what called what and we do that with the union all. So um the top half grabs the the root the root span which is like the main entry point for the agent where parent ID is null and it assigns it to a depth of zero. The bottom half grabs all the child operations where parent ID is not null and assigns them a depth of one. Then in the outer select we use repeat depth. I mean in the outer se select we use the repeat coales depth zero to visually indent the child spans under the parent. The coles is just as there's a safeguard. So if the depth ever somehow evaluate to null, it would default to zero instead of wiping out the entire text string. So let me run that. And we can see here there's the run perfectly ordered by time. The LM thinks, it decides to do a tool call. The tool fails, it retries, it thinks again, and it finally answers. This is in indented timed and cost cost wait where we can see all these things the costs that's the the flight recorder doing exactly what the name promises and notice is notice the speed we just pulled a needle out of a haststack of millions of rows in milliseconds this is because back in the schema section we created an index on the run ID and time scale DB uses that index to bypass the partitions entirely and jump straight to this part specific run. So this is a two a two-level workflow. This is the operating pattern for the entire project and for uh most observability systems. Aggregates define the problem raw data to explain it. And so it's fast and cheap on the wide question and full fidelity on the narrow one. Neither one alone is enough. So we just looked at a waterfall of individual spans, but often you just want a simpler answer. a simple you want to answer a simpler question. What is the actual bottleneck of this run? Is the LM slow or are the tools slow? So here's some more SQL here. Okay, let me put in some new SQL here that I've already run. Notice that last line of SQL. Basically, actually I'm talking about this one right here. We are doing a sum. We're doing a sum of a sum using a window function. The inner sum gets the total time for that specific span kind. The outer sum with over calculates the total time for the entire run across all kinds. We cast it to numeric to run it cleanly and dividing them gives us a perfect percentage without having to write a subquery. For most agent runs, the answer surprises people. The model isn't the bottleneck. The tools are, which fundamentally changes what you'd optimize. No amount of prompt engineering fixes a slow database lookup. Now, if you want to track this over time across thousands of runs, doing that math on the fly gets heavy. So that's why we used the state egg hyper function, which models exactly how long a system spent in discrete states. And let me show you that we are doing a group by in the run ID run. We're doing group by run ID in this sub query state a models a single state machine meaning a system that can only be in one state at a time. If we tried to aggregate the entire project into our timeline, the database would throw an error the moment agent A started an LM call in the exact same microscond agent B started a tool call. So by grouping by run ID, we correctly treat each agent run as its own independent timeline. Once those individual timelines are built, we use duration n to extract the micros secondsonds spent in each state and clearly sum them up across the whole hour. Build the state and read the answers. Okay, now let's uh test everything out. I planted an incident in the stimulated data. One tool got dramatically slower for about 40 minutes. So I know when but and but now we're let's see how we would find find it in the way you'd find a real one with no idea what we're looking for. starting broad and zooming in. So the first step is is anything wrong at all? So we're going to start with the widest possible view using only aggrits aggregate uh hourly aggregates. So this is going to um give me all some information. We are selecting um basically for every we're getting these 1-hour buckets and we are looking for the project acme and the scan of tool. So we can see a few of these are a little different than the others. Specifically a lot of them are 1843. This one's 1729. This one's 1729. So, I'm going to look at all these uh in uh all this P95 milliseconds and we can see uh actually took me a little bit to find it, but it's like you can see this one is well above all the other ones. But if we look at the spans column traffic, it looks pretty normal. So, this isn't a load spike. something specifically got slower. So step two is trying to figure out what tool. So let's keep the hourly window, but now we're going to group by name. Okay, I'm running this code to like look at the different tools. And we see web search is taking over 10 seconds while everything else is operating normally. So now the next step, step three, is trying to find out exactly when did it start and stop. So, I just ran this code here and we're going to drop down a rung on our aggregate ladder to the five minute buckets to get precision. So, we're doing from span 5 minutes and we can see that most of these numbers are under 3,000 milliseconds. But then right around here, we can see it shoots up. So, now we have a tight window. And notice the shape of the recovery because that shape is diagnostic. A cliff edge means a deploy or a config change rolled back. A gradual ramp means the satur means saturation recovering. A Q draining a connection pool freezing up. So now then step four the next step is to explain it. So now and only now we drop down to the raw spans. But because we marked down the time window and the tool name, this query will be instant. So we do this query with the tool name and then the time that we found out from the last query. And look what we see here. So now we can see the exact root cause sitting in the JSON uh attributes payload. Read time of upstream did not respond in 10 seconds. uh the web API went down. The Python agent waited the full 10-second timeout limit before giving up, which caused every single run in that window to stall for 10 seconds. So, let me just name what happened because it's the thesis for a lot of this course. So, we had three days, millions of spans, four queries, each returning in milliseconds. We walked from something feels wrong to here is the exact tool, the exact 35m minute window, the exact error message from the HTCP client. The first three queries read premputed aggrids. Only the last one touched the raw data and it touched a tiny slice of it. That is what a lot of these features are for. The ability to ask a chain of questions fast enough that you keep asking them instead of giving up. Okay, time to put a face on it. We have the queries. Now let's them wrap them up in an API. So we can actually look at a dashboard. This is where continuous aggregates stop being a database concept and start being visibly obviously useful. So the the design rule for the entire backend is strict. One endpoint per panel, one SQL query per endpoint and that query hits an aggregate. No RMS, no query builders, no fetching thousands of rows to join them in Python. The database does the math and the API just delivers the JSON. So we have this just to set up our API file and here is where we are starting our connection basically to get get to get all the data. Now production we we use a scopy pool not a connection per request but we're we're just uh we're not going to detour into pooling right now. And then we have our API endpoints. We have our health endpoints which is this liveness probe. Here's one of our main panel or endpoint for our main panel. Um the cost. So that's the exact qu that's the query we wrote in psql par parameterized cleanly using scop g's named parameters. Every other endpoint ex is exactly the sha the same shape. So I uh and these endpoints don't touch raw data. So before we look at the dashboard, let's verify that claim. But uh let me just show you the other endpoints. We have the latency endpoint. And we can see a lot of these are what are commands that we've already ran that I showed an example, but now we're just putting them into the API. So that's for the errors. And then we have the top failures sessions, how many sessions per day and you can see this is all just raw SQL using everything we have been learning. Then we have histogram and remember you can see all this code yourself right in the the code link in the description. We also get the the information for one run. So, I'm back in psql and then I'm just going to run this query to see that it doesn't touch the data. Okay, let me zoom out a little bit so it doesn't go over so much. Basically, in this query plan, it pulls a handful of chunks from span hourly. It scans a few hundred rows. There's no reference to agent span anywhere. The raw table has millions of rows and this dashboard query literally does not even know it exists. A dashboard whose computational cost is proportional to what it displays rather than to how much data you have ever collected. It's better. Your dashboard is just as fast on day one as on day 1. Okay. Right now I'm in staticindex.html and we are going to basically look at the front end. So the front end is just one HTML file. If we go down, we can see that in our code, we fetch the endpoints. Um, we draw the charts and we set a loop to refresh every 5 seconds. Now, I'm not going to go through this in detail. This is not an HTML JavaScript course, but you can look through the code of in the repo if you want. Okay, it's time to actually try this out and actually run this demo. So, this is the thing that we've been building for a while. So, I'll just run uicorn and it'll run the application. Okay. And we have it running down here the terminal. But now we can we can see this. So we have all this information about the spin the spin spans air rate worst P95 airate lag and then look at these charts can go down tool latency percentage we got the error rate latency distribution we got the top failing tools and check this out I can change the window so we went from 24 hours let's go change it to 72 2 hours and we can see the charts change in real time. Oh, and when we change the 72 hours, we can see we got a big error rate right here. So, we can even zoom in or zoom out with our mouse wheel on here. And it's really nice to have the this top failing tools thing here. We can also change the different projects as well. And right now we're not getting new data, but we're going to simulate giving getting live data. So I'm just going to open up a new terminal window and I'm going to run Python simulate live rate 60. Let me change the window just last six hours. Now let's just watch the right edge of these charts. So now we can see data coming in here. So, we didn't have data between this gap here, but now we have the live data coming in and we can see the charge updating in real time. So, the refresh job runs every minute. So, the last minute of data has not been material materialized. That data is coming from the live half of the real time aggregate. time cell DB is reading the newest rows straight from the raw hyper table and unioning them onto the precomputed history old data premputed and instant. So new data live. So basically it's combining the new data with the old data um seamlessly. Okay, let me just clear or stop the simulator here. So new more no more new data is coming in. So the traffic data is now going to stop. The current bucket will finish filling and within a minute the refresh job materializes it. And then it will basically get a gap just like we have here. So that's a production-shaped observ observability stack instrumentation batched ingest column store an aggregate ladder and a live dashboard reading only summaries. Now let's talk more about the retention policy over here. So this makes it uh it makes it so it doesn't fill the disk. So this helps the system not to grow forever. So we have a retention ladder. It's a tiered retention pattern now that we have a concrete reason for every number. So let me just talk about each one because how long do I keep data is a product question disguised as an ops question. So the raw the agent span or the raw spans we keep for 14 days. Individual spans are only useful for debugging something recent. Nobody replies a specific agent run from 3 months ago. And if they need to, that's what your log archive is for. 14 days covers what happens last sprint. Then runs we keep for 90 days, much smaller than spans. And the run list is what people browse. It's cheaper to keep longer. Then five gra minute buckets for 90 days. Five fine grained enough to investigate an incident and a quarter is how far back anyone actually zooms at that resolution. an hourly for one year, year-over-year comparisons, capacity planning. Um, then daily we keep forever. It's tiny. Compressed daily summaries for a whole year are a rounding error on your disk and executives ask about last year constantly. So you should ensure the retention policy doesn't delete data that the continuous aggreate would still refresh or your aggregated data will be deleted too. Our five minute aggreate has the start offset of in interval seven days meaning it's willing to recomputee any bucket in the last seven days raw spans are retained 14 days. Seven is less than 14. So every bucket the aggregate might refresh still has source data behind it. So let me help you understand how this can fail fail with fail with an example. So, I'm back into psql and I just created a new hyper table called foot gone raw that's going to have 30 days of history. Now, I'm going to create a daily aggregate over it and refresh it. So, we can see that we have about we have 24 day rows per day. So, that's perfect. 24 rows seen per day and the summary is correct. Now let's play the part of a retention policy and delete the raw data than 25 days. So we're seeing what happens when you accidentally destroy data that that you shouldn't destroy. So I'm deleting from foot gun where time basically um when it's older than 25 days. Okay. Now we can see that when we query the last 25 days um these have all now been deleted. Uh we only it the the most basically recent row is on the 20th or the oldest old rows on the 20th instead of the 19th 18th. All these other dates have just basically been deleted. And I want you to understand how this happened. Because we didn't restrict the aggregates refresh window with a start offset. The refresh job woke up and asked the database, hey, let's recalculate the count for 28 days ago. The rod table just answered, I have zero rows for that day. And the aggregate faithfully wrote that down. It overwrote your correct historical summary with a correct looking nothing. There's no error. There's no warning. There isn't even a log line. So your yearly chart just develops a hole where the past used to be. you find out months later uh when you finally try to look up the data and note the ordering because um the gap between you made the mistake and you can see the mistake can be days which makes it hard to uh debug. So let's just drop that table in the view right now. So basically every aggregate start offset must be shorter than its sources retention. This is the single most common way people accidentally destroy data with time scale DB and it's entirely preventable with one simple mathematical comparison. So I just ran this command here um which is going to put every automated policy on your system in one output. The retention compression um it will even uh show column store refresh along with their intervals. And then we can eyeball the refresh out offsets against the retentions and then it just helps us check what all the data is, what all the um configuration is stored at. So now I just have run this command here to get the bytes per span. So we can kind of see how big everything's going to be. We can see it's it's close to about 200. We'll round this to 200 about 200 bytes per span. So let's look at three scenarios based on that footprint. It says 25 after column store. That means after the column store compression. So for a hobby project, let's say you process 10,000 spans a day. That's about 2 megabytes of of raw data. With a 14-day retention, your total raw database is under 30 megabytes. Your aggregate tables are kilobytes. You'll never even think about storage costs. But if you're a startup, maybe you have 10 million spans a day. It's pretty busy. it. It's a profitable agent project. That's about 2 gigabytes a day uncompressed. Dropping to 250 megabytes a day compressed. Keeping 14 days of raw traces takes roughly three and a half gigabytes plus a couple of gigs for your aggregates. That fits comfortably on the smallest cheapest paid database instance available. But if you have a very large heavy project, 500 million spans a day, you're generating a 100 gigabytes of raw te telemet telemetry daily. Compression drops that to 12 gigabytes a day. So retaining 14 days takes under 200 gigabytes. At this scale, this is where tiered storage earns its keep. You keep the most recent three days of chunks on expensive fast local SSDs for live debugging and you automatically push older chunks to just cheap external storage. So that compares the comparison that matters without retention and the aggreate ladder that startup case is 2 gigabytes a day forever 700 gigabytes in the first year and every dashboard query gets slower every single day. with the ladder storage plateaus at a a few gigabytes and query time is flat forever. So that's just the same data, the same questions, but one of those systems can um you can basically leave alone for 30 years for three years because it's not going to get too big. So four things I'd add if I were going to get this to production. Um first sampling for cheap spans. Not every successful 12 mil millisecond calculator called needs to be stored. You can keep all errors, all slow spans, and one in 10 of the boring ones. Your volume drops by most of itself and you lose nothing. You'd have you'd have to look at also um PII scrubbing before writes prompts contain user data. You scrub in the recorder, not in the database once it's written and it's in your backups. Alerting on the regression query. Uh, we already wrote it. Put it on a cron with a threshold and web hook. Add semantic search over failures with PV PG vector. Uh, embed prompts. Then ask, show me run similar to this failure. It's pretty helpful when a customer reports something vague. So, let's just overview what we built. So what we built a a sche a schema with the compound primary key and UU ID v7 identifiers batch copy ingest that doesn't block the agent a column store configuration tuned to how the data is actually queried three aggregate ladder storing states rather than the results eight real operational queries one of which found a planted incident in four steps a live dashboard reading only summaries and a retention ladder that keeps the whole thing bounded forever that's a production observ observability stack. It's a few hundred lines of code, one PostgresQL extension, and no other infrastructure. So, I need to use Kafka, no separate analytics warehouse, no data pipeline, just a database. Okay, we're finished with the first project and it's time to make another project. Everything we've built so far has been running locally in Docker. That's the perfect place to learn and build and is completely fine for running small workloads in production. But time scale also has a managed cloud version and it's built by the exact same team behind the extension. It has some architectural features that only exists in the cloud. So we're going to move over to it. We're also going to build a completely new project so you can see these patterns applied to a differently shaped problem. So we're over on the Tiger Data website. You can see it says from the creators of Timecale DB. And if you sign up, you get a 30-day trial of their performance plan. You don't even need a credit card. And that's plenty of time to just try it out and um and even try migrating a test workload. Also, they have a free shared CPU service which are great for learning inside project. So, just uh get logged in or sign signed up for the service. So after you get signed up or signed in, you can start creating your first database. So I'm going to you can choose any sort of CPU. I'm going to choose the free tier and continue. And then you can change your name. Uh I'm just going to call it fleet. You can call it anything you want. And then create service. I'm just going to create basically use the defaults for everything. My service is ready. So now that we've created everything, we get all the connection info. So we have a host, a port, a database name, a user, and a password. And it basically all goes into here. Our primary connection string. That's it. It's just like a standard Post SQL connection string. Okay. I'll connect using psql on the command line here. And then I'll just pass in that URL that we got from Tiger Data. And you'll notice this SSL mode equals require in the connection string. That's mandatory when you're connecting over the open internet. And the console gives it to you by default. And it's going to ask for my password. So I go back over to the dashboard over here. If I go to more details and I can say forgot your password and then just create a new password if you don't know what it what it is and then I can just type it in here. Okay, let's check our extensions. So I'll say select X name X version from PG extension or by X name and we can see here timecale DB and time scale DB toolkit are already installed and active. No compile steps, no modified anything. It's just there. So, there are other extensions installed on the machine that you just enable yourself. I'm going to turn on the extensions uh PG vector and PG vector scale which makes vector search faster at scale. We're going to use those at the very end of this section. And I want to test something right now. Basically the hyper table, the column store, the continuous aggregate ladders, the retention policies, all the things that we've kind of discussed earlier in the course, they should run against this managed service without changing a single line of code. So let's find out. So I'm back to my command line and then I'm just going to try to run all my migrations and put in our tiger data URL here. And it it's done. It actually ran every single migration and it's all now in the cloud. So if I go back into psql into the database on the cloud and we see this we ran this query and we see uh numchucks zero num num chunks are zero. Uh that's exactly what we want and what we expect. I mean time scale DB creates chunks dynamically when data actually arise. We just create the schema on a fresh database but until we insert rows it doesn't waste disk space pre-allocating empty chunks but the tables the views and the policies are all perfectly configured. And now after we've run all the migrations, I can go into our web dashboard and we go into explore. We can see the different objects. These hyper tables have been created, the tables that have been created. We can see how many of everything we have. We have some continuous aggregates. And if we go into the table, we can see some information. Right now, there is no data in the table, but if there was data, we would see the data here. And we can see all the fields here that we created right from our migration files. So basically we now have every table, every materialized view, every policy executed perfectly. Not one line of code changed. I want to emphasize this because it's the strongest practical argument for using this stack. your local development database running in Docker, your CI environment, and your managed production database can all run the exact same schema. You can develop locally and deploy to the cloud or pull a snapshot from the cloud to debug on your laptop. And if you ever decide you want to leave the managed service, you are leaving with a standard Postgress dump that will restore anywhere the extension is installed. That's a meaningfully a meaningfully different position than adopting a proprietary cloudonly database. Now clicking around a web console is fine for day one, but if you're managing this every day, you want a CLI. So I'm just going to run brew install time scale tap tiger CLI. The arts arm 64 is just for my particular computer. Okay, it tells what to do next. And I'm going to do tiger o login. And then it's going to open my browser for authentication. And then once it's authenticated in the browser, it will now continue with the the login on the in the CLI. And then it gives a few different next success. We can get an MCP server for for AI coding tool. List list existing services. Create a new service. I'm going to list the existing services. So, tiger service list and here's the service we created we just made in the browser and you can provision infrastructure directly from here too. So, I'm going to do tiger service create-c shared and then memory shared. And now it's creating this service. So, that single command spins up a free shared service. No browser required. Okay, now it's going to give me all the information about that service that we just created. Now I can just run tiger db connect. Now that command just drops us straight into a psql session against our database without me having ever having to copy and paste a connection string. So we can start doing stuff in our database. So, I'm just going to go out of this with slash Q. I'll clear my screen. And before we go into go any further, I want to set up a guardrail. So, I want you to see me just do this intentionally. I can do tiger config set read only true. Now, if you remember in our after we logged in, it asked the question about this, but this is how we can manually do this from the from the the terminal. So set readonly equals all. So what this does is configure the CLI so that mutating commands are refused and any database sessions it opens are forced into readonly mode. So if I go to tiger service delete and then this is the ID of the one we just created, it's going to fail. Error. This operation is not allowed in readonly mode. So, it doesn't just ask, "Are you sure?" It just stops you. So, here's what I think on connecting AI coding agents to your database. Language models are very great at exploring a schema and suggesting optimizations, and they're also completely capable of running a drop table command because your prompt was slightly ambiguous. So, the answer isn't never use AI with your database. The answer is to make sure the destructive path is impossible. Uh turning on readonly mode takes just one command and it turns a risky experiment into a safe one. MCP or model context protocol is a standard way to hand external tools to a language model. And as we kind of saw already, Tiger ships an MCP server directly inside the CLI. So your AI coding agent can talk to your database. So we can so we can install that which just tiger mcp install and then we can choose which one we want to configure. Uh depending on what you use I'll just use cursor here. And now it's already it's basically configured successfully installed tiger mcp server configuration for cursor. So it tells our next step by just restarting the cursor to load the new configuration and then it'll be tiger and then it gives us some things we can um try testing with one with. So let's actually try it out. I have cursor opened and then I'm just going to go here to cursor and I'll just say list my tiger services and tell me which region each is in. And then I just have to make tell it to always run our MCP server for Tiger Cloud. Okay, we got the information. These are the two the one we created on the website and the one we created in the CLI. And then we have the the regions. We have two region. We have two cloud services both in US East1 which does happen to be the region closest to where I live. So instead of looking up the CLI flags, it just calls the tool and gets the data. Okay, let's try another one. Describe the agent span table including its hyper table settings and column store configuration. So this is even more interesting. It's not just uh reading the Postgress tables. It's reading the time scale metadata. Okay. Okay. And it looks like I need to save my password from the the fleet table locally. So I'll just do tiger tiger db save password and I can put in the password for the table that we developed. And then I'll just tell the AI agent that the password's there. Okay. And now it did what we asked it for. So it described the agent span table and then including the column store the hyper table and it knows all everything including the the chucking chunk interval and everything like that and one more question given how I query agent span is my segment by configuration reasonable what would you change so now it's going to use information from that's getting from tagger data And it's going to reason about my cardala cardality and query patterns which are the concepts we discussed previously. It can do this because the tiger MCP server actually has time scale skills and documentations wired into it. It isn't just guessing based on generic Postgress training data. Okay, it's giving me some uh suggestions. is I would segment by project only. Um and then I saying like basically what I did wrong, why the current pair is weaker than it looks, what I would set. So it's giving very specific suggestions and uh based on this these time scale skills, but here's my uh rule for AI, which is basically just to verify every suggestion yourself. Okay, let's actually start this second project. I intentionally chose a problem with a completely different shape. So before the previous project was about irregular events spans arrived randomly whenever an AI agent did something. Durations varied wildly and we were aggregating over those specific events. This project is about regular regular interval sensor sensor data. We're tracking a fleet of 2,000 EV chargers. Every single device reports exactly once every 10 seconds forever. This requires completely different tools. We have a monotonic we have monotonic counters that need to be differenced device state machines and the hardest monitoring problem of all which is depend detecting when a device stops reporting. So let's look at this schema. So first we have the this table site and the table device. These are two standard relational tables. There are no time columns here and they are not hyper tables. These are our dimensions. They are facts about the devices that don't change every 10 seconds. Then we create the index on device. And then we have this hyper table which is a reading. This is our see this is where we create the hyper table. Uh this is our fact table which we turn into a hyper table. So let's look at the four different kinds of columns in here because the data tape completely dictates how you are allowed to aggregate it. First we have the power W, the voltage, the temp C. These are gauges. They go up and down. You can average them, but as we'll see later, how you average them really matters. Second, the energy wh total. It's a monotonic counter. It represents the lifetime energy delivered. It only ever increases unless the device reboots, at which point it resets to zero. Averaging this number is totally meaningless. You want to find the difference over a window of time. Third, we have the next one which is the state. It's a discrete state like charging, idle, or faulted. You don't average a word. You just ask how much time this device spent in each state. Also notice that the gauges are stored as real, which is a which are four byte floats instead of double precision eight byte floats. A charger's temperature reading doesn't have 15 significant digits. when you're generating 17 million rows a day, cutting those column sizes in half makes a massive difference. So, let's look at the primary key, which is the device ID and time. So, in the previous project, we used time and span ID, but here we put the device list first. And you're maybe wondering why. Well, that's because the single most common query in a fleet management app is, "Show me one device's history over the last 24 hours." Putting the device first means all those rows are physically adjacent on disk, making it a single incredibly fast range scan. The primary key should always follow your dominant query pattern. I've cleared out my fleet project or service and so I'm going to be starting this from scratch and I'm going to be using the connection info. Now I'll put in this command to run my migration file to make sure I'm in the same directory as my m migration file and I have to put in my database URL. Okay, I've ran that and created on my database and we can verify that over on our web dashboard. We can now see the the objects in here, the hypert and all the things that we just created. Here's the devices. There's a myth that in analytics you can never use joins and therefore you must denormalize everything into a single massive fact table that leads to terrible schemas. The reality is that joining a small dimension table to a massive hyper table is completely fine and Postgress is deeply optimized for it. So let's prove it. Before we start quering this, we need data. I have this Python script here to generate the fleet telemetry. It's basically simulating the data that we need to do. It uses the exact same batched copy pattern we used in the first project. And it can generate 2,00 devices reporting every 10 seconds. And to save us from sitting here watching a progress bar, I've already applied our schema and run this simulator in the background. It backfilled 24 hours worth of data, which is quite a few row rows. I've also planted three specific ana anomalies in that data that we'll hunt for later. And a side note, I use a Python script here so you can see how the data is shaped. But on Tigercloud, you wouldn't write this inest code yourself. You would use their fully managed Kafka connector to consume telemetry straight from a topic or the S3 connector to bulk load historical CSVs. So we also have this queries file here which are which are going to have some queries that we're going to just use for for examples. So we have tons of rows of data and I want to address a massive myth in analytics. The myth is that you can never use joins and therefore you must denormalize everything into a single massive fact table that leads to terrible schemas. The reality is that joining a small dimension table to a massive hyper table is completely fine and Postgress is deeply optimized for it. So we're going to start with this first join. So first I going to get into psql on our our hosted database. Then I'll paste in this SQL code here. So we just joined across millions of readings and it returned in milliseconds. So it only took basically 11 seconds to run this entire query. And now we're going to run this explain query. Again, you can find these all in the code link in the description. So if we look at the explain plan, we can see exactly why it's so fast. Let me zoom out so we can see more on the screen at one time. It Postgress takes the device and site tables which total maybe a few thousand rows and take up a few kilobytes and builds a hash table in memory. Then it just streams the millions of readings past that hash table in the CPU cache. The join costs virtually nothing. And here's the actual rule for data modeling. Denormalize facts about the measurement itself, but normalize attributes that describe the entity. The current state of the charger is a fact about that state about that exact second time. So it goes in the hypert. But the firmware version belongs in the device table. If a charger gets a firmware update, you update one row on the device table. You not rewrite 400 million rows in your readings table. Let's answer the most important configuration question. What should our segment by column be when we compress this hyper table? Well, we have two obvious choices. We can segment by device ID and there are 2,000 devices or we can segment by site ID and there are 40 sites. Well, previously I told you that the rule of thumb was to aim for at least a thousand rows per compressed batch. Let's run an experiment and actually measure both axis the storage size and the query latency. So I've already run all this code and we've run this these alter tables. These are going to create two identical copies of a day's worth of data but compress them differently. One is by seg one is segmented by device ID. The other is segmented by site ID. Notice that for the site version I had to move device ID into the order by clause to ensure the data stays locally clustered within the segment. I ran this before we started recording because copying compressing a full day of data can take some time. So let's compare sizes on disk first using the hypert compression stats. So basically I have this SQL code that does the comparison here. So let's look at the percentages saved. We can see the um by device or device ID is just slightly better at just over 89%. The this result is exactly why we run this experiment instead of guessing. If you just read the documentation, you might assume that segment by segmenting by site ID would compress better because the batches are larger. With a 10-second interval, one device emits about 8,600 readings a day. Segmenting by site groups 50 devices together, creating a mass creating massive batches of over 400,000 rows. So why did device ID just went by a bit? Well, think about the physical shape of the data with segment by equals device ID. Each batch represents a single charger's continuous timeline. The temperature curves smoothly. The power output stays steady and delta encoding can compress those patterns perfectly. When you segment by site ID, you interle 50 different chargers into the same batch. The values constantly jump up and down between different machines which disrupts the encoding and actually hurts your compression ratio. So device ID wins on disk space. But storage size is only half the benchmark. Let's look at the second axis. query latency. So I just ran another query where we're going to look at query speed. So here are the results. The first one for reading by device, 251 milliseconds, and then 176 milliseconds for reading by site. Okay, so I just ran this code here to check the query latency and let's look at the query times. So when we're reading by device, it's 253 milliseconds and here's 166 milliseconds. So if you're paying close attention, you'll notice something unexpected. By sight actually ran faster here, 166 milliseconds compared to the 253. So, uh, why did that happen? Because of how databases actually work under the hood. First, a cache warning. The first query had to fetch cold data from disk into memory. The second query ran immediately after, found what it needed in the hot cache, and finish faster. Second, we're only quering a tiny slice of data. Right now, we only have a few megabytes of compressed data RAM. Scanning it is triv is very fast regardless of how it is segmented. But you have to protect project this architecture choice out to a production scale. So imagine you have three years of fleet data totally totaling billions of rows. If you use segment by site ID, the database is forced to decompress and scan 49 other device histories just to find the one charger you asked for. That memory overhead will crush your database. If you segment by device ID, the database read reads the batch metadata metadata and and ignores the rest of the site entirely and only decompresses the exact device you need. So despite the microbenchmark quirks of caching, device ID is definitely the right choice uh for our production workload because the single device query is what our API serves on every service detail page hundreds of times a minute. So we're going to choose device ID. Storage is cheap. The latency for user that you your users feel when opening the device detail page 50 times an hour is not the rule of takeaway isn't always isn't always use device ID. The rule is segment by should be the column you filter by the most on your raw data. So now we can drop our scratch experiment tables and we can apply that measure configuration policy directly to our primary reading hyper table. Um this is because I had already this errors because I had already added that we compress after 4 hours because uncompressed chunks are the expensive ones. And we add sparse minmax indexes on temp C and power W. So queries filtering on thermos spikes or power thresholds can skip entire batches without decompressing them. Okay. Now we're going to start working with the data more. And I have deliberately engineered three anomalies into the data set. Basically, real world problems that are incredibly hard to detect with plain SQL. A site outage. Every device at one specific site goes completely silent for some time. No air logs, no fault state, just silence. A counter reset. One device reboots and its lifetime energy WH total drops back to zero. And also a stuck state. a device gets stuck in a fault in a faulted state and faithfully report that it's broken every 10 seconds for an entire day, but it never goes offline. So, I just ran this to see how much we have of um basically so you can kind of understand the whole database right now. These are my readings, how many devices, and the date range. So, this is just representing a day of fleet operations. We could do it bigger. We could do it more, but then it would just create an even bigger database. And I'm using a Python script here so you can see how the data is shaped. But on Tiger Cloud, you wouldn't write this inest code yourself. You would use their fully managed Kafka connector to consume telemetry straight from a topic or the S3 connector to bulk load historical CVS. So now let's find the anomalies. We're going to use four specific hyperunctions designed to solve problems that are miserable to write in raw SQL. So first the counter reset. Our energy wh total column is a lifetime meter reading. It only ever increases until the device restarts at which point it drops to zero. If we write a naive SQL query to find the total energy delivered over the last 24 hours, it looks like this. For the healthy device uh 1338, it works perfectly. But you see for device 1547 which rebooted, we get a completely wrong answer. The max value was from right before the reset and the min value was zero. And what we we're not it's not showing us any error. Look at these numbers here for device 1338. It reports around 2 million W hours, about 2 megawatt hours, which makes complete sense for a normal operating day. But if we look at 1547, it claims to have delivered over 44 million watt hours, 20 times more than normal. So why is that? It's because the device rebooted, dropping the meter back to zero. The min value was zero right after the restart, and the max value was 44 million right before the restart. So naive math just built a customer for the charger's entire multi-year lifetime accumulation in a single day. And notice the worst part, there is no error. Postcrest happily executed the query and handed you a completely plausible looking invoice with a catastrophic number. So here's the correct way to do it using time scales hyperfunctions. The this is that is the actual energy delivered. The counter egg walks the readings in chronological order. So when it sees the value decrease, it recognizes that as a reset and safely adds the pre-reset accumulation to the total. We also see the numbum resets out of that. We also pull the num resets out of that same state. We clearly see that uh device 1547 reset exactly once. That is an incredibly useful metric on its own. A charger that resets five times a day is a charger you need to replace. This uses the same two-part pattern we learned in the previous project. You build a state using an aggregate function and you read the answer out using an accessor function. And because it's a state, we can put it in a continuous aggregate. Rate gives you the average rate of energy increase scaled to watts. Because it's derived directly from the metered counter rather than samples from the power gauge, it represents what was actually delivered according to the hardware meter. Now let's see how that fits into our continuous aggregate ladder. To make our dashboards lightning fast, we are going to use continuous aggregates. These are materialized views that automatically summarize your data in the background. But look closely at what we are actually storing in our hourly view. We aren't storing finished numbers. Every single aggregate column is storing an intermediate state. We're looking at this view right here. So, why does this matter? If you take a standard average of Monday's power usage and a standard average of Tuesday's power usage, you cannot safely average those two numbers together to get a weekly average. The math is wrong. By using hyper functions like states agg and the counterag, we are storing the raw mathematical state. Think of it like having the raw ingredients instead of a baked cake. Because we save the state, we can easily roll those hourly buckets up into daily or weekly buckets later with perfect accuracy. And look at the refresh policy right below it. We tell time scale to update this every 5 minutes. But notice the end offset of 2 hours. We deliberately do not close the window right up to the current minute. In the real world, IoT devices lose their cellular connection and spend their and and send their data late. So leaving a two-hour buffer ensures that late arriving data gets perfectly incorporated into the summaries before the bucket is finalized. So now let's look at the rung two of our ladder, the daily view. Notice that the device daily queries the device hourly, not the raw reading table. Because we store the mathematical states in rung one, rung two can just call rollup on those hourly buckets, it never has to scan the 100 million rows of raw data. This is how you keep queries fast at an infinite scale. And then we have this uh refresh rate here. Okay, let's apply this file. So I'm just going to run this command. We already I've now stored our URL into the environment variable. Okay. Now let's go let me go back into our database the PSQL. And remember continuous aggregates are empty until refreshed. And because device daily depends on device hourly, we must refresh bottom first. So I'm going to do these commands to refresh them and get our data in there. Okay, now let's query total energy delivered today from our continuous aggregate. So that's what this line will do. So we just rolled up hourly counter states across an entire day and difference them. And this math is reset aware across bucket boundaries. If a charger reboots at like 145955 right on an hourly theme, the calculation stays completely accurate. Now let's find our second planted anom anomaly anomaly using state a in fleet management. The morning operations meeting always starts with how much did our charger spend charging idling or broken? So I'm going to run this SQL here. Notice the syntax for duration in here. With time scale hyperunctions, you always pass the aggregate state as the first argument and the specific value you are looking at you're looking for as the second. So if we look at this top row here, one charger has been in the faulted state for almost the entire period. That is our second planted an anomaly. The charger never went offline. It faithfully reported every 10 seconds that its internal state was broken. A simple uptime alert would never catch it, but duration in surfaces it immediately without state. A writing this query in po post P post P post P post P post P post P post P post P post P post PostgreSQL requires a massive window function with lag or lead to detect state changes time timestamp differences to compute durations and complex boundary logic for sessions active when the window opened or closed with time scale it's one clean function call if you need to draw a timeline chart on a front end you can extract the exact intervals So here's the SQL to extract those intervals. You can see state timeline expands the state into discrete start and end timestamps. That's an entire NAT chart ready for a front-end UI produced directly from SQL. Now for the third anomaly, the site outage. So I have this other SQL code up here that I just ran. This represents the hardest problem in telemetry which is silence. There are no error rows there. There is no faulted status. The hardware simply stop communicating. You cannot write a wear clause to find records that do not exist. So if we look at the results here, we see multiple devices showing the exact same downtime gap. Let's confirm the scope of this outage by joining our data back to the site dimension table. Because we need to calculate the missing heartbeats per device before we can find the maximum downtime per site, we will use a common table expression, a width clause to structure the query cleanly. And so here is the entire SQL. And let's just look at the result. Every single device showing that downtime belongs to the exact same site. And we can see where the the worst downtime is. So this tells you immediately that you aren't dealing with individual charger hardware failures. The physical internet connection or cellular gateway at that facility went down. This is a much better health alert than threshold gou g gouges. A device reporting weird numbers is a problem you can triage later. A device reporting complete silence is broken, disconnected, or unpowered. Let's look at another subtle trap with continuous sensor readings which are standard averages. So uh you see we're calculating the averages different. So first the naive average and then the time weighted average. And if we look here, we can see the numbers are just very slightly different. So standard the average assumes every row represents an equal slice of time. But real networks readings clump together. If a device sits idle at zero watts for 10 minutes, reports once, and then rapidly streams 60 readings in one minute while pulling 50 kilowatts, a standard average weights those high readings 60 times more heavily than the idle period. And so one thing is that we don't see the time weight function in this query and that's because um we did the heavy lifting inside our device hourly continuous aggregate view earlier when we built this view. We pass linear to the time weights function that tells time scale to linear linearly interpolate between missing readings which is the correct mathematical model for continuous values like power draw or temperature. Now here in our select we just use roll up to seamlessly merge those pre-calculated hours states together and average to extract the final correct number. You can also use integral on that same state to calculate total energy directly from your power gauge and compare it against your counter. They won't match to the exact decimal because one is sampled and what is integrated hardware pulses, but a large discrepancy flags a faulty sensor right away. The final hyperunction addresses chart rendering. A single charger generates about 260,000 readings over a 30-day period. If you send 260,000 JSON coordinates to an 800 pixel browser chart, you will freeze the UI thread and waste bandwidth. So the LTB function um stands for largest triangle three buckets is a downsampling algorithm built directly into the database engine. it can reduce those 260,000 points down to exactly 500 points while mathematically preserving visual peaks, troughs, and silhouettes. So if you use hourly averages, a brief 3inut power spike is smoothed into nothingness. Uh LTB gives the front end a lightweight payload that retains the true shape of the data. Okay, now let's talk about data tiering. Now, I've already talked about it a little bit earlier in the course, but I want to go into even more detail. When you're dealing with time series data, you're dealing with massive scale. A medium-siz IoT fleet can generate terabytes of data a month. If you keep all that on ultra fast, premium NVMe solidstate drives, your database bill will bankrupt your project. But time series data has a very specific life cycle. It starts out hot and quickly turns cold. The readings from the last 24 hours are are queried constantly for real-time dashboards and alerting. But the readings from six months ago, those are only queried once a month for a historical report. Data tiering solves the cost problem by splitting your database across two different types of storage. Recent hot data stays on fast, expensive SSDs. Older cold data is automatically moved into cheap Amazon S3 object storage. The magic of time scales taring is that it is completely invisible to your application. You don't have to write custom code to query on S3 bucket to query an S3 bucket and join it with your database. It all just looks like one single PostgreSQL table. And let me show you how to set it up. So before we can write any SQL, we have to provision the cloud infrastructure. That's why I'm on the Tiger cloud console right now. If you try to run the SQL command to tear your data right now, PostgreSQL will throw an error saying the function does not exist. That's because the object storage manager extension isn't actually installed on our database yet. So to install it, we are on our project um and we're going to go I'm going to go to the explore tab. Then I'm going to click data taring and then I'm just going to click enabled tiered storage and then let's go. Now depending on the plan that you have on Tiger Data, you may have to upgrade your account before you can enable this. Now behind the scenes, Tiger Cloud is provisioning a dedicated S3 bucket, configuring the IM security roles and installing the time scale OSM extension directly into our Postgress instance. Now, because our terminal session was already open, it might not see the new extension yet. So, I'm going to quit out of this. And then I'm going to go right back. I'm going to reconnect right into the database. Now that the cloud infrastructure is wired up, creating our policy is just one line of SQL add tiering policy and it's added. If I would have done this before, we did that stuff on the dashboard, it would have given an error, but this time it succeeds. From now on, any chunk containing data older than 3 days will automatically slide off your expensive local disc and into cheap S3 storage. Now, let's look at the most important foot gun with tiered storage. Tiered reads are disabled by default. So, if you try to query data than 4 days, it would just return zero. Not because the data is missing. it su it it would it successfully moved to object storage but our current connection is intentionally ignoring tiered chunks. Uh why is this off by default? Well, because scanning object storage across the network has higher latency than reading a local SSD. If tiered reads were turned on globally, a poorly written dashboard query could accidentally scan three years off three years of S3 data, stalling the connection and consuming resources. So to query your cold data, you have to explicitly opt in with set times DB enable tier reads to equal true. And then I could query data older than 4 days. Right now I actually don't have any data over older than 4 days because I just set it up with uh just 24 hours of data. But if we did, we could query all the rows. And so the production best practice is to leave tiered reads turned off globally for your main application and only enable it selectively on the analytical roles that actually need historical access. So for instance alter ro reporting set and we can enable tiered reads for reporting. Now we don't have that role right now but that's just an example if we had that role. Okay, this is the final section of this part. We're going to be talking about semantic search over agent failures. So, we're going to combine time series telemetry with vector search inside a single Postgregql database. When an AI agent fails, standard keyword queries often miss the root cause due to phrasing differences. We want to run a semantic vector search restricted to errors from the past seven days. So look at this SQL right here and actually this whole file here. So we separate the embeddings into their own table. It's called span embeddings because a 1,536 dimension float vector is about 6 kilobytes. We only embed spans that contain errors, avoiding massive overhead on healthy spans. Because this new table is also a hyper table, time scale uses chunk exclusion to skip older time windows entirely during the vector search. Okay, so I've now just applied this and now let's look what hybrid query looks like. So let's insert a mock error log into our new table so we have something to search for and then we will run our hybrid query. So this will put in the mock error in your application code. You would use a parameter variable here to pass in the real embedding generated by open AAI. Since we are in the terminal, I'm going to use Postgress's array fill function to generate a Mach 13,536 dimensional vector on the fly. So, we can see it execute. And if you notice this little thing here, the distance operator, it computes cosine distance, which compares vector angle rather than magnitude and the and is the standard for text embeddings. Now remember this is remember this critical indexing rule for vector databases. Your index operator class must match your match your query operator. So we created our HNSW index using vector cosine ops. So watch what happens when we write a query using this other symbol here which is which is the operator for ukidian distance. If we look here, we can in the query plan, we see that it drops back to a sequential scan. Postgress cannot use the agents index because the math operators don't match. Always verify that your query operator matches your index definition. This pattern completely eliminates the need for a dedicated separate vector database. You avoid running sync jobs between dis desperate systems. You eliminate eventual consistency lag and you can combine relational joins, time scale partitions and vector similarity all in a single unified SQL query plan. And before we wrap up this section, let's step out of the terminal. Writing SQL is only half the job of managing a database. The other half is keeping it healthy in production. So let's talk just a little bit more about the cloud tiger cloud console because this dashboard provides visibility that you simply cannot get from a command line. So when you open your service you land on the overview dashboard. This gives you your core telemetry CPU load memory usage uh the storage used here and more. When you are ingesting thousands of IoT readings per a second, this is where you watch for bottlenecks. If your CPU is constantly pegged at 99% or your memory starts swapping to disk, this dashboard tells you it's time to scale up your compute. Now, let's look at the explore tab. This is the visual control center specifically built for time series data. If you click into our reading table, you don't just see a standard Postgress t table schema. The explorer understands hypert. It shows you exactly how many chunks exist and how much data is compressed. Now let's look at the explore tab. This is a visual control center specifically built for time series data. If we click on the reading table here, we don't just see a standard Postgress table schema. The explorer understands hyper tables. It shows you exactly how many chunks exist, how much data is compressed and how much storage you are saving. You can also see the continuous aggregates we built earlier like the device daily and device hourly. It visually tracks the background jobs refreshing those views. If a refresh job fails because of a bad data type or a timeout, it gets flagged right here. You don't have to hunt through Postgress system tables to figure out why your dashboards are suddenly outdated. Now go to the monitoring tab. This is a continuous agentless query performance tracker. It basically watches every query your application runs, normalize them and ranks them. We can we have the logs, the jobs, we have the insights. Okay, so insights is giving us a like really the most information or a lot of information. We have connectors, we have operations, and there's a bunch of things you can do on the operations tabs, but one thing I want to point out is the backup and restore and the create recovery fork. Basically in a traditional Postgress setup you have to test a massive schema migration or a destructive query. If you have you have if you need to do that you need you have to spend hours restoring a backup straight to a staging server. But in tiger data you just can create a fork because the platform uses copy and write storage. It spins up an exact bite for bite clone of your production database in minutes running an isolated running on isolated compute. You can run your destructive experiments on the fork, verify the results and then safely delete it. So production is completely untouched. So the visibility and operational safety is exactly why we've moved from a local Docker container to a managed cloud environment. Okay, this wraps up this section. We moved from local docker to manage cloud. We validated complete schema portability. Configure an IoT fleet with hyperfunctions to handle real world edge cases like counter resets and network outages. Configured tier storage and uh unified vector search with part-time with time series data. Before we finish, here are 12 things to check before running this in production. You can use the summary slide at the end of this as a reference. One, enabling column store and converting chunks are separate steps. The alter table setting configures column store. It doesn't immediately convert existing chunks. Check whether a conversion policy already exists. If it does, it can process eligible old chunks as well as chunks that become eligible later. You can also convert a specific chunk manually when you want it done immediately. Two, check how many rows each segment will contain. A segment column with too many distinct values can leave you with small batches and poor compression. Aim for enough rows per segment within each chunk to fill batches about of about a thousand rows. For example, 8,000 readings for one device would fill several batches. It doesn't mean there's one batch containing all 8,000. The query on screen gives you an average so you can check for devices or users with very few records. If you segment by several columns, check their combined values. Three, choose sparse index settings before converting chunks. Changing the settings doesn't retrofit chunks already in column store. to rebuild an existing chunk with chunk with new settings. Convert it back to row store and then to column store again that rewrites data. So plan for the for the time and disk space it needs. Four, include a time range and point lookups when you have one. An IDON lookup can require searching across many chunks. A time condition lets the planner exclude chunks outside the range. It doesn't automatically reduce the query to one chunk. That depends on how wide the range is and how your chunks are partitioned. Use the narrowest correct range and check the plan. Five. Unique constraints must include every partitioning column. For a table partitioned only by time, that can mean a primary key on time and ID. If you've added another partitioning dimension, include that column too. UUIDv7 can help because the ID contains a timestamp, but UU UUID V7 alone doesn't bypass the constraint rule. You still need the appropriate unique key and a check constraint that ties the time the time column to the time stamp in the ID. Six, keep aggregate refreshes inside the data you still retain. If a refresh covers a period whose source rows have been deleted, the corresponding aggregate rows can be removed, too. They aren't necessarily replaced with zero. A chart might show a gap or display zero if the application fills missing values that way. Keep the refresh loop back comfortably shorter than source retention. For example, refreshing 7 days back while retaining 14 days gives you a buffer. Check this whenever either policy changes and be careful with manual refreshes over older periods. Seven, the reorder policy processes each chunk once and skips the two newest. A large backfill can add rows that no longer follow the chunk's physical order. If that affects your queries, run reorder chunk again and on the affected chunks. And if you're testing on fresh data, remember that the policy intentionally leaves the two newest chunks alone. Eight, separate real- time aggregation from refresh policy settings. Setting materialize only to false enables real time aggregation. A query then combines materialized results with new or raw data beyond the materialization watermark. with no data avoids populating the entire view when you create it. It's useful for controlling the initial refresh, but it isn't required to enable real time aggre aggregation. The offset controls how close to the present the refresh runs. 30 minutes for 15minute buckets is the choice shown here, not a universal minimum. Refreshes only complete buckets within their window. Choose the offset for your workload and how late your data arrives. Also remember that corrections to older already materialized buckets still need a refresh. Real time mode doesn't automatically recalculate all of your history. Nine watch chunk count and active index size. Around a thousand chunks is use is a useful point to review planning overhead. Not a hard limit. An hourly interval kept for a year creates 8,760 chunks. So that deserves a look. Keeping the indexes on actively written chunks within roughly a quarter of available memory is another starting guideline. Consider all the active hyper tables together. Measure your workload before changing the interval and remember that a new interval normally applies only to new chunks. 10. Check whether queries include tiered data. Tiered reads are disabled by default. Without enabling them, your query can emit chunks in object storage and still return a validlooking result. Enable the setting for sessions or roles that need that history. Expect reads from object storage to have different performance from reads on local storage. 11. Monitor background job failures. A failed refresh can leave the dashboard serving old results even though the dashboard query itself succeeds. Check job errors and arrange alerts for failures. We'll look at the error query shortly. 12. Review postgressql settings before benchmarking. Memory limits, worker capacity, and parallelism can all affect your results. They don't replace good schema design or appropriate time scale DB settings for a self-hosted database. Time scale DB tune or PG tune can provide a starting configuration. Review the recommendations, apply them, and restart if the changed settings require it. Then measure again. Manage services provide a configured starting point, but you still need to monitor your workload. Here are the 12 checks together. Pause here if you want to save the list. It's also in the course repository. Now, five monitoring queries you can keep. One, how much space does compression save? This query shows before and after sizes and percentage saved for each hyper table. Watch for changes over time. A lower saving could come from smaller batches. a change in segment cardality or a change in the data itself. Treat it as a reason to investigate rather than a diagnosis on its own. Two, how many chunks do we have? This shows the total chunk count. How many are compressed and the oldest time range? If the count keeps growing, check planning time and whether your interval and retention still makes sense. Changing the interval doesn't resize the chunks already there. Three, are the jobs succeeding? This combines job definitions with their execution statistics. Check the last run status, the last successful finish, and the failure count. A recent success doesn't erase earlier failures, and a job that hasn't run yet may have no execution statistics. Four, what failed. This shows the recent error messages with the job ID and finish time. Use it to investigate failed policies and to build alerts. An empty result means this query found no recorded errors in the selected period. It doesn't prove every expected job ran. So keep checking job status too. Five. How recent is the data returned by each aggregate? This compares the end of the latest bucket with the current time. It's a useful freshness signal, but it isn't a direct measurement of refresh lag. If traffic stops, the latest bucket gets older even when every refresh succeeds. If real time aggregation is enabled, recent raw data can make the view look current even while materialized refreshes are failing. An empty view returns no age to measure. Use this alongside the expected arrival rate, job status, and job errors. Don't alert on the bucket age alone without considering those conditions. Two other checks are worth keeping. First, background worker capacity. Allow room for the data time scale DB schedulers and the jobs that need to run at the same time. You don't need a dedicated worker for every policy you've created. Also, check PostgresQL's overall worker limit and whether jobs are being delayed. Second, use the usual PostgreSQL health checks, connections, cash hits, bloat, and longunning transactions. Keep using those alongside the time scale DB specific queries. Tigercloud also provides monitoring in its console. And four ideas to remember. First, hyperts organize data into chunks so queries and retention policies can work on the relevant ranges. Second, choose storage around how you use the data. Recent writes often suit row store while older analytical data can benefit from column store. Third, keep mergeable states when you need to combine summaries. Averages need their weights and supported percentile and distinct count states let you combine estimates correctly. Aggregates reduce repeated work, but they don't guarantee constant query time as every part of your workload grows. Fourth, you're still using Postgress. You can keep using its drivers, joins, and monitoring tools while accounting for the constraints and behavior of hypert. We've reached the end. Thanks for watching and happy coding.

Generated algorithmically for Search Engine Indexing.

Summarize Another Video