-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Fix NOT_ENOUGH_SPACE error from CH during monthly clean sites job #6551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,9 +1,18 @@ | ||||||
| defmodule Plausible.Workers.ClickhouseCleanSites do | ||||||
| @moduledoc """ | ||||||
| Cleans deleted site data from ClickHouse asynchronously. | ||||||
| Cleans deleted site data from ClickHouse sequentially. | ||||||
|
|
||||||
| We batch up data deletions from ClickHouse as deleting a single site is | ||||||
| just as expensive as deleting many. | ||||||
| We batch up data deletions from ClickHouse as deleting a single site may be more expensive than deleting many: | ||||||
| the expense scales with the number of partitions the site or sites have rows in. | ||||||
| A single site with events since 2025-01 to 2026-06 will be more expensive to clean up | ||||||
| than a hundred sites with events only in the partition 2026-06. | ||||||
|
|
||||||
| Tables are cleaned one partition at a time because to clean one partition, | ||||||
| Clickhouse needs to rewrite it. It reserves room for rewriting it in full on disk. | ||||||
| Cleaning all partitions at the same time would mean Clickhouse reserving room on disk | ||||||
| equal to the size of all the partitions, | ||||||
| potentially reserving all the available disk space and shorting out INSERTs from ingestion. | ||||||
| This sequential approach prevents that. | ||||||
| """ | ||||||
|
|
||||||
| use Plausible.Repo | ||||||
|
|
@@ -31,7 +40,9 @@ defmodule Plausible.Workers.ClickhouseCleanSites do | |||||
| "imported_visitors" | ||||||
| ] | ||||||
|
|
||||||
| @settings if Mix.env() in [:test, :ce_test, :e2e_test], do: [mutations_sync: 2], else: [] | ||||||
| @settings [mutations_sync: 2] | ||||||
|
|
||||||
| @partition_delete_timeout :timer.minutes(15) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So are we ok to crash if this takes more than 15 mins? What's the reasoning here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important question, we must take fails / timeouts into account when working on another solution. |
||||||
|
|
||||||
| def perform(_job) do | ||||||
| deleted_sites = get_deleted_sites_with_clickhouse_data() | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if you like to do that, but we could store the deleted IDs upon deletion and pick them up from there instead of dancing around with mapset and somewhat greedy queries. We could run this exact cleanup for the last time manually and keep reading exactly what we need from a trusted source.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea. These queries are scanning a lot of rows. We will need to discuss how to implement the removal of a site_id from that table: it must not happen before all mutations cleaning the data for the site for all tables for all partitions have run, but it's not obvious to me where we can get that signal. |
||||||
|
|
@@ -41,18 +52,59 @@ defmodule Plausible.Workers.ClickhouseCleanSites do | |||||
| "Clearing ClickHouse data for the following #{length(deleted_sites)} sites which have been deleted: #{inspect(deleted_sites)}" | ||||||
| ) | ||||||
|
|
||||||
| database = current_database() | ||||||
|
|
||||||
| for table <- @tables_to_clear do | ||||||
| IngestRepo.query!( | ||||||
| "ALTER TABLE {$0:Identifier} DELETE WHERE site_id IN {$1:Array(UInt64)}", | ||||||
| [table, deleted_sites], | ||||||
| settings: @settings | ||||||
| ) | ||||||
| clean_sites_from_table(database, table, deleted_sites) | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| :ok | ||||||
| end | ||||||
|
|
||||||
| defp clean_sites_from_table(database, table, deleted_sites_ids) do | ||||||
| for partition_id <- active_partition_ids(database, table) do | ||||||
| delete_sites_from_partition(table, partition_id, deleted_sites_ids) | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| defp delete_sites_from_partition(table, partition_id, deleted_sites_ids) do | ||||||
| IngestRepo.query!( | ||||||
| "ALTER TABLE {$0:Identifier} DELETE IN PARTITION ID {$1:String} WHERE site_id IN {$2:Array(UInt64)}", | ||||||
| [table, partition_id, deleted_sites_ids], | ||||||
| settings: @settings, | ||||||
| timeout: @partition_delete_timeout, | ||||||
| checkout_retries: 0 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid question! Need to discuss when working on another solution
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, but why did you write it even? 😅
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I appreciate your input and wanted to clarify that it will not be ignored, just that I didn't want to go in depth for a solution that is inadequate for other reasons. Since timeouts and failures are such an integral part of any new solution, I'd prefer to discuss it with the proposal of the new system at hand. These comments are a public record of how things are done at Plausible, so I thought it's appropriate to take an extra minute to ack.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because this may seem as an evasion of responsibility on my part, I want to clarify that I removed the retries intentionally, without realising that retrying won’t make or break this job: it doesn’t matter whether there’ll be 1x or 4x attempts for each partition when the count of partitions this job would have needed to sequentially process without errors is 1000+. They’re pretty chunky partitions as well, so we need to find another way. |
||||||
| ) | ||||||
| end | ||||||
|
|
||||||
| defp active_partition_ids(database, table) do | ||||||
| source = | ||||||
| if IngestRepo.clustered_table?(table) do | ||||||
| "clusterAllReplicas('{cluster}', system.parts)" | ||||||
| else | ||||||
| "system.parts" | ||||||
| end | ||||||
|
|
||||||
| %Ch.Result{columns: ["partition_id"], rows: rows} = | ||||||
| IngestRepo.query!( | ||||||
| """ | ||||||
| SELECT DISTINCT partition_id | ||||||
| FROM #{source} | ||||||
| WHERE database = {$0:String} AND table = {$1:String} AND active | ||||||
| ORDER BY partition_id | ||||||
| """, | ||||||
| [database, table] | ||||||
| ) | ||||||
|
|
||||||
| Enum.map(rows, fn [partition_id] -> partition_id end) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| end | ||||||
|
|
||||||
| defp current_database do | ||||||
| %Ch.Result{rows: [[database]]} = IngestRepo.query!("SELECT currentDatabase()") | ||||||
| database | ||||||
| end | ||||||
|
|
||||||
| def get_deleted_sites_with_clickhouse_data() do | ||||||
| pg_sites = | ||||||
| from(s in Plausible.Site.regular(), select: s.id) | ||||||
|
|
@@ -67,15 +119,22 @@ defmodule Plausible.Workers.ClickhouseCleanSites do | |||||
| DBConnection.run( | ||||||
| ch, | ||||||
| fn conn -> | ||||||
| Ch.query!(conn, "FROM events_v2 SELECT site_id GROUP BY site_id", [], | ||||||
| Ch.query!(conn, site_ids_with_data_query(), [], | ||||||
| settings: [optimize_distinct_in_order: 1], | ||||||
| timeout: :infinity | ||||||
| ) | ||||||
| end, | ||||||
| timeout: :infinity | ||||||
| ) | ||||||
|
|
||||||
| ch_sites = rows |> MapSet.new(fn [site_id] -> site_id end) | ||||||
| ch_sites = for [site_id] <- rows, not is_nil(site_id), into: MapSet.new(), do: site_id | ||||||
|
|
||||||
| MapSet.difference(ch_sites, pg_sites) |> MapSet.to_list() | ||||||
| end | ||||||
|
|
||||||
| defp site_ids_with_data_query do | ||||||
| Enum.map_join(@tables_to_clear, " UNION DISTINCT ", fn table -> | ||||||
| "SELECT DISTINCT site_id FROM #{table}" | ||||||
| end) | ||||||
| end | ||||||
| end | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pretty sure this should be async on prod
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a bit of a step away from tradition and you're right to point this out. If
mutations_sync: 2, we wait for all nodes in the cluster to finish the mutation, which is a bit flimsy if one of the nodes is in trouble at the time.If
mutations_sync: 0, we dump the mutations into a list on CH cluster Keeper, hang up and the job completes with :ok. CH then churns through these as and when able (https://clickhouse.com/docs/reference/statements/alter/index#mutations). The mutations may succeed or they may fail. How do we know that we actually cleaned up that which we intended? There's also the matter of mutation concurrency. With this method, we lose control over it (beyond settings levers), which can lead to mutations using up all the available disk space.I'm ok with
mutations_sync: 0, but in this case we should run another job soon after to check up on these mutations.(from https://clickhouse.com/docs/concepts/best-practices/avoid-mutations)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think running a single oban job potentially for hours/days is unacceptable design-wise. If you think you can recover from a synchronous timeout or other failure by retrying the job, please make it so each partition prune is a job on its own and oban takes care of scheduling.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you need to know right away? What is the consequence of one partition not getting pruned because site id 2387 was deleted? On a scale of no one is affected to catastrophic, what is the severity?
Monitoring ClickHouse for its inability to execute mutations consistently is another matter, completely out of scope, so we're talking a one-off event where a partition isn't fully rewritten. What is the big deal?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the currently deployed method that's using mutations_sync: 0, if the mutation cleaning up events_v2 table for a batch of sites succeeded, but cleaning up other tables failed, when will the data for those sites be cleaned up from the other tables?
Data deletion is a critical part of security and privacy guarantees to users and I look at it with utmost severity, so I don't think monitoring mutations we rely on to ensure this is out of scope. CH docs explicitly advise monitoring mutations. Still, it can be done over our monitoring platform and with alerts, not in app code as I suggested.
Agreed, converting to draft and will discuss alternatives, separate job per partition per table or similar.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Care to elaborate how the data we store at rest is not private and how this is important for security? The site ID doesn't exist anymore, there's a bunch of anonymous visits with meaningless identifiers worst case, on a rare event (we have no record of) of a partial mutation failing.
Out of scope of ensuring ingestion is uninterrupted due to deletions happening
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The data about website visits is valuable, as evidenced by analytics platforms that offer their service for free in exchange for some access to the data.
This value and meaning persists even if the site_id that it is for doesn't exist any more. In events_v2 we have
hostname,page,meta, ...Failure to clean up this properly means that in the event of a data leak, data of website visitors of sites that have stopped using Plausible would be exposed as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the event of data leak, the data we failed to delete is our least concern. What would need to happen so that we'd leak partially deleted data but not everything else? Food for thought.