Interactions with other components — CloudKitty — branch stable/2026.2
Branch
stable/2026.2@0685cc330e83— repository https://opendev.org/openstack/cloudkitty — analysed on 2026-11-09
1. RabbitMQ (oslo.messaging RPC)
CloudKitty uses RPC only between its own API and its own processor. It emits no notification and consumes none (no Notifier or notification listener in the code; cloudkitty/messaging.py:25-80).
1.1 Topology
| Element | Value | Where |
|---|---|---|
| Transport | [DEFAULT]/transport_url, default exchange cloudkitty (set_transport_defaults('cloudkitty')) | cloudkitty/messaging.py:25-47 |
| Client target | topic cloudkitty, version 1.0, no server → round-robin over processors | cloudkitty/messaging.py:50-61 |
| Server target | topic cloudkitty, server [DEFAULT]/host, version 1.0, executor threading, DefaultRPCAccessPolicy | cloudkitty/orchestrator.py:672-681, cloudkitty/messaging.py:64-71 |
| Endpoints | RatingEndpoint (namespace rating, 1.0), ScopeEndpoint (no namespace, 1.0) | cloudkitty/orchestrator.py:121-123, cloudkitty/orchestrator.py:210-211 |
| Servers per host | one per processor and reprocessor worker (each cotyledon worker starts its own RPC server) | cloudkitty/orchestrator.py:634-656, cloudkitty/orchestrator.py:768-770 |
Queues (oslo.messaging rabbit driver conventions [inferred]): cloudkitty (shared), cloudkitty.<host> (direct), cloudkitty_fanout_<uuid> (fanout, one per server).
1.2 RPC methods
| Method | Direction | Mode | Namespace / version | Arguments | Response | Emitted at | Handled at |
|---|---|---|---|---|---|---|---|
quote | API → one processor | call | rating / 1.0 | res_data={"usage": {<service>: [{"desc": {...}, "vol": {"qty": "<str>", "unit": "undef"}}]}} | price as a string | cloudkitty/api/v1/controllers/rating.py:200-209 | cloudkitty/orchestrator.py:145-180 |
reset_state | API → one processor | cast | — / 1.0 | res_data={"scopes": [{"scope_id","scope_key","fetcher","collector"}], "last_processed_timestamp": "<ISO>"} | — | cloudkitty/api/v2/scope/state.py:145-148 | cloudkitty/orchestrator.py:221-246 |
reload_modules | API → one processor | cast | rating / 1.1 | — | — | cloudkitty/api/v1/controllers/rating.py:50-53 | cloudkitty/orchestrator.py:182-186 (stores a flag; no effect) |
reload_module | API → all processors | cast, fanout | rating / 1.0 | name | — | cloudkitty/rating/__init__.py:131-134 (from set_priority) | cloudkitty/orchestrator.py:188-193 (stores; no effect) |
enable_module / disable_module | API → all processors | cast, fanout | rating / 1.0 | name | — | cloudkitty/rating/__init__.py:82-97 | cloudkitty/orchestrator.py:195-207 (stores; no effect) |
1.3 Sample frame (quote call, oslo.messaging v2 envelope [inferred])
{"oslo.version": "2.0",
"oslo.message": "{\"method\": \"quote\", \"namespace\": \"rating\", \"version\": \"1.0\",
\"args\": {\"res_data\": {\"usage\": {\"instance\": [
{\"desc\": {\"groupby\": {\"project_id\": \"p1\"}, \"metadata\": {\"flavor_name\": \"m1.small\"}},
\"vol\": {\"qty\": \"1\", \"unit\": \"undef\"}}]}}},
\"_msg_id\": \"…\", \"_reply_q\": \"reply_…\", \"_unique_id\": \"…\", \"_context_…\": …}"}
Reply: {"result": "0.0420", "failure": null, "ending": true}.
2. Database
2.1 Access
oslo.db enginefacade with a thread-local context, SQLite foreign keys enabled (cloudkitty/db/__init__.py:20-38); [database]/connection. Schemas are managed by separate Alembic trees (one version table each [inferred]): core (cloudkitty/db/sqlalchemy/alembic/versions/), hashmap, pyscripts, storage-state, v1 storage. cloudkitty-dbsync upgrade migrates core + rating modules (cloudkitty/cli/dbsync.py:43-75); cloudkitty-storage-init migrates the storage-state tables (cloudkitty/cli/storage.py:21-25, cloudkitty/storage_state/__init__.py:236-237).
2.2 Tables
| Table | Role | Written by | Read by | Model |
|---|---|---|---|---|
states | legacy v1 per-tenant state | v1 storage | v1 storage | cloudkitty/db/sqlalchemy/models.py:24-45 |
modules_state | enabled flag + priority per rating module; also collector_<name> rows (unused) | PUT …/modules, PUT /v1/collector/states | API, every processor cycle | cloudkitty/db/sqlalchemy/models.py:48-75 |
service_to_collector_mappings | deprecated, unused by processing | /v1/collector/mappings | same | cloudkitty/db/sqlalchemy/models.py:78-100 |
hashmap_services, hashmap_fields, hashmap_groups, hashmap_mappings, hashmap_thresholds | price tables | hashmap API | hashmap API; processor at each scope cycle | cloudkitty/rating/hash/db/sqlalchemy/models.py:62-348 |
pyscripts_scripts | scripts (zlib), SHA-512 | pyscripts API | processor at each scope cycle | cloudkitty/rating/pyscripts/db/sqlalchemy/models.py:64-106 |
cloudkitty_storage_states | one row per scope: identifier, scope_key, fetcher, collector, last_processed_timestamp (UTC, naive), active, scope_activation_toggle_date; unique on the 4 first | processor, /v2/scope | processor, API | cloudkitty/storage_state/models.py:31-71 |
storage_scope_reprocessing_schedule | identifier, reason, start/end/current_reprocess_time | POST /v2/task/reprocesses, reprocessor | reprocessor, API | cloudkitty/storage_state/models.py:74-103 |
rated_data_frames | v1 storage only: tenant_id, begin, end, unit, qty NUMERIC(15,5), res_type, rate FLOAT, desc TEXT | v1 storage | v1 API | cloudkitty/storage/v1/sqlalchemy/models.py:32-50 |
Money columns: hashmap cost NUMERIC(40,28), threshold level NUMERIC(20,8) (cloudkitty/rating/hash/db/sqlalchemy/models.py:230-232, cloudkitty/rating/hash/db/sqlalchemy/models.py:306-311). Rated prices are not stored in SQL with v2 storage.
2.3 Representative queries (reconstructed)
Active mappings of a service for the period being rated (cloudkitty/rating/hash/db/sqlalchemy/api.py:178-215, cloudkitty/rating/common/db/filters.py:19-53), run twice per service (tenant NULL, then the scope):
SELECT m.mapping_id FROM hashmap_mappings m
JOIN hashmap_services s ON s.id = m.service_id
WHERE s.service_id = :service_uuid
AND m.tenant_id <=> :tenant -- NULL or the scope id
AND m.start <= :period_start AND (m.end > :period_start OR m.end IS NULL)
AND m.deleted IS NULL;
Each id is then re-read with get_mapping (one query per mapping, plus its group) (cloudkitty/rating/hash/__init__.py:50-69). Cost per scope cycle: 1 + Σ_services (1 + 4 + fields × (1 + 4)) + one query per rule — every cycle, for every scope, in every worker.
Overlap check at mapping creation (cloudkitty/rating/hash/db/sqlalchemy/api.py:340-393):
SELECT * FROM hashmap_mappings
WHERE deleted IS NULL
AND (name = :name OR ( <window overlaps> AND value = :value AND tenant_id = :tenant
AND <field_id|service_id> = :parent AND group_id = :group))
FOR UPDATE;
<window overlaps> is start < :end AND (end >= :start OR end IS NULL) when an end is given, else start > :start OR end IS NULL. Note that tenant_id = NULL / group_id = NULL comparisons are never true in SQL, so global or ungrouped duplicates are caught only by the unique keys [inferred].
Next period of a scope (cloudkitty/storage_state/__init__.py:116-146):
SELECT * FROM cloudkitty_storage_states
WHERE identifier = :scope AND scope_key = :key AND fetcher = :fetcher AND collector = :collector
LIMIT 1;
-- fallback for legacy rows: same identifier with scope_key/fetcher/collector IS NULL, then filled in
Unfinished reprocessing schedules (cloudkitty/storage_state/__init__.py:310-347): WHERE current_reprocess_time IS NULL OR current_reprocess_time < end_reprocess_time ORDER BY id DESC LIMIT … OFFSET … (the order is injected with sql.text("id %s" % order) after the API has validated it).
2.4 Transactions and locks
SELECT … FOR UPDATEon mapping creation, update and delete, threshold update, group delete, module state/priority writes (cloudkitty/rating/hash/db/sqlalchemy/api.py:393,cloudkitty/rating/hash/db/sqlalchemy/api.py:506,cloudkitty/rating/hash/db/sqlalchemy/api.py:636,cloudkitty/db/sqlalchemy/api.py:96-110).- No lock protects the scope state in SQL: concurrency between workers relies on the tooz lock (§ 6).
3. Collectors (usage sources)
The collector is chosen by [collect]/collector (gnocchi by default) and loaded through stevedore with the validated metric file (cloudkitty/collector/__init__.py:39-56, cloudkitty/collector/__init__.py:122-132).
3.1 Metric file (common part)
Per metric (cloudkitty/collector/__init__.py:81-119, cloudkitty/collector/__init__.py:191-227):
| Key | Meaning |
|---|---|
unit (required) | unit stored with each point |
factor, offset | qty = raw × factor + offset (fractions such as 1/1048576 allowed) (cloudkitty/utils/__init__.py:290-292) |
mutate + mutate_map | NONE, NUMBOOL (1 if ≠ 0), NOTNUMBOOL, FLOOR, CEIL, MAP (lookup table) (cloudkitty/utils/__init__.py:250-271) |
groupby, metadata | attribute lists; the scope key is always added to groupby |
alt_name | name under which the metric is rated and stored (the DataFrame key); a metric may be declared several times with different alt_names (internal key name@#alt_name) |
skip_datapoints_expression | Python expression evaluated per point before storage (overrides [orchestrator]/skip_datapoints_expression) |
extra_args | collector-specific |
Every collected point also receives groupby keys week_of_the_year, day_of_the_year, month, year computed from the period start (cloudkitty/collector/__init__.py:285-309).
3.2 Gnocchi
| Item | Value | Where |
|---|---|---|
| Client | gnocchiclient v1 over a keystoneauth session (or basic auth), connect_retries=3, pool http_pool_maxsize, endpoint interface (internalURL), region_name | cloudkitty/collector/gnocchi.py:42-80, cloudkitty/collector/gnocchi.py:150-179 |
extra_args | resource_type (required), resource_key (id), aggregation_method (max), re_aggregation_method (max), force_granularity (3600), use_all_resource_revisions (true), custom_query | cloudkitty/collector/gnocchi.py:105-133 |
| Measures | POST /v1/aggregates?… with operation ["aggregate", <re_agg>, ["metric", <metric>, <agg>]] (or custom_query with RE_AGGREGATION_METHOD, AGGREGATION_METHOD, METRIC_NAME placeholders), resource_type, search = {"and": [{"=": {"type": <rt>}}, {"=": {<scope_key>: <scope>}}]}, groupby = metric groupby, start/stop = the period, granularity, use_history=True | cloudkitty/collector/gnocchi.py:312-417 |
| Quantity | first aggregated value of each group: measures.measures.aggregated[0][2] | cloudkitty/collector/gnocchi.py:438-444 |
| Metadata | only when metadata is non-empty: POST /v1/search/resource/<rt> paginated by resource_key (marker), filtered on resources alive within [start − period, end + period] and on the scope | cloudkitty/collector/gnocchi.py:248-310 |
| Revisions | with use_all_resource_revisions=false, keep the latest revision per id and sum quantities across revisions | cloudkitty/collector/gnocchi.py:495-560 |
rate: re-aggregation | start moved back by one granularity to get two points | cloudkitty/collector/gnocchi.py:365-371 |
| Errors | missing metric → empty result for that metric; other errors abort the scope cycle | cloudkitty/collector/gnocchi.py:335-343 |
Cost per scope and period: one aggregates call per metric (+ one paginated resource search per metric with metadata). Calls for one scope run in a thread pool of [orchestrator]/max_threads (16) with a global timeout of collector_request_timeout (300 s) (cloudkitty/orchestrator.py:70-98, cloudkitty/orchestrator.py:378-391).
3.3 Prometheus and Aetos
| Item | Value | Where |
|---|---|---|
| Prometheus client | GET <prometheus_url>/query?query=…&time=<period end ISO>; basic auth if user and password; cafile/insecure; timeout 60 s | cloudkitty/collector/prometheus.py:24-82, cloudkitty/common/prometheus_client.py:20-47 |
| Aetos client | python-observabilityclient over a keystoneauth session, service type metric-storage, interface (public), region_name | cloudkitty/collector/aetos.py:27-83, cloudkitty/common/aetos_client.py:28-75 |
extra_args | aggregation_method (max; avg, count, min, stddev, stdvar, sum), query_function (abs, ceil, exp, floor, ln, log2, log10, round, sqrt), range_function (changes, delta, deriv, idelta, irange, irate, rate), query_prefix, query_suffix | cloudkitty/collector/prometheus_base.py:37-60 |
| Query built | [prefix] <agg>( [qf]( <range_fn | agg_over_time>( <metric>{<scope_key>="<scope>"}[<period>s] ) ) ) by (<groupby>, <metadata>) [suffix] | cloudkitty/collector/prometheus_base.py:116-174 |
| Quantity | Decimal(value[1]) with 9-digit precision, ROUND_HALF_UP, then factor/offset/mutate | cloudkitty/collector/prometheus_base.py:87-113 |
Example for etc/cloudkitty/metrics.yml entry openstack_nova_server_status (unit instance, groupby id, metadata flavor_name, period 3600):
max(max_over_time(openstack_nova_server_status{project_id="p1"}[3600s])) by (id, project_id, flavor_name)
The scope id is injected into PromQL by string formatting (cloudkitty/collector/prometheus_base.py:131-136); scope ids come from the fetcher, not from API users.
4. Fetchers (scope discovery)
Chosen by [fetcher]/backend (keystone by default) (cloudkitty/fetcher/__init__.py:20-26). Called at the start of every processor loop, in every worker (cloudkitty/orchestrator.py:760-765).
| Fetcher | Request | Scopes returned | Where |
|---|---|---|---|
keystone | GET /v3/projects (all), then for each project roles.list(user=<cloudkitty user>, project=<p>), i.e. GET /v3/projects/<p>/users/<u>/roles [inferred: keystoneclient] | projects where the CloudKitty user has the role named **rating**, unless ignore_rating_role; disabled projects skipped if ignore_disabled_tenants | cloudkitty/fetcher/keystone.py:28-105 |
gnocchi | POST /v1/search/resource/<rt>?details=true for each resource_types (default generic), repeated with {"not": {"in": {<scope_attribute>: [already found]}}} until empty | distinct values of scope_attribute (project_id) | cloudkitty/fetcher/gnocchi.py:30-168 |
prometheus | GET /query?query=max(<metric>{<filters>}) by (<scope_attribute>) | distinct label values | cloudkitty/fetcher/prometheus.py:27-148 |
source | none | the static list [fetcher_source]/sources | cloudkitty/fetcher/source.py:20-41 |
Costs: the Keystone fetcher makes 1 + N requests per loop and per worker (N = number of projects) unless ignore_rating_role is set; the Gnocchi fetcher sends an ever-growing not in list, so the request size grows with the number of scopes.
5. Storage back ends (rated data)
Chosen by [storage]/version (2) and [storage]/backend (influxdb) (cloudkitty/storage/__init__.py:29-43, cloudkitty/storage/__init__.py:195-212). Contract (cloudkitty/storage/v2/__init__.py:43-167): init, push(frames, scope_id), retrieve(begin, end, filters, metric_types, offset, limit, paginate), delete(begin, end, filters), total(groupby, begin, end, metric_types, filters, custom_fields, offset, limit, paginate).
5.1 InfluxDB (default)
| Item | v1 (version = 1, InfluxQL) | v2 (version = 2, Flux) | Where |
|---|---|---|---|
| Connection | influxdb.InfluxDBClient(host, port, username, password, database, ssl, verify_ssl) | influxdb_client.InfluxDBClient(url, token, org, timeout=query_timeout ms) | cloudkitty/storage/v2/influx.py:125-141, cloudkitty/storage/v2/influx.py:514-521 |
| Write | write_points in chunks of 500, retention policy autogen | write_api(SYNCHRONOUS) into bucket | cloudkitty/storage/v2/influx.py:147-195, cloudkitty/storage/v2/influx.py:559-572 |
| Total | SELECT SUM(qty) AS qty, SUM(price) AS rate FROM "dataframes" WHERE time >= … AND time < … [AND "k"='v' …] [AND "type"='…'] [GROUP BY time(<period>s), "g1",…] | Flux built from the same arguments | cloudkitty/storage/v2/influx.py:229-257, cloudkitty/storage/v2/influx.py:681-745 |
| Delete | DELETE FROM "dataframes" WHERE time >= … [AND time < …] AND "<scope_key>"='<id>' | delete_api.delete(begin, end, predicate='_measurement="dataframes" AND "<k>"="<v>"') | cloudkitty/storage/v2/influx.py:293-315, cloudkitty/storage/v2/influx.py:535-547 |
Point written (one per DataPoint, cloudkitty/storage/v2/influx.py:156-195):
{"measurement": "dataframes",
"time": "2026-11-09T10:00:00+00:00",
"tags": {"type": "instance", "project_id": "p1", "id": "vm-1", "user_id": "u1",
"week_of_the_year": "45", "day_of_the_year": "313", "month": "11", "year": "2026"},
"fields": {"qty": 1.0, "price": 0.042, "unit": "instance", "description": null,
"flavor_name": "m1.small",
"groupby": "type|project_id|id|user_id|week_of_the_year|day_of_the_year|month|year",
"metadata": "flavor_name", "__ck_collect_period": 3600}}
groupby attributes are tags (indexed, high cardinality with id), metadata attributes are fields. InfluxQL text is assembled by string formatting from API filters (cloudkitty/storage/v2/influx.py:198-227). The v2 retrieve ignores the metric-type filter (cloudkitty/storage/v2/influx.py:523-533).
5.2 Elasticsearch and OpenSearch
| Item | Value | Where |
|---|---|---|
| Connection | requests.Session to host (http://localhost:9200), index index_name (cloudkitty), insecure/cafile; no timeout on requests | cloudkitty/storage/v2/elasticsearch/__init__.py:33-101, cloudkitty/storage/v2/elasticsearch/client.py:41-60, cloudkitty/storage/v2/elasticsearch/client.py:144-153 |
| Mapping | start/end date, type/unit keyword, qty/price double, groupby/metadata dynamic objects, strings as keywords; PUT /<index>/_mapping/_doc?include_type_name=true at init (OpenSearch: POST /<index>/_doc) | cloudkitty/storage/v2/elasticsearch/__init__.py:62-84, cloudkitty/storage/v2/elasticsearch/client.py:155-171 |
| Write | POST /<index>/_bulk (or /<index>/_doc/_bulk) in chunks; @timestamp added when use_datastream | cloudkitty/storage/v2/elasticsearch/client.py:232-316 |
| Query | bool.must = start >= begin, end <= end, type terms; bool.should = one term on groupby.<k> and one on metadata.<k> per filter, minimum_should_match = len(should)//2 | cloudkitty/storage/v2/elasticsearch/client.py:70-142 |
| Total | composite aggregation over the group-bys with sum(price), sum(qty), iterated with after_key over all buckets | cloudkitty/storage/v2/elasticsearch/client.py:385-442 |
| Retrieve | scroll API (scroll_duration 30 s), scrolls closed at the end | cloudkitty/storage/v2/elasticsearch/client.py:183-230, cloudkitty/storage/v2/elasticsearch/client.py:323-367 |
| Delete | POST /<index>/_delete_by_query with must only — scope filters are dropped | cloudkitty/storage/v2/elasticsearch/client.py:369-383 |
OpenSearch differs only by .keyword sub-fields in aggregations and the mapping call (cloudkitty/storage/v2/opensearch/client.py:100-171).
5.3 Loki
| Item | Value | Where |
|---|---|---|
| Connection | url (http://localhost:3100/loki/api/v1), header X-Scope-OrgID: <tenant> (cloudkitty), client cert, timeout 60 s | cloudkitty/storage/v2/loki/__init__.py:34-129, cloudkitty/storage/v2/loki/client.py:32-52 |
| Write | POST /push {"streams": [{"stream": {<stream labels>, "project_id": <p>}, "values": [["<ns>", "<JSON line>", {<structured metadata>}]]}]}; batches of buffer_size; 204 expected | cloudkitty/storage/v2/loki/client.py:54-69, cloudkitty/storage/v2/loki/client.py:212-241, cloudkitty/storage/v2/loki/client.py:324-370 |
| Query | GET /query_range?query={labels} | json | …&start&end&limit (≤ 5000) | cloudkitty/storage/v2/loki/client.py:184-210 |
| Total | sums computed in Python over the lines returned by one query_range | cloudkitty/storage/v2/loki/client.py:372-447 |
| Delete | POST /delete?query=…&start=<s>&end=<s> (Loki deletion API must be enabled) | cloudkitty/storage/v2/loki/client.py:242-273 |
The stream label is always named project_id, whatever [collect]/scope_key is (cloudkitty/storage/v2/loki/client.py:329-331).
5.4 v1 storage (deprecated)
sqlalchemy writes rated_data_frames; hybrid keeps state in SQL and data in Gnocchi resource types rating_service_* (cloudkitty/storage/v1/sqlalchemy/__init__.py:36-196, cloudkitty/storage/v1/hybrid/backends/gnocchi.py:100-111). Wrapped by V1StorageAdapter, which only understands the project_id filter and type/project_id group-bys, and whose delete does nothing (cloudkitty/storage/__init__.py:64-192).
6. Coordination (tooz)
| Lock | Name | Taken by | Mode | Where |
|---|---|---|---|---|
| scope rating | sha256("cloudkitty-<scope>-<collector>-<fetcher>-<scope_key>") | processor workers | non-blocking: a busy scope is skipped this loop | cloudkitty/orchestrator.py:111-118, cloudkitty/orchestrator.py:700-735 |
| scope reset | same name | ScopeEndpoint.reset_state | blocking | cloudkitty/orchestrator.py:221-246 |
| reprocessing | same function on "<ReprocessingWorker>-id=<scope>-start=<s>-end=<e>" | reprocessor workers | non-blocking | cloudkitty/orchestrator.py:802-806 |
Back end [orchestrator]/coordination_url, default file:///var/lib/cloudkitty/locks — a host-local lock: several processor hosts need a shared back end (etcd, ZooKeeper, MySQL…) [inferred from tooz semantics]. Each worker also starts a coordinator with heartbeat (cloudkitty/orchestrator.py:657-661). Note that the reprocessing lock name differs from the rating lock, so a scope can be rated and reprocessed at the same time [inferred].
7. Keystone
| Call | When | Authentication | Where |
|---|---|---|---|
| token validation | every authenticated API request | keystonemiddleware ([keystone_authtoken]) | cloudkitty/api/middleware.py:19-45 |
GET /v3/auth/tokens (X-Subject-Token = caller's token) | every hashmap mapping POST/PUT/DELETE and pyscript POST/PUT/DELETE, to fill created_by/updated_by/deleted_by | service credentials from [keystone_authtoken], new session per call, **verify=False** | cloudkitty/common/custom_session.py:38-58 |
| project and role listing | each processor loop with the Keystone fetcher | [fetcher_keystone] credentials | cloudkitty/fetcher/keystone.py:60-105 |
| catalogue lookup | Gnocchi/Aetos clients | [collector_gnocchi], [collector_aetos], [fetcher_gnocchi] | cloudkitty/collector/gnocchi.py:154-179, cloudkitty/collector/aetos.py:59-77 |
8. Other
- Files read: the metric file at every
Workercreation and every/v1/infocall (cloudkitty/orchestrator.py:301,cloudkitty/api/v1/controllers/info.py:36-39);etc/cloudkitty/api_paste.iniat API start (cloudkitty/api/app.py:77-91). - Code execution:
execof pyscripts andevalofskip_datapoints_expressioninside the processor (cloudkitty/rating/pyscripts/__init__.py:98-101,cloudkitty/orchestrator.py:494-507). - No subprocess, no privsep, no OVSDB, no cache server.
Source: reverse-spec-cloudkitty/03-interactions_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.