Value service
Value service provides an append only time series data store. It serves as the underlying store for the event log.
Client roles
client urn:ads:platform:value-service
| name | description |
|---|---|
| value-reader | Reader role for value service. This role is used to allow users to read values from the values. It is part of the tenant-admin composite role and allows tenant administrators to read and search the event log. |
| value-writer | Writer role for value service. This role is used to allow service accounts to write values to the value service. |
Concepts
Value definition
Value definition is an optional metadata description for a particular value (identified by a specific namespace and name). The definition provides write-time validation via json schema. Value definitions are configured in the configuration service under the platform:value-service namespace and name.
Value
A value represents a particular time series stream. Each write results in a new record with a timestamp. It differs from a typical transactional record in that the record has no unique identity; instead it represents another entry in the set of entries for the value. Consumers can write scalars or json objects to values.
Metrics
Metrics are numeric values that can be included in value writes. The are automatically included in time interval aggregations and can be used for basic KPIs.
Metric interval rollups
Reading a metric interval aggregates the metrics table on every request, which gets slow as history grows. The metric_interval_rollups table stores each interval’s buckets so those reads become a keyed lookup, and a scheduled job keeps it current.
The job runs every five minutes and moves one interval’s coverage by at most a chunk in each direction: forward from where coverage ended, so recent buckets stay fresh, and backward through history, so an interval that has never been rolled up fills in over successive runs. Both windows touch the existing coverage, which keeps metric_interval_rollup_coverage a single contiguous span per interval.
Reads consult that coverage. A request whose window falls entirely inside it is served from the rollups; anything reaching outside falls back to a live aggregate of the raw metrics table, which is slower but always complete, so rollups can be populated progressively without the API losing data in the meantime.
The end of a requested window is first snapped back to the start of the interval in progress, so only whole periods are ever served: a monthly request made on 17 September returns August as its most recent bucket rather than seventeen days of September, and the bound actually used comes back as page.intervalMax so a shortened window is distinguishable from one that simply holds no data. Snapping is what lets coverage satisfy a request reaching up to “now” — a composed interval’s rollup can never contain its own open bucket, so without it every such read missed on the trailing edge and fell back to a live aggregate of the whole window.
Each interval is aggregated from the next finer one, and only one_minute reads the raw metrics table. sum, count, min and max all compose — a month’s sum is the sum of its days’ sums, its min the least of their mins — so a monthly bucket is built from about thirty daily rows rather than a month of raw metrics. That is why the rollups store those four and derive the average on read: an average of averages would not compose. Both weekly and monthly build from daily, because a week can straddle a month boundary and so does not nest inside one.
An interval can only advance as far as its source has been rolled up, so coverage fills from the finest interval outwards and a coarse interval waits, logging at debug, until its source has something to build from.
METRIC_INTERVAL_ROLLUP_MAX_CHUNK_HOURS caps every interval’s window, and defaults to a week. It cannot cut a window below a single bucket, since an interval whose chunk is narrower than its bucket would never finish backfilling. A composed interval’s read cost follows the number of rollup rows rather than the span of time it covers, but what it writes does not: the upsert groups by namespace, name, tenant and metric as well as the bucket, so a wide window can still write one row per bucket per distinct series in a single statement — enough, on a tenant-heavy deployment, to exhaust the database’s shared lock table even though no relation the query touches is itself large. Capping every interval’s window keeps that upsert bounded the same way it keeps one_minute’s raw scan bounded.
Intervals advance one at a time, each in its own transaction under a Postgres advisory lock, so of the replicas that all schedule the job, whichever gets the lock advances that interval and the rest skip it; within a replica, a tick arriving while the last run is still going is skipped as well. Committing per interval is what keeps the job making progress: an interval that exceeds METRIC_INTERVAL_ROLLUP_STATEMENT_TIMEOUT_MS rolls back only itself and is logged by name, while the intervals that already succeeded stay committed. DB_POOL_MAX bounds how many connections a replica can hold at all — worth setting deliberately, since it is multiplied by the replica count against one database.
Coverage is recorded in whole buckets: both edges are snapped down to a bucket boundary, so the bucket currently being written to is never advertised as rolled up. Reads of a window that includes it fall back to the metrics_* views, which is what keeps a partially totalled bucket from being served as a complete one.
Set METRIC_INTERVAL_ROLLUP_JOB_ENABLED=false to stop the job. Reads keep working — they fall back to the views — but the rollups stop advancing and stale coverage will gradually stop matching incoming requests.
SELECT "interval", covered_from, covered_to, updated_at
FROM metric_interval_rollup_coverage
ORDER BY "interval";
Rollups store sum and count rather than an average, because an average of averages is not the average; the read path divides the two.
Code examples
Write a value
const namespace = 'support';
const name = 'application-stats';
const value = {
correlationId,
context,
timestamp: new Date(),
value: {
property: 123,
},
};
const response = await fetch(`https://value-service.adsp.alberta.ca/value/v1/${namespace}/values/${name}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(value),
});
const { correlationId, context, timestamp, value } = await response.json();
Read a value
const namespace = 'support';
const name = 'application-stats';
const response = await fetch(`https://value-service.adsp.alberta.ca/value/v1/${namespace}/values/${name}`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
const { results, page } = await response.json();