diff --git a/analyzer/aggregate_stats/aggregate_stats.go b/analyzer/aggregate_stats/aggregate_stats.go index 397ee0613..18c30b6eb 100644 --- a/analyzer/aggregate_stats/aggregate_stats.go +++ b/analyzer/aggregate_stats/aggregate_stats.go @@ -181,6 +181,36 @@ func (a *aggregateStatsAnalyzer) aggregateStatsWorker(ctx context.Context) { statsComputations = append(statsComputations, sc) } + // Compute daily total accounts for all layers. + // This is a simple count of all accounts in the layer, updated once per day. + for _, layer := range statsLayers { + sc := &statsComputation{ + target: a.target, + name: "total_accounts" + "_" + layer, + layer: layer, + outputTable: "stats.total_accounts", + outputColumn: "total_accounts", + windowSize: 24 * time.Hour, + windowStep: 24 * time.Hour, + } + if layer == layerConsensus { + // Consensus layer queries. + sc.statsQuery = QueryConsensusTotalAccounts + sc.latestAvailableDataTs = func(ctx context.Context, target storage.TargetStorage) (*time.Time, error) { + var latestBlockTs *time.Time + return latestBlockTs, target.QueryRow(ctx, QueryLatestConsensusBlockTime).Scan(&latestBlockTs) + } + } else { + // Runtime layer queries. + sc.statsQuery = QueryRuntimeTotalAccounts + sc.latestAvailableDataTs = func(ctx context.Context, target storage.TargetStorage) (*time.Time, error) { + var latestBlockTs *time.Time + return latestBlockTs, target.QueryRow(ctx, QueryLatestRuntimeBlockTime, sc.layer).Scan(&latestBlockTs) + } + } + statsComputations = append(statsComputations, sc) + } + for { // If batch limit was reached, or the batch timeout was reached, start the next iteration sooner. var useCatchupTimeout bool diff --git a/analyzer/aggregate_stats/queries.go b/analyzer/aggregate_stats/queries.go index d1a1558b9..859b6066b 100644 --- a/analyzer/aggregate_stats/queries.go +++ b/analyzer/aggregate_stats/queries.go @@ -117,4 +117,23 @@ const ( ORDER BY window_end DESC LIMIT 1 ` + + // QueryConsensusTotalAccounts is the query to get the total number of accounts + // in the consensus layer. + // Note: The query uses standard parameters for consistency with other stats queries, + // but only counts total accounts regardless of time window. + QueryConsensusTotalAccounts = ` + WITH dummy AS (SELECT $1::text, $2::timestamptz, $3::timestamptz) -- Dummy select for parameter compatibility. + SELECT COUNT(*) + FROM chain.accounts + ` + + // QueryRuntimeTotalAccounts is the query to get the total number of accounts + // in a runtime layer. + QueryRuntimeTotalAccounts = ` + WITH dummy AS (SELECT $2::timestamptz, $3::timestamptz) -- Dummy select for parameter compatibility. + SELECT COUNT(*) + FROM chain.runtime_accounts + WHERE runtime = $1 + ` ) diff --git a/api/spec/v1.yaml b/api/spec/v1.yaml index 302fd5966..15edc0480 100644 --- a/api/spec/v1.yaml +++ b/api/spec/v1.yaml @@ -1736,6 +1736,31 @@ paths: $ref: '#/components/schemas/ActiveAccountsList' <<: *common_error_responses + /{layer}/stats/total_accounts: + get: + summary: | + Returns the total number of accounts for either consensus or one of the paratimes. + This is a daily snapshot of the total account count, updated once per day. + parameters: + - *limit + - *offset + - in: path + name: layer + required: true + schema: + allOf: [$ref: '#/components/schemas/Layer'] + description: | + The layer for which to return the total accounts. + responses: + '200': + description: | + A JSON object containing a list of total account snapshots. + content: + application/json: + schema: + $ref: '#/components/schemas/TotalAccountsList' + <<: *common_error_responses + components: schemas: Layer: @@ -4141,6 +4166,33 @@ components: description: The number of active accounts for the 24hour window ending at window_end. example: 420 + TotalAccountsList: + type: object + required: [snapshots] + properties: + snapshots: + type: array + items: + allOf: [$ref: '#/components/schemas/TotalAccountsSnapshot'] + description: The list of total account snapshots, ordered by date descending. + description: | + A list of daily total account snapshots showing account growth over time. + + TotalAccountsSnapshot: + type: object + required: [date, total_accounts] + properties: + date: + type: string + format: date-time + description: The date of the snapshot. + example: *iso_timestamp_1 + total_accounts: + type: integer + format: uint64 + description: The total number of accounts as of this date. + example: 42000 + RoflAppList: allOf: - $ref: '#/components/schemas/List' diff --git a/api/v1/strict_server.go b/api/v1/strict_server.go index 8382e9954..d8e186815 100644 --- a/api/v1/strict_server.go +++ b/api/v1/strict_server.go @@ -271,6 +271,19 @@ func (srv *StrictServerImpl) GetLayerStatsActiveAccounts(ctx context.Context, re return apiTypes.GetLayerStatsActiveAccounts200JSONResponse(*activeAccountsList), nil } +func (srv *StrictServerImpl) GetLayerStatsTotalAccounts(ctx context.Context, request apiTypes.GetLayerStatsTotalAccountsRequestObject) (apiTypes.GetLayerStatsTotalAccountsResponseObject, error) { + // Additional param validation. + if !request.Layer.IsValid() { + return nil, &apiTypes.InvalidParamFormatError{ParamName: "layer", Err: fmt.Errorf("not a valid enum value: %s", request.Layer)} + } + + totalAccountsList, err := srv.dbClient.TotalAccounts(ctx, request.Layer, request.Params) + if err != nil { + return nil, err + } + return apiTypes.GetLayerStatsTotalAccounts200JSONResponse(*totalAccountsList), nil +} + func (srv *StrictServerImpl) GetConsensusTransactions(ctx context.Context, request apiTypes.GetConsensusTransactionsRequestObject) (apiTypes.GetConsensusTransactionsResponseObject, error) { txs, err := srv.dbClient.Transactions(ctx, request.Params, nil) if err != nil { diff --git a/storage/client/client.go b/storage/client/client.go index cfc870e20..7d7c216c4 100644 --- a/storage/client/client.go +++ b/storage/client/client.go @@ -3019,3 +3019,35 @@ func (c *StorageClient) DailyActiveAccounts(ctx context.Context, layer apiTypes. return &ts, nil } + +// TotalAccounts returns a list of total account snapshots. +func (c *StorageClient) TotalAccounts(ctx context.Context, layer apiTypes.Layer, p apiTypes.GetLayerStatsTotalAccountsParams) (*TotalAccountsList, error) { + rows, err := c.db.Query( + ctx, + queries.TotalAccounts, + translateLayer(layer), + p.Limit, + p.Offset, + ) + if err != nil { + return nil, wrapError(err) + } + defer rows.Close() + + ts := TotalAccountsList{ + Snapshots: []apiTypes.TotalAccountsSnapshot{}, + } + for rows.Next() { + var t apiTypes.TotalAccountsSnapshot + if err := rows.Scan( + &t.Date, + &t.TotalAccounts, + ); err != nil { + return nil, wrapError(err) + } + t.Date = t.Date.UTC() // Ensure UTC timestamp in response. + ts.Snapshots = append(ts.Snapshots, t) + } + + return &ts, nil +} diff --git a/storage/client/queries/queries.go b/storage/client/queries/queries.go index 34bd7fe83..a9650b371 100644 --- a/storage/client/queries/queries.go +++ b/storage/client/queries/queries.go @@ -1269,6 +1269,24 @@ const ( window_end DESC LIMIT $2::bigint OFFSET $3::bigint` + + // TotalAccounts returns the query for total account snapshots per layer. + TotalAccounts = ` + SELECT window_end, total_accounts + FROM stats.total_accounts + WHERE layer = $1::text + ORDER BY + window_end DESC + LIMIT $2::bigint + OFFSET $3::bigint` + + // LatestTotalAccounts returns the most recent total account count for a layer. + LatestTotalAccounts = ` + SELECT window_end, total_accounts + FROM stats.total_accounts + WHERE layer = $1::text + ORDER BY window_end DESC + LIMIT 1` ) func escapeLike(s string) string { diff --git a/storage/client/types.go b/storage/client/types.go index e6118bcd0..cb96a733e 100644 --- a/storage/client/types.go +++ b/storage/client/types.go @@ -170,6 +170,9 @@ type TxVolume = api.TxVolume // DailyActiveAccountsList is the storage response for GetDailyActiveAccounts. type DailyActiveAccountsList = api.ActiveAccountsList +// TotalAccountsList is the storage response for GetTotalAccounts. +type TotalAccountsList = api.TotalAccountsList + // RoflAppList is the storage response for GetRuntimeRoflApps. type RoflAppList = api.RoflAppList diff --git a/storage/migrations/54_total_accounts_stats.up.sql b/storage/migrations/54_total_accounts_stats.up.sql new file mode 100644 index 000000000..c46b48042 --- /dev/null +++ b/storage/migrations/54_total_accounts_stats.up.sql @@ -0,0 +1,18 @@ +-- Add stats table for tracking total number of accounts per layer. + +BEGIN; + +-- total_accounts stores the daily snapshot of total accounts per layer. +CREATE TABLE stats.total_accounts +( + layer TEXT NOT NULL, + window_end TIMESTAMP WITH TIME ZONE NOT NULL, + total_accounts UINT63 NOT NULL, + + PRIMARY KEY (layer, window_end) +); + +-- Grant read access. +GRANT SELECT ON stats.total_accounts TO PUBLIC; + +COMMIT;