mirror of
https://github.com/hanzoai/compute.git
synced 2026-10-30 14:50:41 +00:00
453 lines
18 KiB
Go
453 lines
18 KiB
Go
// Copyright 2025 Hanzo Industries Inc. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// metering.go is the ONE commerce metering path for hosted compute. The launch
|
|
// debit (controllers/compute.go), the start debit (SetOrgMachineState) and the
|
|
// recurring hourly debit (MeterRunningMachines, driven by task/ticker) all price
|
|
// from the catalog (HourlyCents) and debit through RecordCompute on cloud's
|
|
// commerce API (commerce.go) — there is no second metering path for /v1
|
|
// machines. Fleet billing (billing/) debits through the same client.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzoai/compute/logs"
|
|
"github.com/hanzoai/compute/telemetry"
|
|
)
|
|
|
|
// meteringProvider labels hosted-compute usage in the commerce ledger so spend
|
|
// is attributable to this surface (product), independent of the size recorded as
|
|
// the Model.
|
|
const meteringProvider = "compute"
|
|
|
|
// HourStamp is the per-hour bucket in the usage id: the same running resource
|
|
// metered twice within one wall-clock hour carries the SAME id, and cloud debits
|
|
// one id once. The single-flight lease in the ticker (object.ClaimMeterHour) keeps
|
|
// a second replica from sweeping at all; the id is what makes a retried debit
|
|
// harmless.
|
|
func HourStamp(now time.Time) string { return now.UTC().Format("2006010215") }
|
|
|
|
// MeterID names one running hour of a machine: the usage id the hourly sweep
|
|
// debits, and the id a start debits its first hour under, so the two can never
|
|
// charge the same hour twice.
|
|
func MeterID(machineID string, now time.Time) string {
|
|
return "compute-" + machineID + "-" + HourStamp(now)
|
|
}
|
|
|
|
// CreatedInHour reports whether an RFC3339 create time falls in the same UTC
|
|
// hour bucket as stamp ("YYYYMMDDHH"). An empty or unparseable time is NOT in the
|
|
// hour (metered normally) — we only ever SKIP a resource we can prove the
|
|
// provision path already billed this hour.
|
|
//
|
|
// Exported because it is the ONE launch-hour rule, and every provision path that
|
|
// debits its first hour up front (machines, node pools) needs its recurring sweep
|
|
// to honour the same rule or that hour is billed twice.
|
|
func CreatedInHour(createdTime, stamp string) bool {
|
|
if createdTime == "" {
|
|
return false
|
|
}
|
|
t, err := time.Parse(time.RFC3339, createdTime)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return t.UTC().Format("2006010215") == stamp
|
|
}
|
|
|
|
// mostCatchUp bounds how many hours one sweep bills a machine. Hours are owed
|
|
// from the last one billed, so this is reached only by an outage longer than a
|
|
// week or a ledger that lost its rows, and in both cases billing a week at a
|
|
// time is safer than billing a machine's whole life in one sweep.
|
|
const mostCatchUp = 7 * 24
|
|
|
|
// MeterRunningMachines debits every RUNNING metered machine every hour of its
|
|
// price it has run and not yet been billed for, through the hour now falls in,
|
|
// to its OWNING org — the recurring counterpart to the launch debit. A machine
|
|
// that stays up keeps drawing down the org's credit balance, hour by hour, and
|
|
// an hour a sweep missed is billed by the next one.
|
|
//
|
|
// Per machine: org is recovered from the machine's own hanzo-org tag (never
|
|
// trusted from a client — it is the tag LaunchOrgMachine set), the hourly price
|
|
// comes from the catalog (HourlyCents), and each hour's debit carries the usage
|
|
// id MeterID: "compute-<machineID>-<YYYYMMDDHH>". Recording is decoupled from
|
|
// gating for an hour that already ran; the hour now starting is paid for as it
|
|
// starts, and a machine whose org cannot pay for it is stopped (meterMachines).
|
|
// A per-machine error is logged and does not abort the sweep.
|
|
//
|
|
// EXACTLY-ONCE PER HOUR is the Ledger and the meter id together. The sweep
|
|
// debits each owed hour under its own MeterID and moves the machine's mark only
|
|
// over the hours whose debit landed, the launch and a start record their hour
|
|
// the same way, and the ticker's per-hour single-flight lease
|
|
// (object.ClaimMeterHour) keeps other replicas from sweeping at all. A debit
|
|
// that fails leaves its hour owed for the next sweep; a mark that fails to write
|
|
// after its debit landed re-sends that hour's id, which commerce debits once.
|
|
//
|
|
// No-op when metering is unconfigured or when the hosted account is unconfigured
|
|
// — nothing to enumerate, nothing to debit.
|
|
func MeterRunningMachines(ctx context.Context, now time.Time) {
|
|
if !ComputeConfigured() || !Billable(ctx, "compute.hourly") {
|
|
return
|
|
}
|
|
ctx, span := telemetry.Span(ctx, "billing.meter.hourly", "", "")
|
|
defer span.End()
|
|
machines, err := ListMeteredMachines()
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
logs.Warning("compute metering: list running machines: %v", err)
|
|
return
|
|
}
|
|
metered, skipped := meterMachines(ctx, machines, now)
|
|
logs.Info("compute metering: hourly sweep done (metered=%d skipped=%d total=%d)", metered, skipped, len(machines))
|
|
}
|
|
|
|
// owed is one machine's unbilled hours and what each costs, under one ledger
|
|
// key: its running hours under its id, its stopped hours under its disk key.
|
|
type owed struct {
|
|
m *Machine
|
|
org string
|
|
project string
|
|
key string
|
|
cents int64
|
|
model string
|
|
id func(time.Time) string
|
|
hours []time.Time
|
|
}
|
|
|
|
// running reports whether these are the machine's running hours.
|
|
func (d owed) running() bool { return d.key == d.m.Id }
|
|
|
|
// diskKey is the ledger key a machine's stopped hours are marked under, apart
|
|
// from its running hours, so a stopped hour and a running hour are each billed
|
|
// at their own price and neither mark hides the other.
|
|
func diskKey(machine string) string { return machine + "/disk" }
|
|
|
|
// DiskMeterID names one stopped hour of a machine: its disk.
|
|
func DiskMeterID(machineID string, now time.Time) string {
|
|
return "compute-" + machineID + "-disk-" + HourStamp(now)
|
|
}
|
|
|
|
// meterMachines is the pure sweep over a machine set at a fixed wall-clock `now`
|
|
// (injected so the idempotency bucket is deterministic under test). It resolves
|
|
// each machine's org from its tag, prices it from the catalog, finds the hours it
|
|
// owes, and debits them in order under their own meter ids; each ledger key's
|
|
// mark moves over the hours whose debit landed, and the marks are written once,
|
|
// after every debit. Returns (metered hours, skipped). A per-machine failure is
|
|
// logged and skipped — one bad machine never aborts the sweep.
|
|
//
|
|
// Past hours and a stopped machine's disk are debited whatever the balance. The
|
|
// current running hour is debited only if the org's balance covers it plus the
|
|
// transfer the machine could run up in it (transferRates); otherwise the machine
|
|
// is stopped. Each org's current hours are decided under holdOrg.
|
|
func meterMachines(ctx context.Context, machines []*Machine, now time.Time) (metered, skipped int) {
|
|
current, _ := parseHour(hourOf(now))
|
|
var dues []owed
|
|
for _, m := range machines {
|
|
d, err := owe(m, current)
|
|
if err != nil {
|
|
skipped++
|
|
logs.Warning("compute metering: machine %s not billed: %v", m.Id, err)
|
|
continue
|
|
}
|
|
if len(d.hours) > 0 {
|
|
dues = append(dues, d)
|
|
}
|
|
}
|
|
|
|
marks := map[string]Mark{}
|
|
bill := func(d owed, h time.Time) bool {
|
|
if err := RecordCompute(ctx, d.org, d.project, d.cents, d.model, d.id(h)); err != nil {
|
|
skipped++
|
|
logs.Warning("compute metering: debit %s hour %s (org %s): %v", d.key, hourOf(h), d.org, err)
|
|
return false
|
|
}
|
|
metered++
|
|
marks[d.key] = Mark{Hour: hourOf(h)}
|
|
// Roll a running event into the analytics datastore alongside a running
|
|
// hour's debit (best-effort; never blocks or affects the sweep).
|
|
if d.running() {
|
|
EmitCompute(d.org, ComputeRunning, d.m, d.cents)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Hours that already happened, in order; a failed debit leaves it and every
|
|
// hour after it owed.
|
|
halted := map[string]bool{}
|
|
for _, d := range dues {
|
|
for _, h := range d.hours {
|
|
if d.running() && h.Equal(current) {
|
|
break
|
|
}
|
|
if !bill(d, h) {
|
|
halted[d.key] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// The current running hour, org by org, against what each org has left.
|
|
var orgs []string
|
|
starting := map[string][]owed{}
|
|
for _, d := range dues {
|
|
if !d.running() || halted[d.key] || !d.hours[len(d.hours)-1].Equal(current) {
|
|
continue
|
|
}
|
|
if starting[d.org] == nil {
|
|
orgs = append(orgs, d.org)
|
|
}
|
|
starting[d.org] = append(starting[d.org], d)
|
|
}
|
|
rate := transferRates(ctx, orgs, starting, now)
|
|
for _, org := range orgs {
|
|
func() {
|
|
defer holdOrg(org)()
|
|
have, known := balanceOf(ctx, org, current, marks)
|
|
for _, d := range starting[org] {
|
|
need := d.cents + rate[d.m.Id]
|
|
if known && have < need {
|
|
if err := suspend(ctx, org, d.m.Id); err != nil {
|
|
logs.Warning("compute metering: machine %s (org %s) cannot be paid for and could not be stopped: %v", d.m.Id, org, err)
|
|
continue
|
|
}
|
|
logs.Warning("compute metering: machine %s (org %s) stopped: %d cents left, its hour costs %d and its transfer could cost %d", d.m.Id, org, have, d.cents, rate[d.m.Id])
|
|
continue
|
|
}
|
|
if bill(d, current) {
|
|
have -= need
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if len(marks) > 0 {
|
|
if _, err := billed().Advance(marks); err != nil {
|
|
logs.Warning("compute metering: record billed hours: %v — the next sweep re-sends them under the same ids", err)
|
|
}
|
|
}
|
|
return metered, skipped
|
|
}
|
|
|
|
// mostUnread is how many consecutive hours an org's machines run while its
|
|
// balance cannot be read.
|
|
const mostUnread = 6
|
|
|
|
// readKey is the ledger key of org's run of unreadable balances: Hour is the
|
|
// last such hour, Streak the run's length through it.
|
|
func readKey(org string) string { return "balance/" + org }
|
|
|
|
// balanceOf is what org can spend on the current hour, and whether that is known.
|
|
// A 402 is known to be nothing. Any other failure is unknown, and the machines
|
|
// run, until the run passes mostUnread consecutive hours; then it is nothing.
|
|
func balanceOf(ctx context.Context, org string, current time.Time, marks map[string]Mark) (int64, bool) {
|
|
have, err := available(ctx, org)
|
|
if err == nil {
|
|
return have, true
|
|
}
|
|
if refusesOrg(err) {
|
|
logs.Warning("compute metering: commerce refused the balance of %s; its machines stop: %v", org, err)
|
|
return 0, true
|
|
}
|
|
unread := int64(1)
|
|
if last, lerr := billed().Through(readKey(org)); lerr != nil {
|
|
logs.Warning("compute metering: read the balance history of %s: %v", org, lerr)
|
|
} else if prev, ok := parseHour(last.Hour); ok && last.Streak > 0 && prev.Add(time.Hour).Equal(current) {
|
|
unread = last.Streak + 1
|
|
}
|
|
marks[readKey(org)] = Mark{Hour: hourOf(current), Streak: unread}
|
|
if unread > mostUnread {
|
|
logs.Warning("compute metering: balance of %s unreadable for %d hours; its machines stop: %v", org, unread, err)
|
|
return 0, true
|
|
}
|
|
logs.Warning("compute metering: balance of %s unreadable (%d of %d hours); its machines keep running this hour: %v", org, unread, mostUnread, err)
|
|
return 0, false
|
|
}
|
|
|
|
// transferRates is, by machine, transferCents of the NetworkOut it sent in the
|
|
// last settled hour. NetworkOut includes in-region traffic, so this is an upper
|
|
// bound used to stop machines, never to charge. An unreadable NetworkOut is no rate.
|
|
func transferRates(ctx context.Context, orgs []string, starting map[string][]owed, now time.Time) map[string]int64 {
|
|
last := now.UTC().Add(-time.Hour - settle).Truncate(time.Hour)
|
|
var instances []string
|
|
for _, org := range orgs {
|
|
for _, d := range starting[org] {
|
|
if d.m.instance != "" {
|
|
instances = append(instances, d.m.instance)
|
|
}
|
|
}
|
|
}
|
|
if len(instances) == 0 {
|
|
return nil
|
|
}
|
|
sent, err := outbound(ctx, instances, last, last.Add(time.Hour))
|
|
if err != nil {
|
|
logs.Warning("compute metering: read outbound transfer: %v — no machine is stopped for its transfer this hour", err)
|
|
return nil
|
|
}
|
|
rate := map[string]int64{}
|
|
for _, org := range orgs {
|
|
for _, d := range starting[org] {
|
|
if cents := transferCents(sent[d.m.instance][hourOf(last)]); cents > 0 {
|
|
rate[d.m.Id] = cents
|
|
}
|
|
}
|
|
}
|
|
return rate
|
|
}
|
|
|
|
// suspend stops org's hosted machine. It is a variable so the sweep can be run
|
|
// against a machine set with no EC2 behind it.
|
|
var suspend = stopHosted
|
|
|
|
// stopHosted stops org's hosted machine id: the machine and its disk stay, the
|
|
// running price stops, and the org starts it again — a start is a provision,
|
|
// gated on the balance — once it can pay.
|
|
func stopHosted(ctx context.Context, org, id string) error {
|
|
api, _, err := hostedEC2(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inst, err := findInstance(ctx, api, org, id)
|
|
if err != nil || inst == nil {
|
|
return err
|
|
}
|
|
return setState(ctx, api, inst, false)
|
|
}
|
|
|
|
// owe is what machine m owes through current: its unbilled hours, what each
|
|
// costs, and the ledger key and meter id they are billed under. An error is a
|
|
// machine that cannot be billed at all — no org, no price, no ledger.
|
|
func owe(m *Machine, current time.Time) (owed, error) {
|
|
org := orgFromTag(m.Tag)
|
|
if org == "" {
|
|
return owed{}, fmt.Errorf("no %s tag (unattributable)", orgTagKey)
|
|
}
|
|
// ONE price resolver, shared with the provision gate. An unresolvable price
|
|
// is a LOUD skip, never a silent $0 debit — a machine we cannot price is
|
|
// lost revenue, and the log line is how anyone learns.
|
|
d := owed{m: m, org: org, project: projectFromTag(m.Tag), key: m.Id, model: m.Size,
|
|
id: func(h time.Time) string { return MeterID(m.Id, h) }}
|
|
var err error
|
|
if m.State == "Stopped" {
|
|
d.key, d.model = diskKey(m.Id), m.Size+"/stopped"
|
|
d.id = func(h time.Time) string { return DiskMeterID(m.Id, h) }
|
|
if d.cents, err = StoppedCents(m.Size); err == nil {
|
|
d.hours, err = stoppedHours(m, current)
|
|
}
|
|
} else if d.cents, err = HourlyCents(m.Size); err == nil {
|
|
d.hours, err = unbilled(m, current)
|
|
}
|
|
return d, err
|
|
}
|
|
|
|
// stoppedHours is every whole clock hour a stopped machine owes for its disk,
|
|
// through current: the hours after the last one billed either way, running or
|
|
// stopped — a machine's running price already carries its disk. A machine with
|
|
// neither mark owes the current hour only.
|
|
func stoppedHours(m *Machine, current time.Time) ([]time.Time, error) {
|
|
run, err := billed().Through(m.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
disk, err := billed().Through(diskKey(m.Id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
from := current
|
|
if last, ok := parseHour(max(run.Hour, disk.Hour)); ok {
|
|
from = last.Add(time.Hour)
|
|
}
|
|
if earliest := current.Add(-mostCatchUp * time.Hour); from.Before(earliest) {
|
|
logs.Warning("compute metering: stopped machine %s owes hours from %s; billing the last %d", m.Id, hourOf(from), mostCatchUp)
|
|
from = earliest
|
|
}
|
|
var hours []time.Time
|
|
for h := from; !h.After(current); h = h.Add(time.Hour) {
|
|
hours = append(hours, h)
|
|
}
|
|
return hours, nil
|
|
}
|
|
|
|
// unbilled is every whole clock hour machine m has run and not been billed for,
|
|
// through current.
|
|
//
|
|
// m.CreatedTime is EC2's LaunchTime, the machine's LAST start, and the machine
|
|
// has run without a stop since, so no hour before it is owed. A machine with a
|
|
// mark owes every hour after the mark from its last start on. A machine with no
|
|
// mark owes every hour from its last start: a launch or start whose debit failed
|
|
// recorded nothing, so the sweep is what bills that hour. One whose start is
|
|
// unknown owes the current hour.
|
|
func unbilled(m *Machine, current time.Time) ([]time.Time, error) {
|
|
mark, err := billed().Through(m.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
started, known := time.Time{}, false
|
|
if t, err := time.Parse(time.RFC3339, m.CreatedTime); err == nil {
|
|
started, known = t.UTC().Truncate(time.Hour), true
|
|
}
|
|
from := current
|
|
if last, ok := parseHour(mark.Hour); ok {
|
|
from = last.Add(time.Hour)
|
|
if known && started.After(from) {
|
|
from = started
|
|
}
|
|
} else if known {
|
|
from = started
|
|
}
|
|
if earliest := current.Add(-mostCatchUp * time.Hour); from.Before(earliest) {
|
|
logs.Warning("compute metering: machine %s owes hours from %s; billing the last %d", m.Id, hourOf(from), mostCatchUp)
|
|
from = earliest
|
|
}
|
|
var hours []time.Time
|
|
for h := from; !h.After(current); h = h.Add(time.Hour) {
|
|
hours = append(hours, h)
|
|
}
|
|
return hours, nil
|
|
}
|
|
|
|
// LaunchCharge names a launch's debit: its first hour, under the id the sweep
|
|
// and a start use for that hour. It records nothing; MarkBilled does, once the
|
|
// debit has landed.
|
|
func LaunchCharge(machine string, now time.Time) string {
|
|
return MeterID(machine, now)
|
|
}
|
|
|
|
// startCharge names a start's debit, or "" when its hour is already billed: a
|
|
// machine launched, stopped and started in one clock hour pays for that hour
|
|
// once. A ledger that cannot be read charges nothing here, and the sweep bills
|
|
// the hour from the machine's start.
|
|
func startCharge(machine string, now time.Time) string {
|
|
mark, err := billed().Through(machine)
|
|
if err != nil {
|
|
logs.Warning("compute metering: read billed hours of %s: %v", machine, err)
|
|
return ""
|
|
}
|
|
if mark.Hour >= hourOf(now) {
|
|
return ""
|
|
}
|
|
return MeterID(machine, now)
|
|
}
|
|
|
|
// MarkBilled records machine billed through the hour at falls in. It is called
|
|
// only once that hour's debit has landed, so an hour the ledger holds is an hour
|
|
// commerce charged. A mark that fails to write leaves the hour looking owed, and
|
|
// the next sweep re-sends its id, which commerce debits once.
|
|
func MarkBilled(machine string, at time.Time) {
|
|
if _, err := billed().Advance(map[string]Mark{machine: {Hour: hourOf(at)}}); err != nil {
|
|
logs.Warning("compute metering: record billed hour %s of %s: %v", hourOf(at), machine, err)
|
|
}
|
|
}
|