carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The leftover-replica rail on the instance and sidecar create doors
2// (discussion #199). This is a stopgap, not the fix.
3//
4// A hibernating record's replica lives at instances/<host> and a residence
5// move stages one at moves/<host>/. Both are keyed by HOST alone, and
6// deleting the record leaves both behind (deliberately: retiring a record is
7// not destroying its data). So whoever next creates a record on the same host
8// inherits the old database, on its first wake or through `carlos db pull`.
9// The full fix is being designed separately. Until it lands, the create
10// doors refuse a new record on a host whose replica or staging prefix in this
11// console's data bucket still holds an LTX file, and refuse on uncertainty
12// when the bucket cannot be read.
13//
14// The gap, stated plainly: the check only sees THIS console's region. A
15// record homed in another region, placed on a customer fleet, or run in a
16// pool is created unchecked, because its data lives somewhere this console's
17// role cannot read (resolveReplica's residency rules). Leftovers in this region under a
18// host created elsewhere are not seen either, and a later move of that
19// instance into this region would land on top of them. A console with no
20// data bucket configured checks nothing.
21
22package console
23
24import (
25 "context"
26 "fmt"
27 "log"
28 "net/http"
29 "strings"
30
31 "github.com/carlosframework/platform/internal/activator"
32 "github.com/carlosframework/platform/internal/dbdoor"
33 "github.com/carlosframework/platform/internal/registry"
34)
35
36// leftoverCheckApplies reports whether a record homed in region, in pool,
37// can be checked from here: the data bucket is configured, and the record's
38// data would live in it. The residency rules are resolveReplica's: a pooled
39// record's data is on the pool's own box; a fleet placement is never this
40// console's to read, whatever its own region string says; and otherwise the
41// record's region must equal the console's ("" == "" on the home region and
42// on a single-region deployment).
43func (s *Server) leftoverCheckApplies(region, pool string) bool {
44 if s.cfg.HibernateBucket == "" || pool != "" {
45 return false
46 }
47 if _, fleet := registry.FleetPlacement(region); fleet {
48 return false
49 }
50 return region == s.cfg.Region
51}
52
53// hostHoldsReplicaData reports whether host's replica prefix or its move
54// staging prefix holds any LTX file, at any level from 0 to the snapshot
55// level. It stops at the first file. Every error is returned, never treated
56// as "empty": the caller refuses on uncertainty.
57//
58// The listing goes through s.dbReplica, the db door's cached
59// lease.ReadReplicaClient seam, so the reader here and the writer on the box
60// cannot disagree about the bucket's endpoint, region or layout.
61//
62// Neither prefix may end in "/". litestream's S3 client lists
63// <path>/<level>/, so MovesPrefix's own trailing slash would ask for
64// moves/<host>//0000/, which matches nothing in S3 and would read every
65// staged replica as absent. (The file client joins paths and forgives it,
66// which is why TestLeftoverCheckListsTheExactS3Prefixes goes through the
67// S3 client.) The slash litestream adds is also what keeps app.example.co
68// from seeing app.example.com's files.
69func (s *Server) hostHoldsReplicaData(ctx context.Context, host string) (bool, error) {
70 for _, prefix := range []string{
71 activator.HibernatePrefix(s.cfg.HibernatePrefix, host),
72 strings.TrimSuffix(activator.MovesPrefix(host), "/"),
73 } {
74 rd, err := s.dbReplica(ctx, prefix)
75 if err != nil {
76 return false, fmt.Errorf("replica client for %s: %w", prefix, err)
77 }
78 for level := 0; level <= dbdoor.MaxLevel; level++ {
79 found, err := anyLTXFile(ctx, rd, level)
80 if err != nil {
81 return false, fmt.Errorf("list %s level %d: %w", prefix, level, err)
82 }
83 if found {
84 return true, nil
85 }
86 }
87 }
88 return false, nil
89}
90
91// anyLTXFile reports whether one level lists at least one file.
92func anyLTXFile(ctx context.Context, rd replicaReader, level int) (bool, error) {
93 itr, err := rd.LTXFiles(ctx, level, 0, false)
94 if err != nil {
95 return false, err
96 }
97 defer itr.Close()
98 if itr.Next() {
99 return true, nil
100 }
101 return false, itr.Err()
102}
103
104// leftoverCreate is one create as the rail needs it.
105type leftoverCreate struct {
106 logTag string // "api instances create <sqid>/<app>"
107 host, region, pool string
108 // exists reads the record this create would write.
109 exists func(ctx context.Context) (bool, error)
110 // duplicate and readFailed are the door's OWN answers to a record that
111 // already exists and to a store read that failed, byte for byte.
112 duplicate, readFailed doorRefusal
113 // taken and unsure are the rail's 409 and 503 sentences.
114 taken, unsure string
115}
116
117// leftoverReplicaRefusal is the rail as the doors call it. It answers nil
118// when the create may go ahead: the record is out of this console's sight
119// (leftoverCheckApplies), or the host holds no replica data. Otherwise it
120// answers 409 with taken, or 503 with unsure when the bucket could not be
121// read. The store's own error is logged and never returned: it carries the
122// bucket name.
123//
124// It reads the record first. A create retried after a lost response finds
125// its own record there, and that record's live replica fills the prefix,
126// so without the read the retry would be told the address holds a deleted
127// instance's data (or, in an S3 outage, to try again). It gets the door's
128// ordinary duplicate answer instead. A failed read is not "absent": it gets
129// the door's own failure. The create stays create-only, so a record that
130// appears after this read still loses the CAS in the write.
131//
132// The rail gets its own instanceStoreTimeout and runs off the request's
133// context rather than the door's, whose budget earlier reads have spent
134// some of. The door must likewise take a fresh budget for its write after
135// this returns.
136func (s *Server) leftoverReplicaRefusal(parent context.Context, c leftoverCreate) *doorRefusal {
137 if !s.leftoverCheckApplies(c.region, c.pool) {
138 return nil
139 }
140 ctx, cancel := context.WithTimeout(parent, instanceStoreTimeout)
141 defer cancel()
142 exists, err := c.exists(ctx)
143 if err != nil {
144 log.Printf("console: %s: read existing record for %s: %v", c.logTag, c.host, err)
145 return &c.readFailed
146 }
147 if exists {
148 return &c.duplicate
149 }
150 held, err := s.hostHoldsReplicaData(ctx, c.host)
151 if err != nil {
152 log.Printf("console: %s: leftover replica check for %s: %v", c.logTag, c.host, err)
153 return &doorRefusal{http.StatusServiceUnavailable, c.unsure}
154 }
155 if held {
156 log.Printf("console: %s: refused: %s still holds replica data from a deleted record (#199); purge it to free the host", c.logTag, c.host)
157 return &doorRefusal{http.StatusConflict, c.taken}
158 }
159 return nil
160}
161