OpenKeel: the sovereign French Cloud solution of the inherent group, with a European reach Discover inherent

Reverse-spec · Services

cloudkitty — Interactions

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

ElementValueWhere
Transport[DEFAULT]/transport_url, default exchange cloudkitty (set_transport_defaults('cloudkitty'))cloudkitty/messaging.py:25-47
Client targettopic cloudkitty, version 1.0, no server → round-robin over processorscloudkitty/messaging.py:50-61
Server targettopic cloudkitty, server [DEFAULT]/host, version 1.0, executor threading, DefaultRPCAccessPolicycloudkitty/orchestrator.py:672-681, cloudkitty/messaging.py:64-71
EndpointsRatingEndpoint (namespace rating, 1.0), ScopeEndpoint (no namespace, 1.0)cloudkitty/orchestrator.py:121-123, cloudkitty/orchestrator.py:210-211
Servers per hostone 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

MethodDirectionModeNamespace / versionArgumentsResponseEmitted atHandled at
quoteAPI → one processorcallrating / 1.0res_data={"usage": {<service>: [{"desc": {...}, "vol": {"qty": "<str>", "unit": "undef"}}]}}price as a stringcloudkitty/api/v1/controllers/rating.py:200-209cloudkitty/orchestrator.py:145-180
reset_stateAPI → one processorcast— / 1.0res_data={"scopes": [{"scope_id","scope_key","fetcher","collector"}], "last_processed_timestamp": "<ISO>"}—cloudkitty/api/v2/scope/state.py:145-148cloudkitty/orchestrator.py:221-246
reload_modulesAPI → one processorcastrating / 1.1——cloudkitty/api/v1/controllers/rating.py:50-53cloudkitty/orchestrator.py:182-186 (stores a flag; no effect)
reload_moduleAPI → all processorscast, fanoutrating / 1.0name—cloudkitty/rating/__init__.py:131-134 (from set_priority)cloudkitty/orchestrator.py:188-193 (stores; no effect)
enable_module / disable_moduleAPI → all processorscast, fanoutrating / 1.0name—cloudkitty/rating/__init__.py:82-97cloudkitty/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

TableRoleWritten byRead byModel
stateslegacy v1 per-tenant statev1 storagev1 storagecloudkitty/db/sqlalchemy/models.py:24-45
modules_stateenabled flag + priority per rating module; also collector_<name> rows (unused)PUT …/modules, PUT /v1/collector/statesAPI, every processor cyclecloudkitty/db/sqlalchemy/models.py:48-75
service_to_collector_mappingsdeprecated, unused by processing/v1/collector/mappingssamecloudkitty/db/sqlalchemy/models.py:78-100
hashmap_services, hashmap_fields, hashmap_groups, hashmap_mappings, hashmap_thresholdsprice tableshashmap APIhashmap API; processor at each scope cyclecloudkitty/rating/hash/db/sqlalchemy/models.py:62-348
pyscripts_scriptsscripts (zlib), SHA-512pyscripts APIprocessor at each scope cyclecloudkitty/rating/pyscripts/db/sqlalchemy/models.py:64-106
cloudkitty_storage_statesone row per scope: identifier, scope_key, fetcher, collector, last_processed_timestamp (UTC, naive), active, scope_activation_toggle_date; unique on the 4 firstprocessor, /v2/scopeprocessor, APIcloudkitty/storage_state/models.py:31-71
storage_scope_reprocessing_scheduleidentifier, reason, start/end/current_reprocess_timePOST /v2/task/reprocesses, reprocessorreprocessor, APIcloudkitty/storage_state/models.py:74-103
rated_data_framesv1 storage only: tenant_id, begin, end, unit, qty NUMERIC(15,5), res_type, rate FLOAT, desc TEXTv1 storagev1 APIcloudkitty/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 UPDATE on 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):

KeyMeaning
unit (required)unit stored with each point
factor, offsetqty = raw × factor + offset (fractions such as 1/1048576 allowed) (cloudkitty/utils/__init__.py:290-292)
mutate + mutate_mapNONE, NUMBOOL (1 if ≠ 0), NOTNUMBOOL, FLOOR, CEIL, MAP (lookup table) (cloudkitty/utils/__init__.py:250-271)
groupby, metadataattribute lists; the scope key is always added to groupby
alt_namename 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_expressionPython expression evaluated per point before storage (overrides [orchestrator]/skip_datapoints_expression)
extra_argscollector-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

ItemValueWhere
Clientgnocchiclient v1 over a keystoneauth session (or basic auth), connect_retries=3, pool http_pool_maxsize, endpoint interface (internalURL), region_namecloudkitty/collector/gnocchi.py:42-80, cloudkitty/collector/gnocchi.py:150-179
extra_argsresource_type (required), resource_key (id), aggregation_method (max), re_aggregation_method (max), force_granularity (3600), use_all_resource_revisions (true), custom_querycloudkitty/collector/gnocchi.py:105-133
MeasuresPOST /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=Truecloudkitty/collector/gnocchi.py:312-417
Quantityfirst aggregated value of each group: measures.measures.aggregated[0][2]cloudkitty/collector/gnocchi.py:438-444
Metadataonly 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 scopecloudkitty/collector/gnocchi.py:248-310
Revisionswith use_all_resource_revisions=false, keep the latest revision per id and sum quantities across revisionscloudkitty/collector/gnocchi.py:495-560
rate: re-aggregationstart moved back by one granularity to get two pointscloudkitty/collector/gnocchi.py:365-371
Errorsmissing metric → empty result for that metric; other errors abort the scope cyclecloudkitty/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

