eleven / messenger Public

server: one free space per account, and a quota-blocked create reclaims your own dead weight (#838)

Default LCHAT_CLOUD_QUOTA 3 → 1: one free space is the taste of the product;
wanting a second is the upgrade moment (paid spaces never counted). Every
free space is standing liability — a ledger slot against the box cap, disk,
an S3 prefix, and a chance to leak awake — with no revenue signal at 3.

With one slot, a fumbled create must not lock the account out for the
reaper's 14 days. So on quota_exceeded the create path now frees the
requester's own abandoned allocation first: a stale pending row (a create
that died between allocate and finalize — previously held its slot forever),
or a provisioned space passing the reaper's exact never-used guards (never
set up, nobody joined, zero content), age-gated to 1h so a space mid-setup
in another tab is never touched. Reclaimed instances archive to /data/trash
via the normal controld delete. Anything in real use keeps the 409 (message
now pluralises at quota 1 and mentions delete as the way out).

Existing accounts holding 2-3 free spaces are naturally grandfathered — the
check runs at create time only.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 50a585204deba7bf087199ec692dab4faa796e22 parent ca2b390
6 files changed, +274 −5
  • cloud.go +20 −2
  • cloud_db.go +26 −0
  • cloud_test.go +144 −0
  • docs/cloud-provisioner.md +23 −1
  • home/pow.go +2 −2
  • reaper.go +59 −0
