Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,10 @@ worker size: `cpu_seconds = vCPU × ceil(conn_secs)`, `memory_seconds = GiB ×
ceil(conn_secs)`. Counted internally in integer **millicore-seconds** /
**MiB-seconds** (`compute_meter.go`) to avoid truncating a fractional-core /
sub-GiB worker; worker size is stored in the bucket key as exact NUMERIC
decimals (vCPU / GiB). `team_id` is the org\'s `default_team_id` (resolved from
the config snapshot at connection end); `query_source` is the
decimals (vCPU / GiB). `team_id` is the org's `default_team_id` (an integer —
PostHog's `Team.id`; a JSON NUMBER on every API surface, BIGINT in the config
store, 0 = "no default team"; resolved from the config snapshot at connection
end); `query_source` is the
`duckgres.query_source` session GUC (`standard` unless set; a mid-connection
change bills the whole connection under the final value). Invariants for anyone
touching this path:
Expand All @@ -483,10 +485,10 @@ touching this path:
is UPSERT-increment so all CP pods sum into one row per key.
- **Serve only closed buckets.** `watermark_high` = the newest bucket with
`bucket_start ≤ now − 60s − 30s grace` (grace > flush interval, so every
CP\'s contribution has landed before a minute is served). The GET aggregates
CP's contribution has landed before a minute is served). The GET aggregates
the window `(cursor, watermark_high]` into one row per
`(org, team, query_source, cpu, mem_gib)` per **UTC day** — response size is
bounded by active keys × days, so billing downtime can\'t make it explode.
bounded by active keys × days, so billing downtime can't make it explode.
- **Ack is the only deletion path (plus the 30d GC).** `POST /billing/ack`
advances the single global cursor monotonically and deletes buckets
`≤ watermark_high` in one TXN (`AckComputeUsage`). Idempotent — re-acks and
Expand Down
28 changes: 23 additions & 5 deletions controlplane/admin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,11 @@ func (s *gormAPIStore) UpdateOrg(name string, updates configstore.Org) (*configs
fields["hostname_alias"] = *updates.HostnameAlias
}
}
// DefaultTeamID is *string with the same semantics: nil = preserve, "" =
// clear (NULL), "x" = set. Nullable/optional — NULL is always valid.
// DefaultTeamID is *int64: nil = preserve, 0 = clear (NULL), n = set.
// (PostHog team ids start at 1, so 0 is a safe clear sentinel; the
// handler also maps an explicit JSON null onto it.)
if updates.DefaultTeamID != nil {
if *updates.DefaultTeamID == "" {
if *updates.DefaultTeamID == 0 {
fields["default_team_id"] = nil
} else {
fields["default_team_id"] = *updates.DefaultTeamID
Expand Down Expand Up @@ -610,7 +611,15 @@ func (h *apiHandler) updateOrg(c *gin.Context) {
merged.HostnameAlias = updates.HostnameAlias
}
if _, ok := fields["default_team_id"]; ok {
merged.DefaultTeamID = updates.DefaultTeamID
// Key present: a number sets, and an explicit JSON null (which
// unmarshals to a nil pointer) clears — normalize null onto the 0
// clear-sentinel so the store layer sees one shape.
if updates.DefaultTeamID == nil {
zero := int64(0)
merged.DefaultTeamID = &zero
} else {
merged.DefaultTeamID = updates.DefaultTeamID
}
}

// Audit detail: which fields changed and their old → new values, so the
Expand All @@ -629,7 +638,7 @@ func (h *apiHandler) updateOrg(c *gin.Context) {
addChange("default_worker_ttl", orgStr(existing.DefaultWorkerTTL), orgStr(merged.DefaultWorkerTTL))
addChange("default_worker_min_hot_idle", existing.DefaultWorkerMinHotIdle, merged.DefaultWorkerMinHotIdle)
addChange("hostname_alias", orgStrPtr(existing.HostnameAlias), orgStrPtr(merged.HostnameAlias))
addChange("default_team_id", orgStrPtr(existing.DefaultTeamID), orgStrPtr(merged.DefaultTeamID))
addChange("default_team_id", orgInt64Ptr(existing.DefaultTeamID), orgInt64Ptr(merged.DefaultTeamID))
if len(changes) > 0 {
setAuditDetail(c, strings.Join(changes, ", "))
}
Expand Down Expand Up @@ -698,6 +707,15 @@ func orgStrPtr(s *string) string {
return orgStr(*s)
}

// orgInt64Ptr renders an optional org config integer (nil or 0 == unset) for
// audit detail.
func orgInt64Ptr(v *int64) string {
if v == nil || *v == 0 {
return "(unset)"
}
return fmt.Sprintf("%d", *v)
}

func validateOrgMutationPayload(org *configstore.Org) error {
if org == nil {
return nil
Expand Down
13 changes: 10 additions & 3 deletions controlplane/admin/ui/src/pages/OrgDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function orgToForm(o: {
default_worker_ttl: string;
default_worker_min_hot_idle: number;
hostname_alias: string | null;
default_team_id: string | null;
default_team_id: number | null;
}): FormState {
return {
max_workers: String(o.max_workers),
Expand All @@ -63,7 +63,7 @@ function orgToForm(o: {
default_worker_ttl: o.default_worker_ttl,
default_worker_min_hot_idle: String(o.default_worker_min_hot_idle),
hostname_alias: o.hostname_alias ?? "",
default_team_id: o.default_team_id ?? "",
default_team_id: o.default_team_id == null ? "" : String(o.default_team_id),
};
}

Expand Down Expand Up @@ -107,6 +107,13 @@ export function OrgDetail() {

const save = async () => {
setMsg(null);
// default_team_id is an integer on the wire (PostHog team id); the text
// input needs a digits-only guard so junk can't silently clear it.
const teamIdText = form.default_team_id.trim();
if (teamIdText !== "" && !/^\d+$/.test(teamIdText)) {
setMsg({ kind: "err", text: "Default team id must be a number (or empty to clear)." });
return;
}
const body: OrgUpdate = {
max_workers: Number(form.max_workers) || 0,
max_vcpus: Number(form.max_vcpus) || 0,
Expand All @@ -115,7 +122,7 @@ export function OrgDetail() {
default_worker_ttl: form.default_worker_ttl,
default_worker_min_hot_idle: Number(form.default_worker_min_hot_idle) || 0,
hostname_alias: form.hostname_alias === "" ? "" : form.hostname_alias,
default_team_id: form.default_team_id === "" ? "" : form.default_team_id,
default_team_id: teamIdText === "" ? 0 : Number(teamIdText),
};
try {
await update.mutateAsync(body);
Expand Down
2 changes: 1 addition & 1 deletion controlplane/admin/ui/src/pages/Orgs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function Orgs() {
accessorKey: "default_team_id",
header: "Default team",
cell: ({ getValue }) => {
const v = getValue() as string | null;
const v = getValue() as number | null;
return v ? <span className="font-mono text-xs">{v}</span> : <span className="text-muted-foreground">—</span>;
},
},
Expand Down
5 changes: 3 additions & 2 deletions controlplane/admin/ui/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export interface Org {
name: string;
database_name: string;
hostname_alias: string | null;
default_team_id: string | null;
default_team_id: number | null;
max_workers: number;
max_vcpus: number;
default_worker_cpu: string;
Expand All @@ -66,7 +66,8 @@ export interface OrgUpdate {
default_worker_ttl?: string;
default_worker_min_hot_idle?: number;
hostname_alias?: string | null;
default_team_id?: string | null;
// Number sets, 0 clears (backend maps 0 → NULL), absent preserves.
default_team_id?: number;
}

// ---- Users (confirmed) ----
Expand Down
2 changes: 1 addition & 1 deletion controlplane/compute_billing_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func TestBillingUsageWindowAndRows(t *testing.T) {
store := &fakeBillingStore{
cursor: cursor, hasAck: true,
rows: []configstore.ComputeUsageRow{{
Date: "2026-07-01", OrgID: "org_abc", TeamID: "12345", QuerySource: "standard",
Date: "2026-07-01", OrgID: "org_abc", TeamID: 12345, QuerySource: "standard",
CPU: "8", MemGiB: "16", CPUSeconds: 4800, MemorySeconds: 9600,
}},
}
Expand Down
16 changes: 8 additions & 8 deletions controlplane/compute_meter.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const (
// bill) separately.
type computeUsageKey struct {
orgID string
teamID string
teamID int64
querySource string
millicores int64
mib int64
Expand Down Expand Up @@ -81,7 +81,7 @@ func computeConnectionUsage(millicores, mib int64, dur time.Duration) (milliCPUS
// Record adds one connection's usage to the in-process counter, bucketed by
// connection-end time. Best-effort: a zero/unknown size is a no-op. Never
// errors.
func (c *computeUsageCounter) Record(orgID, teamID, querySource string, millicores, mib int64, endTime time.Time, dur time.Duration) {
func (c *computeUsageCounter) Record(orgID, querySource string, teamID, millicores, mib int64, endTime time.Time, dur time.Duration) {
if c == nil || orgID == "" || millicores <= 0 {
return
}
Expand Down Expand Up @@ -152,15 +152,15 @@ type computeUsageStore interface {
// computeMeter wires the per-org counter to a flusher. Nil-safe: a disabled
// meter (non-remote backend) leaves this nil and every call site no-ops.
// resolveTeam maps an org to its default PostHog team id at record time (a
// config-snapshot read — no I/O); empty is tolerated per the OrgDefaultTeamID
// contract and the bucket then carries an empty team_id.
// config-snapshot read — no I/O); 0 is tolerated per the OrgDefaultTeamID
// contract and the bucket then carries team_id 0 ("no default team").
type computeMeter struct {
counter *computeUsageCounter
store computeUsageStore
resolveTeam func(orgID string) string
resolveTeam func(orgID string) int64
}

func newComputeMeter(store computeUsageStore, resolveTeam func(orgID string) string) *computeMeter {
func newComputeMeter(store computeUsageStore, resolveTeam func(orgID string) int64) *computeMeter {
return &computeMeter{counter: newComputeUsageCounter(), store: store, resolveTeam: resolveTeam}
}

Expand All @@ -169,11 +169,11 @@ func (m *computeMeter) Record(orgID, querySource string, millicores, mib int64,
if m == nil {
return
}
teamID := ""
teamID := int64(0)
if m.resolveTeam != nil {
teamID = m.resolveTeam(orgID)
}
m.counter.Record(orgID, teamID, querySource, millicores, mib, endTime, dur)
m.counter.Record(orgID, querySource, teamID, millicores, mib, endTime, dur)
}

// Flush drains the in-process counter into the durable buffer once. Best-effort:
Expand Down
36 changes: 18 additions & 18 deletions controlplane/compute_meter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,23 @@ func TestComputeConnectionUsage(t *testing.T) {
// keyA is the billing key most tests share: orgA's default team, standard
// source, an 8-vCPU/16-GiB worker.
func keyA(bucket time.Time) computeUsageKey {
return computeUsageKey{orgID: "orgA", teamID: "42", querySource: "standard", millicores: 8000, mib: 16 * 1024, bucket: bucket}
return computeUsageKey{orgID: "orgA", teamID: 42, querySource: "standard", millicores: 8000, mib: 16 * 1024, bucket: bucket}
}

func TestComputeUsageCounterBucketing(t *testing.T) {
c := newComputeUsageCounter()
base := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
// Two connections in the same minute-bucket for the same key accumulate.
c.Record("orgA", "42", "standard", 8000, 16*1024, base.Add(5*time.Second), 10*time.Second) // 80 cpu-s, 160 gib-s
c.Record("orgA", "42", "standard", 8000, 16*1024, base.Add(40*time.Second), 10*time.Second) // +80, +160
c.Record("orgA", "standard", 42, 8000, 16*1024, base.Add(5*time.Second), 10*time.Second) // 80 cpu-s, 160 gib-s
c.Record("orgA", "standard", 42, 8000, 16*1024, base.Add(40*time.Second), 10*time.Second) // +80, +160
// A connection in the next bucket is separate.
c.Record("orgA", "42", "standard", 8000, 16*1024, base.Add(65*time.Second), 5*time.Second) // 40 cpu-s, 80 gib-s
c.Record("orgA", "standard", 42, 8000, 16*1024, base.Add(65*time.Second), 5*time.Second) // 40 cpu-s, 80 gib-s
// Different org, same bucket.
c.Record("orgB", "7", "standard", 1000, 1024, base.Add(5*time.Second), 2*time.Second) // 2 cpu-s, 2 gib-s
c.Record("orgB", "standard", 7, 1000, 1024, base.Add(5*time.Second), 2*time.Second) // 2 cpu-s, 2 gib-s
// Same org+bucket but a different query source is a separate key.
c.Record("orgA", "42", "endpoints", 8000, 16*1024, base.Add(10*time.Second), 10*time.Second) // 80, 160
c.Record("orgA", "endpoints", 42, 8000, 16*1024, base.Add(10*time.Second), 10*time.Second) // 80, 160
// Same org+bucket but a different worker size is a separate key.
c.Record("orgA", "42", "standard", 2000, 4*1024, base.Add(10*time.Second), 10*time.Second) // 20, 40
c.Record("orgA", "standard", 42, 2000, 4*1024, base.Add(10*time.Second), 10*time.Second) // 20, 40

deltas := c.drainWholeUnits()
got := map[computeUsageKey]configstore.ComputeUsageDelta{}
Expand All @@ -92,7 +92,7 @@ func TestComputeUsageCounterBucketing(t *testing.T) {
if d := got[keyA(bucket1)]; d.CPUSeconds != 40 || d.MemorySeconds != 80 {
t.Errorf("orgA bucket1 = (%d,%d), want (40,80)", d.CPUSeconds, d.MemorySeconds)
}
if d := got[computeUsageKey{orgID: "orgB", teamID: "7", querySource: "standard", millicores: 1000, mib: 1024, bucket: bucket0}]; d.CPUSeconds != 2 || d.MemorySeconds != 2 {
if d := got[computeUsageKey{orgID: "orgB", teamID: 7, querySource: "standard", millicores: 1000, mib: 1024, bucket: bucket0}]; d.CPUSeconds != 2 || d.MemorySeconds != 2 {
t.Errorf("orgB bucket0 = (%d,%d), want (2,2)", d.CPUSeconds, d.MemorySeconds)
}
endpointsKey := keyA(bucket0)
Expand All @@ -112,12 +112,12 @@ func TestComputeUsageCounterRemainderCarry(t *testing.T) {
base := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
// 500 millicores × 1s = 500 milli-cpu-seconds = 0 whole vCPU-seconds yet,
// and 512 MiB × 1s = 512 MiB-seconds = 0 whole GiB-seconds yet.
c.Record("orgA", "42", "standard", 500, 512, base, 1*time.Second)
c.Record("orgA", "standard", 42, 500, 512, base, 1*time.Second)
if deltas := c.drainWholeUnits(); len(deltas) != 0 {
t.Fatalf("expected no whole units yet, got %v", deltas)
}
// Add another 500 milli-cpu-s (→ 1000 = 1 vCPU-s) and 512 MiB-s (→ 1024 = 1 GiB-s).
c.Record("orgA", "42", "standard", 500, 512, base, 1*time.Second)
c.Record("orgA", "standard", 42, 500, 512, base, 1*time.Second)
deltas := c.drainWholeUnits()
if len(deltas) != 1 || deltas[0].CPUSeconds != 1 || deltas[0].MemorySeconds != 1 {
t.Fatalf("expected exactly 1 vCPU-s + 1 GiB-s after carry, got %v", deltas)
Expand Down Expand Up @@ -146,11 +146,11 @@ func (f *fakeFlushStore) FlushComputeUsage(d []configstore.ComputeUsageDelta) er
return nil
}

func teamResolverA(orgID string) string {
func teamResolverA(orgID string) int64 {
if orgID == "orgA" {
return "42"
return 42
}
return ""
return 0
}

func TestComputeMeterFlush(t *testing.T) {
Expand All @@ -166,8 +166,8 @@ func TestComputeMeterFlush(t *testing.T) {
t.Fatalf("flushed = %v, want one (80,160)", store.flushed)
}
// The meter resolves the org's default team at record time.
if store.flushed[0].TeamID != "42" || store.flushed[0].QuerySource != "standard" {
t.Fatalf("flushed key = (team=%q, source=%q), want (42, standard)", store.flushed[0].TeamID, store.flushed[0].QuerySource)
if store.flushed[0].TeamID != 42 || store.flushed[0].QuerySource != "standard" {
t.Fatalf("flushed key = (team=%d, source=%q), want (42, standard)", store.flushed[0].TeamID, store.flushed[0].QuerySource)
}
// Nothing left to flush.
if n := m.Flush(); n != 0 {
Expand All @@ -177,15 +177,15 @@ func TestComputeMeterFlush(t *testing.T) {

func TestComputeMeterUnknownTeamTolerated(t *testing.T) {
// An org with no default_team_id (or a nil resolver) still meters — the
// bucket carries an empty team_id rather than dropping usage.
// bucket carries team_id 0 rather than dropping usage.
store := &fakeFlushStore{}
m := newComputeMeter(store, teamResolverA)
m.Record("orgB", "standard", 1000, 1024, time.Now(), 2*time.Second)
if n := m.Flush(); n != 1 {
t.Fatalf("Flush rows = %d, want 1", n)
}
if store.flushed[0].TeamID != "" {
t.Fatalf("team = %q, want empty for unknown org", store.flushed[0].TeamID)
if store.flushed[0].TeamID != 0 {
t.Fatalf("team = %d, want 0 for unknown org", store.flushed[0].TeamID)
}
}

Expand Down
4 changes: 2 additions & 2 deletions controlplane/configstore/compute_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
// float-equality issues.
type ComputeUsageDelta struct {
OrgID string
TeamID string
TeamID int64
QuerySource string
Millicores int64
MiB int64
Expand All @@ -36,7 +36,7 @@ type ComputeUsageDelta struct {
type ComputeUsageRow struct {
Date string `json:"date"`
OrgID string `json:"org_id"`
TeamID string `json:"team_id"`
TeamID int64 `json:"team_id"`
QuerySource string `json:"query_source"`
CPU json.Number `json:"cpu"`
MemGiB json.Number `json:"mem_gib"`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- +goose Up

-- default_team_id becomes a BIGINT end-to-end. PostHog team ids are integers
-- (Team.id), so callers naturally send JSON numbers — the string-typed column
-- forced a string JSON field, which 400ed the obvious `"default_team_id":
-- 12345` provision payload. All stored values are numeric strings (set via the
-- provisioning API / admin UI), so the cast is safe; a non-numeric value would
-- fail this migration loudly, which is the correct outcome (fix the row, not
-- the type). NULL stays NULL (empty string normalizes to NULL). The ::text in
-- the USING clauses makes the rewrite idempotent — a replay onto an
-- already-BIGINT column (goose re-run in tests) still type-checks.
ALTER TABLE duckgres_orgs
ALTER COLUMN default_team_id TYPE BIGINT USING NULLIF(default_team_id::text, '')::bigint;

-- The compute-usage bucket key mirrors the org column. team_id sits in the
-- PRIMARY KEY so it must stay NOT NULL: 0 = "org had no default team" (PostHog
-- team ids start at 1), replacing the old '' sentinel.
-- Drop the old '' default before the type change — Postgres refuses to
-- auto-cast a column default during ALTER TYPE (42804).
ALTER TABLE duckgres_org_compute_usage
ALTER COLUMN team_id DROP DEFAULT,
ALTER COLUMN team_id TYPE BIGINT USING COALESCE(NULLIF(team_id::text, ''), '0')::bigint,
ALTER COLUMN team_id SET DEFAULT 0;

-- +goose Down

-- Best-effort reversal: the 0 sentinel downgrades to '0' (not the old ''),
-- which is fine for a transient buffer.
ALTER TABLE duckgres_org_compute_usage
ALTER COLUMN team_id DROP DEFAULT,
ALTER COLUMN team_id TYPE TEXT USING team_id::text,
ALTER COLUMN team_id SET DEFAULT '';

ALTER TABLE duckgres_orgs
ALTER COLUMN default_team_id TYPE VARCHAR(255) USING default_team_id::text;
16 changes: 8 additions & 8 deletions controlplane/configstore/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ type Org struct {
DefaultWorkerMemory string `gorm:"size:32" json:"default_worker_memory"`
DefaultWorkerTTL string `gorm:"size:32" json:"default_worker_ttl"`
DefaultWorkerMinHotIdle int `gorm:"default:0" json:"default_worker_min_hot_idle"`
// DefaultTeamID links the org to its default PostHog team (a team id, kept
// as a string). It is a prerequisite for pull-based compute billing —
// usage buckets are keyed by team_id = the org's default team. NULLABLE /
// optional everywhere: NULL means "unset" (existing orgs are backfilled
// separately, a follow-up makes it required). *string so the column is a
// nullable VARCHAR; callers must tolerate an empty/absent value.
DefaultTeamID *string `gorm:"size:255" json:"default_team_id,omitempty"`
// DefaultTeamID links the org to its default PostHog team id (an integer,
// matching PostHog's Team.id). It is a prerequisite for pull-based compute
// billing — usage buckets are keyed by team_id = the org's default team.
// *int64 so the column is a nullable BIGINT: NULL means "unset" (required
// at provision time for new orgs; the admin API can still clear it).
// Callers must tolerate an absent value.
DefaultTeamID *int64 `json:"default_team_id,omitempty"`
Users []OrgUser `gorm:"foreignKey:OrgID;references:Name" json:"users,omitempty"`
Warehouse *ManagedWarehouse `gorm:"foreignKey:OrgID;references:Name;constraint:OnDelete:CASCADE" json:"warehouse,omitempty"`
CreatedAt time.Time `json:"created_at"`
Expand Down Expand Up @@ -480,7 +480,7 @@ type OrgConfig struct {
DefaultWorkerMemory string // org default worker profile: pod memory quantity ("" = unset)
DefaultWorkerTTL string // org default worker profile: hot-idle TTL, Go duration string ("" = unset)
DefaultWorkerMinHotIdle int // minimum default-profile hot-idle workers to retain for this org
DefaultTeamID string // org's default PostHog team id ("" = unset); prereq for pull-based compute billing
DefaultTeamID int64 // org's default PostHog team id (0 = unset); prereq for pull-based compute billing
Users map[string]string // username -> password
Warehouse *ManagedWarehouseConfig
}
Expand Down
Loading
Loading