mirror of
https://github.com/hanzoai/compute.git
synced 2026-10-30 14:50:41 +00:00
201 lines
8.4 KiB
Go
201 lines
8.4 KiB
Go
// Copyright 2024 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.
|
|
|
|
package task
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/hanzoai/compute/logs"
|
|
"github.com/hanzoai/compute/object"
|
|
"github.com/hanzoai/compute/service"
|
|
)
|
|
|
|
type Ticker struct{}
|
|
|
|
// sweepEvery is how often the metering tick asks whether the hour's sweep is
|
|
// due. The hour claim makes it once per hour whatever this is.
|
|
const sweepEvery = 5 * time.Minute
|
|
|
|
func NewTicker() *Ticker {
|
|
return &Ticker{}
|
|
}
|
|
|
|
func (t *Ticker) SetupTicker() {
|
|
// delete unused session every hour
|
|
unUsedSessionTicker := time.NewTicker(time.Hour)
|
|
go func() {
|
|
for range unUsedSessionTicker.C {
|
|
t.deleteUnUsedSession()
|
|
}
|
|
}()
|
|
|
|
// compute metering: debit every hosted machine each hour it owes, through the
|
|
// one commerce client (the same one the launch debit uses), under ONE
|
|
// per-hour lease.
|
|
//
|
|
// Enablement is decided inside each sweep (no-op when commerce or compute is
|
|
// unconfigured), so this is wired unconditionally — no second config gate to
|
|
// drift.
|
|
//
|
|
// SINGLE-FLIGHT ACROSS REPLICAS (money safety): visor runs replicas: 2+ with no
|
|
// external coordinator; the ledger debits each hour-bucketed usage id once, and an
|
|
// unguarded sweep would still enumerate and post every machine from every replica.
|
|
// Exactly-once sweeping is TWO composed guarantees, both inside object:
|
|
// (1) IsBillingOwner() — only the HRW-elected owner of the `_global` coord DB runs
|
|
// the sweep, so non-owner replicas never enumerate machines or call commerce
|
|
// (they still serve all read/stateless traffic — only the money-WRITER is gated).
|
|
// (2) ClaimMeterHour — the per-hour lease (hydrate prior claims, insert-once, ship
|
|
// synchronously) makes exactly ONE claim win per wall-clock hour cluster-wide,
|
|
// and blocks a mid-hour restart or a post-flip new owner from re-sweeping.
|
|
// At replicas: 1 the sole pod always elects itself owner (no external coordinator
|
|
// needed), so it bills normally; at replicas: N only the elected owner bills.
|
|
// ClaimMeterHour re-checks ownership, so the gate here is a clean-skip optimization,
|
|
// never the sole guarantee.
|
|
//
|
|
// Wired UNCONDITIONALLY — enablement is decided INSIDE MeterRunningMachines
|
|
// every hour, which is what the paragraph above already promised. The
|
|
// startup-time MeteringConfigured() check that used to stand here broke that
|
|
// promise in the worst direction: a pod that booted before its credential
|
|
// landed never created the ticker at all, so it never billed a single
|
|
// machine-hour for the life of the pod, silently.
|
|
//
|
|
// The tick body is hour.run, which adds the PRECONDITION on claiming — the
|
|
// provider must answer before the hour is spent. Those two exactly-once
|
|
// guarantees are untouched by it; see run for why a read-only probe cannot
|
|
// weaken them.
|
|
//
|
|
// The tick is every few minutes and once at boot, not hourly, and the hour
|
|
// claim is what makes the work hourly. An hourly ticker's phase is the pod's
|
|
// start time, so a pod that restarts inside the hour never reaches a tick; a
|
|
// tick whose hour it could not claim (the provider unreachable) is retried by
|
|
// the next one; and an hour no tick swept at all is billed by the next sweep,
|
|
// which bills every hour a machine owes since the last one billed.
|
|
computeTicker := time.NewTicker(sweepEvery)
|
|
go func() {
|
|
liveHour().run(context.Background(), time.Now())
|
|
for now := range computeTicker.C {
|
|
liveHour().run(context.Background(), now)
|
|
}
|
|
}()
|
|
logs.Info("compute metering: hourly drawdown enabled (hosted machines, single-flight per hour, elected owner)")
|
|
}
|
|
|
|
// hour is one hourly tick's four collaborators, named so the ORDER they compose
|
|
// in is a thing a test can hold still. The order is the whole correctness
|
|
// argument — see run — and an order is not testable while it is spelled inline in
|
|
// a goroutine that only fires on the top of the hour.
|
|
type hour struct {
|
|
// owner reports whether this replica is the elected single writer.
|
|
owner func() bool
|
|
// reachable proves the provider actually answers, or says why not.
|
|
reachable func(context.Context) error
|
|
// claim consumes the hour, cluster-wide, exactly once.
|
|
claim func(time.Time) bool
|
|
// meter does the billing work the claim paid for.
|
|
meter func(context.Context, time.Time)
|
|
}
|
|
|
|
// liveHour binds the production collaborators. It is the only place the real
|
|
// ones are named, so the test wires fakes into the same shape rather than a
|
|
// parallel copy of the logic.
|
|
func liveHour() hour {
|
|
return hour{
|
|
owner: object.IsBillingOwner,
|
|
reachable: service.ComputeReachable,
|
|
claim: object.ClaimMeterHour,
|
|
meter: service.MeterRunningMachines,
|
|
}
|
|
}
|
|
|
|
// run performs ONE hourly tick: prove, then claim, then bill.
|
|
//
|
|
// PROVE BEFORE CLAIM is the ordering that matters, and it used to be the other
|
|
// way round. The claim is insert-once on the wall-clock hour with no retry and
|
|
// no reconciliation, so it does not record "this hour was billed" — it records
|
|
// "this hour is spent, and no one may ever bill it again". Spending it before
|
|
// knowing whether the work is possible means an unreachable provider burns the
|
|
// hour: both sweeps bill nothing, log a warning, report a healthy `sweep done
|
|
// (metered=0)`, and the revenue for that hour is not delayed but destroyed. A
|
|
// credential that comes back cannot recover it, because the hour is claimed.
|
|
//
|
|
// So the hour is now claimed only once the provider has answered. An hour we
|
|
// cannot bill is left UNCLAIMED, which is the difference between a missed hour
|
|
// and a lost one: the lease stays free, so the hour remains billable the moment a
|
|
// credential returns. Every fail-closed comment in the billing path already
|
|
// promised "a missed hour is reconciled" — leaving the lease unclaimed is what
|
|
// makes that promise reachable instead of merely stated.
|
|
//
|
|
// EXACTLY-ONCE IS UNCHANGED, and moving the probe cannot weaken it. The probe is
|
|
// a read: it moves no money, so running it on every replica costs nothing and
|
|
// proves nothing about who bills. The claim is still the sole authority on that,
|
|
// still insert-once on the same hour PK over the same HRW-elected single writer
|
|
// (object.ClaimMeterHour), and still the only thing standing between meter and a
|
|
// duplicate debit. Two replicas that both prove the provider reachable still race
|
|
// exactly one claim, and exactly one of them wins it. Nothing here is a second
|
|
// mechanism; the probe only decides whether the existing one is asked.
|
|
//
|
|
// An UNCONFIGURED configured cloud account is not unreachable — ComputeReachable returns nil
|
|
// with nothing to ask — so a deployment with no provider token bills its tenants'
|
|
// own resources exactly as before.
|
|
func (h hour) run(ctx context.Context, now time.Time) {
|
|
if !h.owner() {
|
|
return
|
|
}
|
|
if err := h.reachable(ctx); err != nil {
|
|
// Loud: this is a revenue outage, and the sweep it replaces reported
|
|
// success. The hour is deliberately left unclaimed — see above.
|
|
logs.Warning("compute metering: hour %s NOT claimed — the provider is unreachable (%v); "+
|
|
"nothing is billed and nothing is spent, so this hour stays billable once a credential returns",
|
|
now.UTC().Format("2006010215"), err)
|
|
return
|
|
}
|
|
if !h.claim(now) {
|
|
return
|
|
}
|
|
h.meter(ctx, now)
|
|
}
|
|
|
|
func (t *Ticker) deleteUnUsedSession() {
|
|
sessions, err := object.GetSessionsByStatus([]string{object.NoConnect, object.Connecting})
|
|
if err != nil {
|
|
logs.Info("failed to get unused sessions: ", err)
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, session := range sessions {
|
|
if session.StartTime != "" {
|
|
startTime, err := time.ParseInLocation(time.RFC3339, session.StartTime, time.Local)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if now.Sub(startTime).Hours() > 1 {
|
|
_, err := object.DeleteSessionById(session.GetId())
|
|
if err != nil {
|
|
logs.Info("delete session failed: %v", err)
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
_, err := object.DeleteSessionById(session.GetId())
|
|
if err != nil {
|
|
logs.Info("delete session failed: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|