Files
vfs/pkg/cost/cost.go
zeekay 5b231600cb cost: add Tiered (hot SSD + cold archive) + Cluster (writer/reader split)
Three new primitives, all brand-neutral:

  BlockTimeWorkload(blockTime, avgBlockBytes, monthsRetention,
                    readAmp, objectBytes) -> Workload
      Generic chain-style workload constructor parameterised by
      block time so a single sweep covers 1ms..1s with one call.

  Tiered{RatePerSecond, HotWindow, ColdRetention, HotBackend,
         ColdBackend, PutsPerByte, GetsPerByte, EgressGBPerMonth}
      Splits cost between a hot tier (PVC/SSD, no per-op fees) and a
      cold tier (object store). The hot window is configurable so
      operators can dial query latency vs cost.

  Cluster{Writers, Readers, Base, ReadsPerReaderRatio,
          EgressPerReaderGBPerMonth}
      Models a shared-archive fleet. Content-addressed dedup means
      Writers does not multiply storage/PUT cost (the dedup
      invariant). Readers each contribute their GET share linearly.

Tests pin three invariants:

  TestBlockTimeSweep_PrintsCostCurve     — 1ms..1s sweep + hot sizes
  TestTiered_24h_Hot_R2_Cold             — tiering math sanity
  TestCluster_DedupKeepsWriterCostFlat   — 1w vs 11w cost identical
  TestCluster_ReadersScaleGetCost        — 1, 11, 100, 1000 readers
2026-06-03 09:28:48 -07:00

