Repository navigation
fix(metrics): keep each account's metrics apart - #955
hitesh96db wants to merge 4 commits into
Conversation
The metrics table had no account column on any backend, so two accounts that each had a table of the same name wrote to one row, and the counts and latencies /metrics reported for that name were the two tables' added together. Every metric of a request, and the TTL metrics of a table, now carry the account they were recorded for: the collector's key, the flushed bucket and the stored row have an account, and /metrics reports it as an AccountId dimension and takes an account_id filter. Metrics of the whole deployment (pool, worker, storage query, backup jobs) have none. Migration 10, account_metrics, replaces the catalog's metrics table (a collection on MongoDB) with account_metrics, whose key has the account, on every backend. A partition key cannot be changed on Cassandra and a primary key cannot be altered on SQLite, so the table is new everywhere. The old rows carry no account and are dropped. The migration is breaking and convergent. MetricsStore::query_metrics takes a MetricsFilter (account, table name, metric). extenddb_storage::metrics_conformance checks each backend's store against a migrated catalog. Part of #728.
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I read the change against main at 9c3fbfb. I ran nothing, so each point below comes from reading the code. CI passes apart from CodeQL, which fails on the new alert at metrics_pages_tests.rs:182.
After this change the feed returns one row per account. The console's Raw Metrics table (metrics_content.rs:109 and :180) shows only Metric, Table and Operation. Two accounts' rows for Orders therefore show as two rows with the same labels and different sums. The charts sum the rows of each timestamp, which adds the accounts together, and the table prints each row as it arrives. Please add an Account column from the AccountId dimension, or sum the rows of one table name as the charts do.
docs/manuals/07-upgrade-manual.md:983, in the v0.1.15 entry on the admin-only dashboard, still says "The metrics table has no account column". The new v0.1.15 entry at line 369 says the opposite. Please put that sentence in the past tense, for example "Before migration 10 the metrics table had no account column".
| pub(super) struct SegmentPoint { | ||
| pub(super) operation: String, | ||
| #[allow(dead_code)] // TODO(cleanup): used when console adds per-account latency breakdown | ||
| pub(super) account_id: String, |
There was a problem hiding this comment.
/metrics?account_id=X reads only X's rows from the store. render_metrics then sets segments from query_segments(window), which takes no account, and the console feed at metrics_pages.rs:143 does the same. The filtered response carries the request count and the average auth, authz, throttle, dispatch and response latencies per operation of every account. The upgrade manual at line 369 says the parameter "returns one account's metrics only". The design doc (06-component-server.md:315) says "only the metrics of this account". record_segments sets this field, and nothing reads it. Please filter the segment points by account in query_segments, with a test that records segments for two accounts and reads one. test_the_account_filter_returns_that_accounts_rows_only could check segments too. If segments should stay deployment-wide, please say so in both docs and drop this field.
There was a problem hiding this comment.
Fixed in 3ec94e1. query_segments takes the account and keeps that account's points only; /metrics and /console/metrics-data pass account_id to it, and an empty one is no filter. segments_are_filtered_by_account records segments for two accounts and reads each, and test_the_account_filter_applies_to_the_latency_segments checks that the first account's segments have no Scan, which only the second account sent. With the filter removed from query_segments, the unit test fails.
| want_first.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); | ||
| assert_eq!(first_only, want_first, "the first account's rows"); | ||
|
|
||
| // Every filter together. |
There was a problem hiding this comment.
This read still passes if a store drops its table filter or its metric filter. The second account has one row, for Orders and ConsumedWriteCapacityUnits, and those two filters keep it anyway. This PR rewrote the table and metric filters in all four stores, and this module is the only store-level test of them. Please give the second account a row under another table name and a row under another metric, so this read has rows to exclude. A read with only table_name and one with only metric would test each filter alone.
There was a problem hiding this comment.
Fixed in 5a44c5c. The second account also has a row under Other and a row under UserErrors, and the check reads with the table filter alone and the metric filter alone. With either filter removed from the SQLite query, the check fails on that read.
| state.metrics.record( | ||
| extenddb_core::metrics::MetricName::ConsumedReadCapacityUnits, | ||
| m.read_capacity_units, | ||
| Some(&ctx.account_id), |
There was a problem hiding this comment.
Only PutItem's write capacity has a test of the account the handler records, in test_metrics_accounts.py. By my reading, no test fails if this line passes None, or if the handler passes "" to record_latency, record_request_count or record_error_metrics. Nothing in the tests calls handle_request. The TTL metrics have no test of their account, and record_ttl_deletion(table_name, table_name) compiles because both parameters are &str. Please add a test that sends requests for two accounts through the router and checks the AccountId of each metric the handler records. Please also add a TTL test on one backend that checks the account of TimeToLiveDeletedItemCount.
There was a problem hiding this comment.
In 3ec94e1 test_metrics_accounts.py sends, as each account, a PutItem, a GetItem, a Query, a DeleteItem whose condition fails and a GetItem on a missing table, and puts an expired item for the TTL sweep. It checks the AccountId of read and write capacity, returned items and bytes, failed conditions, UserErrors, RequestCount, the latency count and TimeToLiveDeletedItemCount. The wire suites run it on all four backends. Against a rebuilt SQLite server, it fails with this line passing None, and with the SQLite TTL worker passing "" to record_ttl_deletion. every_scoped_recorder_keeps_its_account covers each of the 14 per-account recorders, and the record_error_metrics tests now check the account on SystemErrors, failed conditions and TransactionConflict. I did not add a router-level Rust test, and the throttle site has no test.
| pub struct MetricsRow { | ||
| pub bucket: time::OffsetDateTime, | ||
| /// The account the row was recorded for; `None` for a metric of the | ||
| /// whole deployment. Stored as the empty string. |
There was a problem hiding this comment.
On MongoDB a deployment metric has no account_id field, because management_store.rs:2166 inserts it only for Some. "Stored as the empty string" holds on SQLite, PostgreSQL and Cassandra. Please say that the field is absent on MongoDB, here and in the PR body.
There was a problem hiding this comment.
Fixed in 5a44c5c: the comment says that the field is empty on SQLite, PostgreSQL and Cassandra and absent on MongoDB. The PR body says the same.
| PRIMARY KEY (bucket, account_id, metric, table_name, index_name, operation) | ||
| ); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_account_metrics_bucket ON account_metrics (bucket); |
There was a problem hiding this comment.
The primary key starts with bucket, so its index already serves the bucket >= ? AND bucket <= ? read and the prune's bucket < ?. This index is a second B-tree that each flush also writes. The PostgreSQL body has the same index at line 26. The table is new, so dropping the index now needs no later migration. Please drop it from both bodies and the schema dumps, or say in the comment which query needs it.
| ); | ||
| assert!( | ||
| accounts.contains(&account), | ||
| "admin feed lacks the account of {table}: {accounts:?}" |
There was a problem hiding this comment.
CodeQL fails on this message (alert 219, rust/cleartext-logging) because it prints account ids. The ids are the made-up values in TABLES, so the message logs nothing sensitive. Please drop {accounts:?} from the message, so main does not carry the alert after the merge.
There was a problem hiding this comment.
Fixed in 3ec94e1: the message no longer prints the ids. The two other alerts on this PR, in the SQLite migration test, are fixed the same commit: the test inserts with a literal statement.
Review of #955: - The latency segments of /metrics and /console/metrics-data take the account_id filter: query_segments keeps the requests of that account only. Before, a response filtered by account still held every account's per-operation counts and latencies. - The console's raw metrics table has an Account column. - An empty account_id is no filter in the in-memory collector, as in the stores. - The named record_* methods move to collector_record.rs, keeping collector.rs under the 500-line limit. - Tests: every one of the 14 per-account recorders keeps its account; segments are filtered by account; SystemErrors, failed conditions and transaction conflicts carry the account. The store check gives its deployment row a key of its own run, and checks that two inserts of one key add up and that the prune deletes an old row (not on Cassandra, which overwrites and expires by TTL). The wire test sends a GetItem, a Query, a Scan, a failed condition and a request on a missing table as each account, puts an expired item for the TTL sweep, and waits for the PutItem sums to reach their final values; it checks the account on each metric, the TTL deletions and the filtered segments. The MongoDB metrics store test runs with EXTENDDB_TEST_MONGODB_REQUIRED in CI. - Docs: the account is part of the row key on SQLite, PostgreSQL and Cassandra; on MongoDB each document records it. AccountId is ExtendDB's own dimension. The storage query metrics carry no account. Dropping metrics on Cassandra keeps a snapshot when auto_snapshot is on. Stale text about the metrics table having no account is updated. Part of #728.
Review of #955: - The store check gives the second account a row under another table name and a row under another metric, and reads with the table filter alone and the metric filter alone, so a store that ignored either filter fails it. - account_metrics has no separate bucket index on SQLite and PostgreSQL: the primary key starts with bucket, so its index serves the range reads and the prune. - MetricsRow says that a deployment row has no account_id field on MongoDB. Part of #728.
|
3ec94e1 and 5a44c5c address the review. The Raw Metrics table has an Account column, read from the |
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I checked the round-1 items against 3ec94e1 and 5a44c5c, by reading the code this round. The two commits fix seven of the eight. For the handler comment, each metric the handler records with an account now has a test, except the three throttle metrics (handler.rs:234). CodeQL shows no open alert on this PR now. The other comment is on text the new commits rewrote.
| state.metrics.record( | ||
| metric, | ||
| 1.0, | ||
| Some(&ctx.account_id), |
There was a problem hiding this comment.
ReadThrottleEvents, WriteThrottleEvents and ThrottledRequests still have no account check. Line 369 of the upgrade manual now lists throttles among the metrics that carry the account. No test fails if this line or line 242 passes None. tests/rust/src/capacity_throttling.rs already throttles a table provisioned with 1 write capacity unit by sending 50 PutItems. Please give each account in test_metrics_accounts.py such a table, write to it until the first ProvisionedThroughputExceededException, and check the AccountId of WriteThrottleEvents and ThrottledRequests.
There was a problem hiding this comment.
Fixed in 4d8eb8d. Each account gets a table provisioned with one write capacity unit and sends PutItems to it until one answers ProvisionedThroughputExceededException; test_each_throttle_metric_names_its_account checks that WriteThrottleEvents and ThrottledRequests under that table have a row for each account. Against a rebuilt SQLite server with throttling_enabled on, the fixture fails with line 234 passing None, and again with line 242 passing None. ReadThrottleEvents comes from the same record call as WriteThrottleEvents, and the test sends no read that throttles.
|
|
||
| ### v0.1.15: metrics carry their account (catalog migration required) | ||
|
|
||
| **What changed.** The metrics of a request that have a table or an operation dimension (capacity, latency, errors, throttles, returned items and bytes, `RequestCount`), and the TTL metrics of a table, now carry the account they were recorded for. The catalog stores the account with each metrics row (migration 10, `account_metrics`), so two accounts that each have a table of the same name keep their own rows: before this release they wrote to one row, and the counts and latencies `/metrics` reported for that name were the two tables' added together. `/metrics` and the console's `/console/metrics-data` report the account as an `AccountId` dimension, beside `TableName`, `GlobalSecondaryIndexName` and `Operation`, and `/metrics` takes an `account_id` query parameter that returns one account's metrics only. Metrics of the whole deployment, such as the connection pool, worker, storage query and backup job metrics, have no `AccountId` dimension, and the `account_id` filter leaves them out. The filter also applies to the latency `segments` of the response. `AccountId` is ExtendDB's own dimension, not one of CloudWatch's DynamoDB dimensions. The console dashboard still serves admin sessions only and shows every account's metrics: its raw metrics table names each row's account, and its charts add the accounts together. |
There was a problem hiding this comment.
The handler records StorageQueryCount and StorageQueryLatency for each request, with the query category as their Operation dimension, and they carry no account. The rule at the start of this paragraph, "metrics of a request that have a table or an operation dimension", includes them. A later sentence lists them among the deployment's metrics. Please drop "that have a table or an operation dimension", so the list in parentheses names the metrics that carry the account.
There was a problem hiding this comment.
Fixed in 4d8eb8d: the sentence now reads "The metrics of a request (capacity, latency, errors, throttles, returned items and bytes, RequestCount), and the TTL metrics of a table, now carry the account".
Review of #955: - test_metrics_accounts.py gives each account a table provisioned with one write capacity unit and writes to it until a PutItem is throttled, then checks the AccountId of WriteThrottleEvents and ThrottledRequests under that table. - The upgrade manual's list of the request metrics that carry the account no longer has a qualifier that also covered the storage query metrics, which carry none. Part of #728.
LeeroyHannigan
left a comment
There was a problem hiding this comment.
I checked 4d8eb8d against the two comments. Each account now sends PutItems to a table with one write capacity unit until the server throttles one. test_each_throttle_metric_names_its_account then checks the account of WriteThrottleEvents and ThrottledRequests, and ReadThrottleEvents comes from the same record call at handler.rs:231. The test passes in the wire jobs of all four backends. The upgrade manual sentence now lists the metrics without the rule.
What
Metrics are kept per account. Two accounts that each have a table of the same name now get their own metrics rows.
Collector and flush.
MetricKey,FlushBucketandMetricsRowcarry the account a point was recorded for.MetricsCollector::recordtakes anaccount_id, and the table-scopedrecord_*methods take one too (latency, capacity, the error metrics, throttles, returned items and bytes, request count, segments, and the TTL metrics). The request handler passes the account the request was authenticated for, and each backend's TTL worker passes the table's account. Metrics of the whole deployment (pool, worker, storage query, backup and restore jobs,TtlRepairMarkerCount) have no account.Migration 10,
account_metrics, on every backend. It createsaccount_metrics, whose key is(bucket, account_id, metric, table_name, index_name, operation), and dropsmetricswith its rows.account_idis the empty string on a metric of the whole deployment on SQLite, PostgreSQL and Cassandra; on MongoDB such a document has noaccount_idfield.CREATE TABLE IF NOT EXISTS account_metrics, thenDROP TABLE IF EXISTS metrics. The primary key starts withbucket, so its index serves the range reads and the prune.{bucket: 1}index and dropsmetricswhen the catalog lists it.The table is new rather than altered: a Cassandra partition key cannot be changed and a table cannot be renamed, and SQLite cannot alter a primary key. A new name also keeps the bodies safe to run again: on a catalog that has the migration, both statements do nothing. The old rows are dropped, not copied, because they carry no account, and the server keeps metrics for a day. The migration is
Kind::Breakingandconvergent: true. The registry entry says that no later migration may create a table namedmetrics:--reconvergeruns migration 1, which creates it, and then this drop again.MetricsStore.query_metrics(start, end, &MetricsFilter)replaces the two positional filters. The filter has an account, a table name and a metric, and an empty string reads as no filter. With an account set, the store returns that account's rows only, never a row of the whole deployment. All four stores read and writeaccount_metrics./metrics. Each row has anAccountIddimension, besideTableName,GlobalSecondaryIndexNameandOperation, andaggregate_rowskeys on the account, so two accounts' same-named tables are two rows in the snapshots and in every bucket. Theaccount_idquery parameter returns one account's rows. The filter also applies to the latencysegments./console/metrics-datareturns the same payload, so it carriesAccountIdtoo.Console. The dashboard is still admin-only. Its raw metrics table has an Account column, and its charts add the accounts together.
Docs.
MetricsStoretable, and a line of the security model.docs/schema/*.txt, and the "catalog is at migration 10" sample output lines.This is the second part of #728. #922 was the first.
Why
Part of #728.
The
metricstable had no account column on any backend, so two accounts with a table of the same name wrote to one row. The counts and latencies an admin read for that name were the two tables' added together.To reproduce on
main(9c3fbfb):dynamodb:*./metrics?window=LastHourhas oneConsumedWriteCapacityUnitsPutItem row for that name, summing 4.Testing done
extenddb_storage::metrics_conformance(new, featuretest-util). It writes rows for two accounts with a table of the same name, plus a row of the whole deployment, then checks:Each backend runs it against a migrated catalog:
migrations::released_tests::the_metrics_store_keeps_two_accounts_aparttests/metrics_store.rstests/metrics_store.rs, throughMongoCatalogStore::on_database_for_teststests/direct/metrics_store.rsOn SQLite the check fails with the account filter removed, the table filter removed, the metric filter removed, the upsert setting the sum instead of adding to it, or the prune deleting nothing.
Migration 10. On SQLite, PostgreSQL and MongoDB, a catalog with
metricsrows migrates to an emptyaccount_metricswithoutmetrics, and a second run of the body keeps the rows written since. On MongoDB the second run is through--reconverge, after migration 1 recreatedmetrics.Existing migration tests, updated for the dropped table and the new one:
DROPPED_TABLES.expected_catalog()plus a0010_account_metrics.expected.jsonfixture, and the step lists inmigrations_fresh,migrations_locksandmigrations_released.expected_definitions()plus a fixture, andkeyspace_migrations'CATALOG.tablesandDROPPED.tests/test_resource_tag_migration.py:_as_released_catalogremovesaccount_metrics.Unit tests.
record_*methods keeping its account, and the latency segments filtered by account.aggregate_rows: two accounts with the same table name stay two groups, andAccountIdis retained.record_error_metrics:UserErrorscarries the account and no table name;SystemErrors,ConditionalCheckFailedRequestsandTransactionConflictcarry the account.tests/test_metrics_accounts.py(new,slow): two accounts, the same table name with TTL on, one expired item in each. Each account sends one PutItem (1 write capacity unit in the first, 3 in the second), a GetItem, a Query, a DeleteItem whose condition fails and a GetItem on a missing table; the second also sends a Scan. Once the flush has written every row read below, with the PutItem sums at their final values, and two reads 5 s apart agree:{first: 1.0, second: 3.0};UserErrors,RequestCountand the PutItem latency count each have a row for each account;WriteThrottleEventsandThrottledRequestshave a row for each account, under a table of one write capacity unit that each account writes to until a PutItem is throttled;TimeToLiveDeletedItemCountis 1 for each account;account_id=<second>returns only rows whoseAccountIdis the second account;Against rebuilt servers on fresh databases, the fixture fails with the account dropped at flush (
{None: 4.0}), with the handler recording read capacity, a throttle event or a throttled request without the account, and with the SQLite TTL worker recording deletions without it.Against a SQLite release server built from this branch, on a fresh database:
test_metrics_accounts.py,test_metrics_table_names.py,test_metrics.py,test_console_metrics_admin_only.py,test_cross_account_isolation.pyandtest_resource_tag_migration.pypass.cargo test --lockedforextenddb-core,-storage,-server,-storage-sqlite,-appandextenddbpasses, as doextenddb-storage-cassandra --libandextenddb-storage-mongodb --lib. The one MongoDB library failure isa_standalone_server_is_unsupported, which needsEXTENDDB_TEST_MONGODB_STANDALONE_URI, not set here.PostgreSQL
tests/metrics_store.rsandmigrations_{crash,fresh,golden,guards,owners,released,runs,serving,tag_blobs}pass against a local server. The exception ismigrations_guards::a_role_that_does_not_own_the_tables_is_told_what_to_run, which also fails locally onmain: the local superuser owns the tables.MongoDB
tests/metrics_store.rsandmigrations_{fresh,guards,locks,released}pass against a local replica set.Cassandra:
--liband compile checks only. Themigrations,migrations_stoppedanddirect_integrationsuites, including the new test and the updated schema lists, run first in CI.Schema docs.
docs/schema/sqlite.txtanddocs/schema/postgres.txtaredevtools/schema-dumpof fresh installs from this branch, and match exactly.mongodb.txtandcassandra.txtare edited by hand, because their dumps need Docker; the integration jobs compare them.Lint.
cargo fmt --all -- --checkis clean, and both CI clippy commands on 1.97.0 are clean with-D warnings. Locally they also needed-A unused-variables, for the existingline_lenincrates/core/tests/data_file_decode_peak.rs, which is used on Linux only.cargo +1.88.0 check --workspace --all-targetspasses.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
cargo testran for the crates listed above, not the whole workspace. Clippy ran with-D warnings, as CI does, without the pedantic group.ADR / RFC: the catalog change is a numbered migration under ADR-0023.
MetricsStore::query_metricschanges signature: the trait is internal to the workspace, and every implementation is in this PR.Migration number: #934, #833 and #823 also add migration 10. Whichever merges later renumbers.
Overlap with #923:
MongoCatalogStore::on_database_for_testsis added with the same text as in #923.Breaking changes
serverefuses a catalog at migration 9. A build that reads the migration ledger and lacks migration 10 refuses a catalog at migration 10 (exit 4); a 0.1.x release must not be run against a migrated catalog at all. Rolling back means restoring the backup taken beforemigrate --yes. Stop every server of an earlier release before migrating: such a server writes tometrics, which no longer exists./metricsand/console/metrics-data:AccountIddimension, so a reader that groups rows by dimensions gets one row per account where it got one per table name;/metricstakesaccount_id.MetricsStore::query_metricstakes a&MetricsFilter, andMetricsCollector::recordand the table-scopedrecord_*methods take an account.By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.