ItemValueWhere
Prometheus clientGET <prometheus_url>/query?query=…&time=<period end ISO>; basic auth if user and password; cafile/insecure; timeout 60 scloudkitty/collector/prometheus.py:24-82, cloudkitty/common/prometheus_client.py:20-47
Aetos clientpython-observabilityclient over a keystoneauth session, service type metric-storage, interface (public), region_namecloudkitty/collector/aetos.py:27-83, cloudkitty/common/aetos_client.py:28-75
extra_argsaggregation_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_suffixcloudkitty/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
QuantityDecimal(value[1]) with 9-digit precision, ROUND_HALF_UP, then factor/offset/mutatecloudkitty/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).

FetcherRequestScopes returnedWhere
keystoneGET /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_tenantscloudkitty/fetcher/keystone.py:28-105
gnocchiPOST /v1/search/resource/<rt>?details=true for each resource_types (default generic), repeated with {"not": {"in": {<scope_attribute>: [already found]}}} until emptydistinct values of scope_attribute (project_id)cloudkitty/fetcher/gnocchi.py:30-168
prometheusGET /query?query=max(<metric>{<filters>}) by (<scope_attribute>)distinct label valuescloudkitty/fetcher/prometheus.py:27-148
sourcenonethe static list [fetcher_source]/sourcescloudkitty/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)

Itemv1 (version = 1, InfluxQL)v2 (version = 2, Flux)Where
Connectioninfluxdb.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
Writewrite_points in chunks of 500, retention policy autogenwrite_api(SYNCHRONOUS) into bucketcloudkitty/storage/v2/influx.py:147-195, cloudkitty/storage/v2/influx.py:559-572
TotalSELECT 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 argumentscloudkitty/storage/v2/influx.py:229-257, cloudkitty/storage/v2/influx.py:681-745
DeleteDELETE 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

ItemValueWhere
Connectionrequests.Session to host (http://localhost:9200), index index_name (cloudkitty), insecure/cafile; no timeout on requestscloudkitty/storage/v2/elasticsearch/__init__.py:33-101, cloudkitty/storage/v2/elasticsearch/client.py:41-60, cloudkitty/storage/v2/elasticsearch/client.py:144-153
Mappingstart/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
WritePOST /<index>/_bulk (or /<index>/_doc/_bulk) in chunks; @timestamp added when use_datastreamcloudkitty/storage/v2/elasticsearch/client.py:232-316
Querybool.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)//2cloudkitty/storage/v2/elasticsearch/client.py:70-142
Totalcomposite aggregation over the group-bys with sum(price), sum(qty), iterated with after_key over all bucketscloudkitty/storage/v2/elasticsearch/client.py:385-442
Retrievescroll API (scroll_duration 30 s), scrolls closed at the endcloudkitty/storage/v2/elasticsearch/client.py:183-230, cloudkitty/storage/v2/elasticsearch/client.py:323-367
DeletePOST /<index>/_delete_by_query with must only — scope filters are droppedcloudkitty/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

ItemValueWhere
Connectionurl (http://localhost:3100/loki/api/v1), header X-Scope-OrgID: <tenant> (cloudkitty), client cert, timeout 60 scloudkitty/storage/v2/loki/__init__.py:34-129, cloudkitty/storage/v2/loki/client.py:32-52
WritePOST /push {"streams": [{"stream": {<stream labels>, "project_id": <p>}, "values": [["<ns>", "<JSON line>", {<structured metadata>}]]}]}; batches of buffer_size; 204 expectedcloudkitty/storage/v2/loki/client.py:54-69, cloudkitty/storage/v2/loki/client.py:212-241, cloudkitty/storage/v2/loki/client.py:324-370
QueryGET /query_range?query={labels} | json | …&start&end&limit (≤ 5000)cloudkitty/storage/v2/loki/client.py:184-210
Totalsums computed in Python over the lines returned by one query_rangecloudkitty/storage/v2/loki/client.py:372-447
DeletePOST /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)

LockNameTaken byModeWhere
scope ratingsha256("cloudkitty-<scope>-<collector>-<fetcher>-<scope_key>")processor workersnon-blocking: a busy scope is skipped this loopcloudkitty/orchestrator.py:111-118, cloudkitty/orchestrator.py:700-735
scope resetsame nameScopeEndpoint.reset_stateblockingcloudkitty/orchestrator.py:221-246
reprocessingsame function on "<ReprocessingWorker>-id=<scope>-start=<s>-end=<e>"reprocessor workersnon-blockingcloudkitty/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

CallWhenAuthenticationWhere
token validationevery authenticated API requestkeystonemiddleware ([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_byservice credentials from [keystone_authtoken], new session per call, **verify=False**cloudkitty/common/custom_session.py:38-58
project and role listingeach processor loop with the Keystone fetcher[fetcher_keystone] credentialscloudkitty/fetcher/keystone.py:60-105
catalogue lookupGnocchi/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 Worker creation and every /v1/info call (cloudkitty/orchestrator.py:301, cloudkitty/api/v1/controllers/info.py:36-39); etc/cloudkitty/api_paste.ini at API start (cloudkitty/api/app.py:77-91).
  • Code execution: exec of pyscripts and eval of skip_datapoints_expression inside 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.