448 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package cost computes operational cost for VFS workloads across the
// pluggable backends VFS supports. Numbers are list prices as of 2026-06;
// override in deployment-specific configs.
//
// The model splits a workload into three contributions:
//
// storage = GB-months held in the bucket
// requests = PUT/GET/DELETE/LIST counts
// egress = GB read out of the cloud's edge to consumers
//
// Egress is the wild card for any read-heavy workload — if consumers
// live in the same cloud region as the backend, intra-region traffic
// is normally free. Cross-cloud fanout (e.g. workload in DOKS reading
// from AWS S3) is what gets expensive. Pick a backend with the egress
// origin/destination in mind.
//
// The package is intentionally workload-neutral. Application-specific
// projections (a validator archive, a sqlite query DB, an indexer
// snapshot pipeline) compose this package's Workload struct themselves.
package cost
import (
"fmt"
"strings"
"time"
)
// Backend names the supported object stores. Add new ones by extending
// the Catalog below.
type Backend string
const (
BackendAWSS3Std Backend = "aws-s3-standard"
BackendAWSS3IA Backend = "aws-s3-ia"
BackendAWSGlacierIR Backend = "aws-s3-glacier-instant"
BackendCloudflareR2 Backend = "cloudflare-r2"
BackendDOSpaces Backend = "do-spaces"
BackendGCSStandard Backend = "gcs-standard"
BackendGCSColdline Backend = "gcs-coldline"
// BackendPVCBlock is the "no VFS, just a Kubernetes Block PVC"
// baseline. Useful when comparing whether moving to VFS+object
// store actually saves money for a given workload.
BackendPVCBlock Backend = "k8s-pvc-block"
)
// Pricing is the per-unit list-price slice for a backend. Numbers are
// USD list prices as documented inline; document any drift in this
// file's CHANGELOG stanza.
type Pricing struct {
// StoragePerGBMonth is the monthly $/GB held.
StoragePerGBMonth float64
// PutPer1k is the cost per 1000 PUT requests.
PutPer1k float64
// GetPer1k is the cost per 1000 GET requests.
GetPer1k float64
// EgressPerGB is the cost per GB read OUT of the provider's edge.
// Intra-region same-cloud reads are normally free; this rate
// applies to cross-region or cross-cloud egress.
EgressPerGB float64
// MinCommitGBMonth is any minimum storage commit baked into the
// SKU (e.g. Glacier Deep Archive's 180-day minimum).
MinCommitGBMonth float64
// Notes captures gotchas (e.g. R2's $0 egress is the headline).
Notes string
}
// Catalog is the source-of-truth price list. Keep alphabetically-ish
// sorted within a provider.
var Catalog = map[Backend]Pricing{
BackendAWSS3Std: {
StoragePerGBMonth: 0.023, PutPer1k: 0.005, GetPer1k: 0.0004,
EgressPerGB: 0.09,
Notes: "AWS S3 Standard us-east-1; egress free intra-region, $0.09/GB to internet after 100 GB free tier.",
},
BackendAWSS3IA: {
StoragePerGBMonth: 0.0125, PutPer1k: 0.01, GetPer1k: 0.001,
EgressPerGB: 0.09,
Notes: "AWS S3 Infrequent Access; 30-day min storage duration; retrieval fee $0.01/GB.",
},
BackendAWSGlacierIR: {
StoragePerGBMonth: 0.004, PutPer1k: 0.02, GetPer1k: 0.01,
EgressPerGB: 0.09,
Notes: "AWS S3 Glacier Instant Retrieval; 90-day min storage duration; reads are millisecond.",
},
BackendCloudflareR2: {
StoragePerGBMonth: 0.015, PutPer1k: 0.0045, GetPer1k: 0.00036,
EgressPerGB: 0,
Notes: "Cloudflare R2; $0 egress is the headline — best for cross-cloud reads.",
},
BackendDOSpaces: {
StoragePerGBMonth: 0.02, PutPer1k: 0.005, GetPer1k: 0,
EgressPerGB: 0.01,
Notes: "DigitalOcean Spaces; $5/mo baseline includes 250 GB + 1 TB egress, then $0.02/GB + $0.01/GB.",
},
BackendGCSStandard: {
StoragePerGBMonth: 0.020, PutPer1k: 0.005, GetPer1k: 0.0004,
EgressPerGB: 0.12,
Notes: "GCS Standard us-central1; egress to internet $0.12/GB.",
},
BackendGCSColdline: {
StoragePerGBMonth: 0.004, PutPer1k: 0.01, GetPer1k: 0.05,
EgressPerGB: 0.12,
Notes: "GCS Coldline; 90-day min storage; expensive reads at $0.05/GB retrieval.",
},
BackendPVCBlock: {
StoragePerGBMonth: 0.10, PutPer1k: 0, GetPer1k: 0,
EgressPerGB: 0,
Notes: "Kubernetes block-storage PVC baseline (DOKS / EKS gp3 / GKE pd-balanced range); $0.10/GB-mo, no per-op costs; in-region only.",
},
}
// Workload describes a measured (or projected) usage pattern. Convert
// from a benchmark's reported counts via the helpers below.
//
// Workload is intentionally application-neutral: a validator-archive
// study, a sqlite query DB, or a snapshot pipeline all populate the
// same fields. Domain-specific knowledge (e.g. "chain X emits SST
// files of size Y") belongs in the caller, not in this package.
type Workload struct {
// StorageGB is the steady-state stored volume.
StorageGB float64
// PutsPerMonth + GetsPerMonth are the request counts at steady
// state.
PutsPerMonth float64
GetsPerMonth float64
// EgressGBPerMonth is what leaves the provider. Set to 0 if both
// vfsd and consumers run in the same region as the backend.
EgressGBPerMonth float64
// Description is purely cosmetic; surfaces in reports.
Description string
}
// Estimate is the per-month cost breakdown for a workload on a single
// backend.
type Estimate struct {
Backend Backend
Workload string
StorageUSD float64
RequestsUSD float64
EgressUSD float64
TotalUSD float64
Notes string
}
// EstimateFor returns the per-month cost breakdown for a workload on a
// given backend.
func EstimateFor(w Workload, be Backend) Estimate {
p, ok := Catalog[be]
if !ok {
return Estimate{Backend: be, Notes: "unknown backend"}
}
storage := w.StorageGB * p.StoragePerGBMonth
requests := (w.PutsPerMonth/1000.0)*p.PutPer1k + (w.GetsPerMonth/1000.0)*p.GetPer1k
egress := w.EgressGBPerMonth * p.EgressPerGB
return Estimate{
Backend: be,
Workload: w.Description,
StorageUSD: storage,
RequestsUSD: requests,
EgressUSD: egress,
TotalUSD: storage + requests + egress,
Notes: p.Notes,
}
}
// String formats the estimate as a single-line summary.
func (e Estimate) String() string {
return fmt.Sprintf(
"%-22s storage=$%7.2f requests=$%7.2f egress=$%7.2f total=$%7.2f/mo",
e.Backend, e.StorageUSD, e.RequestsUSD, e.EgressUSD, e.TotalUSD,
)
}
// CompareBackends runs the workload across every backend and returns
// the estimates sorted by total monthly cost (cheapest first).
func CompareBackends(w Workload) []Estimate {
out := make([]Estimate, 0, len(Catalog))
for be := range Catalog {
out = append(out, EstimateFor(w, be))
}
// Bubble sort is fine at this size + saves the import.
for i := 0; i < len(out)-1; i++ {
for j := i + 1; j < len(out); j++ {
if out[j].TotalUSD < out[i].TotalUSD {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
// FromBenchmark converts a sustained-write benchmark's measured per-op
// rate into a steady-state Workload projection.
//
// inputs:
//
// measuredOpsPerSec: throughput observed in a benchmark
// sustainedFraction: fraction of time the workload runs at that rate
// (e.g. 0.1 if the workload is mostly idle and
// only bursts during heavy activity windows)
// avgFileSizeBytes: the average size of files written
// monthsRetained: how long data is held before being deleted
//
// Returns a Workload describing the steady state — including the
// computed StorageGB growth based on write rate × retention.
//
// readAmplification = GetsPerMonth / PutsPerMonth at steady state.
// A value of 0 disables reads entirely (write-only archive); 2 is
// reasonable for an archive read by API consumers; 100+ is "actively
// queried" workloads.
func FromBenchmark(measuredOpsPerSec, sustainedFraction float64, avgFileSizeBytes int64, monthsRetained, readAmplification float64) Workload {
secondsPerMonth := float64(time.Hour*24*30) / float64(time.Second)
effectiveOpsPerSec := measuredOpsPerSec * sustainedFraction
opsPerMonth := effectiveOpsPerSec * secondsPerMonth
bytesPerMonth := opsPerMonth * float64(avgFileSizeBytes)
storageBytes := bytesPerMonth * monthsRetained
return Workload{
StorageGB: storageBytes / (1 << 30),
PutsPerMonth: opsPerMonth,
GetsPerMonth: opsPerMonth * readAmplification,
Description: fmt.Sprintf("%.1f ops/s × %.0f%% util × %d B avg × %.1f mo retention × %.1f read amp",
measuredOpsPerSec, sustainedFraction*100, avgFileSizeBytes, monthsRetained, readAmplification),
}
}
// Report renders the comparison as a readable multi-line table.
// Pass the workload's name to disambiguate when reporting multiple.
func Report(name string, w Workload) string {
var b strings.Builder
fmt.Fprintf(&b, "Workload: %s\n", name)
fmt.Fprintf(&b, " %s\n", w.Description)
fmt.Fprintf(&b, " storage_gb=%.1f puts/mo=%.2e gets/mo=%.2e egress_gb/mo=%.1f\n\n",
w.StorageGB, w.PutsPerMonth, w.GetsPerMonth, w.EgressGBPerMonth)
for _, e := range CompareBackends(w) {
fmt.Fprintf(&b, " %s\n", e.String())
}
return b.String()
}
// ---------------------------------------------------------------------
// Tiered storage: hot SSD + cold object-store archive
// ---------------------------------------------------------------------
// Tiered describes a workload split between a hot tier (local block
// storage / SSD, sized for "recent N" data) and a cold tier (VFS into
// an object-store backend, holding everything older than the hot
// horizon).
//
// The model is brand-neutral: blocks/SSTs/snapshots/files all reduce
// to a steady-state byte rate (RatePerSecond), a hot retention window
// (HotWindow), and a cold retention horizon (ColdRetention).
type Tiered struct {
// RatePerSecond is the steady-state byte production rate (e.g. a
// validator emitting finalized state at X MB/s).
RatePerSecond float64
// HotWindow is how long the most-recent data stays on local SSD.
// Typical values: 24h, 7 days, 30 days.
HotWindow time.Duration
// ColdRetention is how long data is held in the cold archive
// before tier-down or deletion. Use a large value (years) to
// model "keep forever".
ColdRetention time.Duration
// HotBackend selects the local-disk pricing entry (typically
// BackendPVCBlock).
HotBackend Backend
// ColdBackend selects the object-store entry.
ColdBackend Backend
// PutsPerByte / GetsPerByte map raw bytes to backend request
// counts. A pure-stream workload writing 64 KiB objects gives
// PutsPerByte = 1/65536. A 4 KiB-block content-addressed VFS
// gives PutsPerByte = 1/4096.
PutsPerByte float64
GetsPerByte float64
// EgressGBPerMonth is what leaves the cold backend's edge per
// month (intra-region reads are free for most providers).
EgressGBPerMonth float64
}
// TieredEstimate is the combined cost of a Tiered workload.
type TieredEstimate struct {
Hot Estimate
Cold Estimate
TotalUSD float64
HotStorageGB float64
ColdStorageGB float64
}
// String prints both tiers + the combined total on one short summary.
func (e TieredEstimate) String() string {
return fmt.Sprintf(
"hot[%s %.1f GB]=$%.2f cold[%s %.1f GB]=$%.2f total=$%.2f/mo",
e.Hot.Backend, e.HotStorageGB, e.Hot.TotalUSD,
e.Cold.Backend, e.ColdStorageGB, e.Cold.TotalUSD,
e.TotalUSD,
)
}
// Estimate computes the per-month cost for a Tiered workload.
//
// Hot tier holds RatePerSecond * HotWindow bytes; no per-op cost (PVC
// has no PUT/GET fees), no egress (in-region only).
//
// Cold tier holds RatePerSecond * ColdRetention bytes, with PUTs/GETs
// derived from PutsPerByte * (RatePerSecond * seconds/month).
func (t Tiered) Estimate() TieredEstimate {
const secondsPerMonth = float64(time.Hour*24*30) / float64(time.Second)
hotBytes := t.RatePerSecond * t.HotWindow.Seconds()
coldBytes := t.RatePerSecond * t.ColdRetention.Seconds()
bytesPerMonth := t.RatePerSecond * secondsPerMonth
hot := Workload{
StorageGB: hotBytes / (1 << 30),
PutsPerMonth: 0, // local SSD has no per-op cost
GetsPerMonth: 0,
EgressGBPerMonth: 0,
Description: "hot tier (local SSD)",
}
cold := Workload{
StorageGB: coldBytes / (1 << 30),
PutsPerMonth: bytesPerMonth * t.PutsPerByte,
GetsPerMonth: bytesPerMonth * t.GetsPerByte,
EgressGBPerMonth: t.EgressGBPerMonth,
Description: fmt.Sprintf("cold archive (%s retention)", t.ColdRetention),
}
he := EstimateFor(hot, t.HotBackend)
ce := EstimateFor(cold, t.ColdBackend)
return TieredEstimate{
Hot: he,
Cold: ce,
TotalUSD: he.TotalUSD + ce.TotalUSD,
HotStorageGB: hot.StorageGB,
ColdStorageGB: cold.StorageGB,
}
}
// ---------------------------------------------------------------------
// Cluster archive sharing: 1 writer + N readers, one shared bucket
// ---------------------------------------------------------------------
// Cluster models a fleet of nodes that share a single content-addressed
// cold archive. This is the architectural win that makes object-store
// archival cost-effective at scale:
//
// - Writers (typically validators) PUT new data into the archive.
// Because content addressing dedupes identical bytes across all
// writers, the effective storage + PUT cost stays linear in
// CHAIN GROWTH, not in writer count.
// - Readers (read-replicas, explorers, indexers, archive nodes,
// follow nodes) GET data from the same archive bucket — they
// never PUT, and they don't need their own copy of historical
// state on disk.
//
// The cluster pays:
//
// storage ← single bucket, sized for cumulative archive
// PUTs ← deduped to ONE writer's share regardless of fleet size
// GETs ← N readers each pulling their share of archived data
// egress ← only if readers live outside the backend's region
type Cluster struct {
// Writers is the number of nodes producing new state. Used only
// for noting in the report; the PUT cost itself is independent
// of this once dedup is in place.
Writers int
// Readers is the number of read-only nodes pulling from the
// shared archive. Each contributes its own GET volume.
Readers int
// Underlying single-tier workload (per writer × dedup factor,
// or equivalently the chain-growth rate times the writer-share).
// In a deduped cluster this is just the chain's intrinsic growth.
Base Workload
// ReadsPerReaderRatio is each reader's fraction of the total GET
// volume relative to one full archive scan per month. 0 = no
// reads; 1 = one full re-read of the archive per reader per
// month; 12 = monthly re-reads per reader (very heavy).
ReadsPerReaderRatio float64
// EgressPerReaderGBPerMonth models the cross-region egress per
// reader. Set to 0 if all readers are in the backend's region.
EgressPerReaderGBPerMonth float64
}
// Workload returns the cluster's effective archive workload (single
// backend bucket, all writers deduped, summed reader GETs + egress).
func (c Cluster) Workload() Workload {
w := c.Base
w.GetsPerMonth = c.Base.PutsPerMonth * c.ReadsPerReaderRatio * float64(c.Readers)
w.EgressGBPerMonth = c.EgressPerReaderGBPerMonth * float64(c.Readers)
w.Description = fmt.Sprintf(
"cluster: %d writers (deduped to 1) + %d readers × %.2f read amp; %s",
c.Writers, c.Readers, c.ReadsPerReaderRatio, c.Base.Description,
)
return w
}
// BlockTimeWorkload constructs a Workload from a chain's block-rate
// parameters. This is the generic primitive a chain-specific paper or
// CLI composes; the package itself stays brand-neutral.
//
// blockTime: target time between blocks (e.g. 1s, 100ms, 1ms)
// avgBlockBytes: average serialized block size on disk
// monthsRetained: how long blocks are held before deletion
// readAmplification: GET/PUT ratio at steady state
// objectBytes: the granularity each block is split into when
// written to the archive (e.g. 4096 for VFS 4 KiB
// blocks; equal to avgBlockBytes for one-PUT-per-
// block writers)
//
// Returns a workload that callers can feed to EstimateFor /
// CompareBackends / Report, or wrap with Tiered / Cluster.
func BlockTimeWorkload(blockTime time.Duration, avgBlockBytes int64, monthsRetained, readAmplification float64, objectBytes int64) Workload {
const secondsPerMonth = float64(time.Hour*24*30) / float64(time.Second)
blocksPerSec := 1.0 / blockTime.Seconds()
bytesPerSec := blocksPerSec * float64(avgBlockBytes)
bytesPerMonth := bytesPerSec * secondsPerMonth
storageBytes := bytesPerMonth * monthsRetained
objectsPerByte := 1.0 / float64(objectBytes)
return Workload{
StorageGB: storageBytes / (1 << 30),
PutsPerMonth: bytesPerMonth * objectsPerByte,
GetsPerMonth: bytesPerMonth * objectsPerByte * readAmplification,
Description: fmt.Sprintf(
"block-time=%s, %d B/block, %d B/object, %.1f mo retention, %.1f read amp",
blockTime, avgBlockBytes, objectBytes, monthsRetained, readAmplification,
),
}
}