diff --git a/cloud.go b/cloud.go
index d71dc36..ec3ee81 100644
--- a/cloud.go
+++ b/cloud.go
@@ -170,8 +170,26 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
return
}
if overQuota {
+ // Before refusing, try to free the slot ourselves: the requester's own
+ // abandoned space (a create that died mid-flight, or one they never set
+ // up) shouldn't block their fresh start for the reaper's 14 days. Only
+ // ever reclaims under the reaper's own never-used guards + an age gate,
+ // so a space mid-setup or in real use is never eaten.
+ if s.reclaimOwnSpace(callerID, req.Requester) {
+ id, overQuota, err = s.db.allocateSpace(callerID, req.Requester, s.quota)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error")
+ return
+ }
+ }
+ }
+ if overQuota {
+ noun := "spaces"
+ if s.quota == 1 {
+ noun = "space"
+ }
writeControlErr(w, http.StatusConflict, "quota_exceeded",
- fmt.Sprintf("this account already has %d free spaces (the limit) — upgrading a space frees its slot", s.quota))
+ fmt.Sprintf("this account already has %d free %s (the limit) — upgrading a space frees its slot, and so does deleting one you no longer use", s.quota, noun))
return
}
@@ -388,7 +406,7 @@ func runCloud(args []string) {
socketPath := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr")
dbPath := fs.String("db", envOr("LCHAT_CLOUD_DB", "cloud.db"), "provisioner SQLite path")
baseDomain := fs.String("base-domain", os.Getenv("LCHAT_CLOUD_BASE_DOMAIN"), "domain spaces are created under (required; needs wildcard DNS at this box)")
- quota := fs.Int("quota", envInt("LCHAT_CLOUD_QUOTA", 3), "max spaces per caller+requester")
+ quota := fs.Int("quota", envInt("LCHAT_CLOUD_QUOTA", 1), "max free spaces per caller+requester (paid spaces never count)")
controlSocket := fs.String("control-socket", envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock"), "the control daemon's unix socket")
reapAfterDays := fs.Int("reap-after-days", envInt("LCHAT_CLOUD_REAP_AFTER_DAYS", 0), "reclaim never-claimed (zero-member) spaces older than this many days; 0 disables the reaper")
reapIntervalHours := fs.Int("reap-interval-hours", envInt("LCHAT_CLOUD_REAP_INTERVAL_HOURS", 6), "how often the reaper sweeps")
diff --git a/cloud_db.go b/cloud_db.go
index 7b394be..c8b04a4 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -349,6 +349,32 @@ type reapCandidate struct {
CreatedAt int64
}
+// reclaimCandidates returns one account's own free-entitlement rows created
+// at/before cutoff, oldest first — the pool the create path may self-reclaim
+// from when the quota is hit. Unlike spacesOlderThan this INCLUDES pending
+// rows (NULL host, returned as ""): a create that died between allocate and
+// finalize holds a quota slot forever otherwise, which at quota 1 would brick
+// the account. Paid rows are excluded at the source, same as the reaper.
+func (db *cloudDB) reclaimCandidates(callerID, requester string, cutoff int64) ([]reapCandidate, error) {
+ rows, err := db.sql.Query(
+ `SELECT id, COALESCE(host, ''), created_at FROM spaces
+ WHERE caller_id = ? AND requester = ? AND entitlement = 'free' AND created_at <= ?
+ ORDER BY created_at`, callerID, requester, cutoff)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []reapCandidate
+ for rows.Next() {
+ var c reapCandidate
+ if err := rows.Scan(&c.ID, &c.Host, &c.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
// spacesOlderThan returns finalized spaces created at/before cutoff (unix secs),
// oldest first — the reaper's candidate set. A NULL host (create in flight or
// rolled back) is never a candidate, and neither is a paid space: someone paying
diff --git a/cloud_test.go b/cloud_test.go
index 9b15201..529ed3d 100644
--- a/cloud_test.go
+++ b/cloud_test.go
@@ -10,7 +10,9 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
+ "time"
)
// fakeControl serves a stand-in control daemon on a unix socket, so the cloud
@@ -318,6 +320,148 @@ func mustCallerID(t *testing.T, s *cloudServer, label string) string {
return ""
}
+// reclaimTracker scripts the fake control daemon for the self-reclaim tests
+// and records what got deleted.
+type reclaimTracker struct {
+ mu sync.Mutex
+ stats instanceStats
+ deleted []string
+}
+
+func (tr *reclaimTracker) count() int {
+ tr.mu.Lock()
+ defer tr.mu.Unlock()
+ return len(tr.deleted)
+}
+
+// reclaimControl answers creates with 201, stats from tr.stats, and records deletes.
+func reclaimControl(t *testing.T, tr *reclaimTracker) string {
+ return fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/stats"):
+ tr.mu.Lock()
+ st := tr.stats
+ tr.mu.Unlock()
+ writeJSON(w, http.StatusOK, st)
+ case r.Method == "DELETE":
+ tr.mu.Lock()
+ tr.deleted = append(tr.deleted, strings.TrimPrefix(r.URL.Path, "/instances/"))
+ tr.mu.Unlock()
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ default:
+ var req controlCreateReq
+ json.NewDecoder(r.Body).Decode(&req)
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
+ })
+ }
+ })
+}
+
+// A quota-blocked create reclaims the account's own abandoned space (never set
+// up, past the age gate) instead of failing: the old instance archives via
+// controld and the fresh one takes its slot. A space younger than the age gate
+// is never touched — not even consulted.
+func TestCloudQuotaSelfReclaim(t *testing.T) {
+ tr := &reclaimTracker{} // zero-value stats = never set up, nobody joined, no content
+ srv, s := newTestCloud(t, reclaimControl(t, tr))
+ s.quota = 1
+ tok := mintCaller(t, srv, "home")
+
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("first create: %d", resp.StatusCode)
+ }
+ first, _ := decodeBody(t, resp)["host"].(string)
+
+ // Fresh space → the age gate holds: 409, and controld is never asked.
+ if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusConflict {
+ t.Fatalf("fresh over-quota create: %d, want 409 (age gate)", r.StatusCode)
+ }
+ if n := tr.count(); n != 0 {
+ t.Fatalf("age-gated create deleted %d space(s) — must not touch a fresh space", n)
+ }
+
+ // Age it past the gate → the next create reclaims it and succeeds.
+ if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = created_at - 7200`); err != nil {
+ t.Fatal(err)
+ }
+ resp = createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("reclaiming create: %d, want 201", resp.StatusCode)
+ }
+ second, _ := decodeBody(t, resp)["host"].(string)
+ if second == first {
+ t.Fatalf("second host = first (%s), want a fresh instance", first)
+ }
+ tr.mu.Lock()
+ deleted := append([]string(nil), tr.deleted...)
+ tr.mu.Unlock()
+ if len(deleted) != 1 || deleted[0] != first {
+ t.Errorf("deleted = %v, want [%s]", deleted, first)
+ }
+ spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(spaces) != 1 || spaces[0].Host != second {
+ t.Errorf("ledger = %+v, want just %s", spaces, second)
+ }
+}
+
+// A space in real use — set up, joined, or holding content — is never
+// self-reclaimed: the quota refusal stands.
+func TestCloudQuotaSelfReclaimKeepsUsedSpaces(t *testing.T) {
+ tr := &reclaimTracker{stats: instanceStats{Members: 1, SetupComplete: true}}
+ srv, s := newTestCloud(t, reclaimControl(t, tr))
+ s.quota = 1
+ tok := mintCaller(t, srv, "home")
+
+ if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusCreated {
+ t.Fatalf("create: %d", r.StatusCode)
+ }
+ if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = created_at - 7200`); err != nil {
+ t.Fatal(err)
+ }
+ if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusConflict {
+ t.Fatalf("over quota vs a set-up space: %d, want 409", r.StatusCode)
+ }
+ if n := tr.count(); n != 0 {
+ t.Errorf("deleted %d space(s) — a set-up space must never be reclaimed", n)
+ }
+}
+
+// A pending row (a create that died between allocate and finalize) past the
+// age gate frees on the next create instead of holding the slot forever —
+// ledger-only, no controld call (there is no instance behind it).
+func TestCloudQuotaSelfReclaimStalePending(t *testing.T) {
+ tr := &reclaimTracker{}
+ srv, s := newTestCloud(t, reclaimControl(t, tr))
+ s.quota = 1
+ tok := mintCaller(t, srv, "home")
+ callerID := mustCallerID(t, s, "home")
+
+ if _, err := s.db.sql.Exec(
+ `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL, ?, ?, ?)`,
+ callerID, "user-1", time.Now().Add(-2*time.Hour).Unix()); err != nil {
+ t.Fatal(err)
+ }
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create blocked by a stale pending row: %d, want 201", resp.StatusCode)
+ }
+ if n := tr.count(); n != 0 {
+ t.Errorf("controld deletes = %d, want 0 (pending rows are ledger-only)", n)
+ }
+ spaces, err := s.db.listSpaces(callerID, "user-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(spaces) != 1 {
+ t.Errorf("ledger = %+v, want exactly the new space", spaces)
+ }
+}
+
func TestCloudAdminTokenLifecycle(t *testing.T) {
srv, _ := newTestCloud(t, okControl(t))
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 5e656af..7f98173 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -159,7 +159,9 @@ subdomains land on the fallback over https too.
```
POST /api/instances bearer: caller token {requester: <opaque id>}
201 {host, url, setup_link}
- 409 {error: quota_exceeded} per (caller, requester) quota, default 3
+ 409 {error: quota_exceeded} per (caller, requester) FREE-space quota,
+ default 1 (LCHAT_CLOUD_QUOTA; paid spaces
+ never count, and upgrading frees the slot)
503 {error: at_capacity, message} the box is full
GET /api/instances?requester=… this requester's spaces (+ entitlement/tier,
and a top-level billing price book)
@@ -292,6 +294,26 @@ would otherwise sit there forever: empty, but holding a slot and a disk. The
Because the reaper only ever considers hosts **in the cloud ledger**, operator-run
instances (family.…, home, push, …) are never candidates.
+### Self-reclaim: a quota-blocked create frees the account's own dead weight
+
+With the free quota at 1, a fumbled create (made a space, lost the tab, never
+set it up) must not lock the account out until the reaper's 14-day sweep. So
+when a create hits `quota_exceeded`, the cloud first tries to reclaim **one of
+the requester's own abandoned allocations** and, if that frees a slot, proceeds
+with the create instead of failing:
+
+- Candidates are only the same (caller, requester)'s free-entitlement rows
+ **older than one hour** — setup takes minutes, so the age gate cleanly
+ separates "abandoned, retrying" from "setting it up in another tab right
+ now" (the home page keeps the setup link for continuing inside the window).
+- A **pending row** (a create that died between allocate and finalize — it
+ would otherwise hold the slot forever) is cleared ledger-only.
+- A **provisioned space** must clear the reaper's exact three guards above
+ (never set up, nobody joined, zero content); it archives to `/data/trash`
+ through the same controld delete. A stats error is a skip, never a reclaim.
+- Anything in real use stays untouched and the 409 stands — the way out then
+ is upgrading a space (paid spaces don't count) or deleting one from home.
+
**Hibernating spaces never wake for the reaper.** Stats always come from local
state, not from the instance: an *asleep* space is read from its on-disk DB (the
warm cache the activator left at its last sleep — on one box, the newest truth),
diff --git a/home/pow.go b/home/pow.go
index e419038..64f48b8 100644
--- a/home/pow.go
+++ b/home/pow.go
@@ -1,8 +1,8 @@
package main
// Hashcash at space creation (#561): the client burns a few seconds of CPU
-// per space, making the Sybil loop (mint invites → fresh accounts → 3 more
-// free spaces each) cost compute instead of nothing. The burn runs during the
+// per space, making the Sybil loop (mint invites → fresh accounts → another
+// free space each) cost compute instead of nothing. The burn runs during the
// fader's "Hold on…" build phase — the cost IS the animation.
//
// Classic hashcash: the server hands out a sealed, expiring, single-use
diff --git a/reaper.go b/reaper.go
index 0c26645..cd5c35b 100644
--- a/reaper.go
+++ b/reaper.go
@@ -254,3 +254,62 @@ func (s *cloudServer) reapOnce(now time.Time) (reaped, kept int) {
}
return reaped, kept
}
+
+// ---- self-reclaim (the create path's escape hatch) ------------------------------
+
+// reclaimAge is how old one of the account's own spaces must be before a
+// quota-blocked create may reclaim it. Setup takes minutes, so an hour cleanly
+// separates "abandoned yesterday, retrying now" (reclaim) from "setting it up
+// in another tab right now" (never touch) — and the home page keeps the setup
+// link for continuing inside the window.
+const reclaimAge = time.Hour
+
+// reclaimOwnSpace frees at most one quota slot for (caller, requester) by
+// reclaiming their own oldest abandoned allocation: a pending row whose create
+// died between allocate and finalize, or a provisioned space that clears the
+// reaper's never-used guards (never set up, nobody joined, zero content) —
+// plus ownership and the age gate on top. Data still archives to /data/trash
+// via controld, same as the reaper. A stats error is a skip, never a reclaim.
+// Returns whether a slot was freed.
+func (s *cloudServer) reclaimOwnSpace(callerID, requester string) bool {
+ cands, err := s.db.reclaimCandidates(callerID, requester, time.Now().Add(-reclaimAge).Unix())
+ if err != nil {
+ log.Printf("cloud: self-reclaim: ledger query: %v", err)
+ return false
+ }
+ for _, c := range cands {
+ if c.Host == "" {
+ // A pending row this old is a dead create (real ones finalize in
+ // seconds); there is no instance behind it — ledger-only cleanup.
+ if err := s.db.deleteSpaceByID(c.ID); err != nil {
+ log.Printf("cloud: self-reclaim: clear stale pending row (id %d): %v", c.ID, err)
+ continue
+ }
+ log.Printf("cloud: self-reclaim: cleared stale pending allocation (id %d, requester of caller %.8s…)", c.ID, callerID)
+ return true
+ }
+ st, err := s.controlStats(c.Host)
+ if err != nil {
+ log.Printf("cloud: self-reclaim: skip %s (stats unavailable): %v", c.Host, err)
+ continue
+ }
+ if st.SetupComplete || st.Members > 1 || st.HasContent {
+ continue // in real use — the quota refusal stands
+ }
+ status, body, err := s.controlDelete(c.Host)
+ if err != nil {
+ log.Printf("cloud: self-reclaim: delete %s failed: %v", c.Host, err)
+ continue
+ }
+ if status != http.StatusOK && body["error"] != "unknown_host" {
+ log.Printf("cloud: self-reclaim: delete %s: HTTP %d", c.Host, status)
+ continue
+ }
+ if err := s.db.deleteSpaceByID(c.ID); err != nil {
+ log.Printf("cloud: self-reclaim: ledger delete %s (id %d): %v", c.Host, c.ID, err)
+ }
+ log.Printf("cloud: self-reclaim: reclaimed %s (id %d) — owner's own never-set-up space, making room for their new create", c.Host, c.ID)
+ return true
+ }
+ return false
+}