package hostops import ( "context" "crypto/rand" "encoding/hex" "errors" "time" ) const RegistrationIngest = "ingest" const SQLTransactionLifetime = 30 * time.Second const sqlIngestAction = "sql-ingest" // IngestHandle has no lifecycle or generic runner door. One registration // issues exactly one SQL batch; the effect remains a drain obligation forever // until its issuer joins it or its own SQL database durably fences it. type IngestHandle struct{ start *StartHandle } func (w *ControlWriter) RegisterIngest(ctx context.Context, account, app, appID, id string, owner RegistrationOwner) (*IngestHandle, error) { if owner.Console == "" || !validRegistrationOwner(owner) { return nil, ErrIdentity } if err := w.purgeAbsent(ctx, account, app); err != nil { return nil, err } a, err := w.read(ctx, account, app, appID) if err != nil { return nil, err } reg := Registration{Kind: RegistrationIngest, Owner: owner, AppID: appID, Epoch: a.Value.Epoch} _, err = w.retryAdmission(ctx, account, app, appID, a, func(v *AppAdmission) error { return registerStart(v, id, reg, w.now()) }) if err != nil { return nil, err } return &IngestHandle{&StartHandle{w: w, account: account, app: app, appID: appID, id: id, owner: owner, epoch: reg.Epoch, kind: RegistrationIngest}}, nil } // SQLIngestRunner is implemented only by the configured mutation boundary. // Database identifies the persistent local receipt namespace, never a path. type SQLIngestRunner interface { RunSQLIngest(context.Context, string, string, string, string, func(context.Context) error) error } func (b *mutationBoundary) RunSQLIngest(ctx context.Context, account, app, action, database string, f func(context.Context) error) error { if b.control == nil || b.localAppID == nil || f == nil || database == "" || action == "" { return ErrContract } appID, err := b.localAppID(ctx, account, app) if err != nil { return err } if appID == "" { return ErrIdentity } // Existing lifecycle and mail operations retain their own authority. if RequireAppMutation(ctx, b.raw, account, app, appID) == nil { return f(ctx) } inherited, _ := ctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) for _, p := range inherited { if p != nil && p.account == account && p.app == app { return ErrPending } } var nonce [24]byte if _, err = rand.Read(nonce[:]); err != nil { return err } id := hex.EncodeToString(nonce[:]) owner := b.owner owner.Controller = id h, err := b.control.RegisterIngest(ctx, account, app, appID, id, owner) if err != nil { return err } pctx, settle, err := h.start.Runner(b.control.now).Issue(ctx, Effect{ID: id, Resource: "app-sql:" + id, Action: sqlIngestAction, Inputs: map[string]string{"sql_database": database, "sql_action": action}}) if err != nil { return err } // Panic is uncertain too. A process crash leaves issued in the bucket. var callErr error func() { defer func() { if p := recover(); p != nil { _ = settle("", errors.New("SQL callback panicked")) panic(p) } }() callErr = f(pctx) }() settleErr := callErr permits, _ := pctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) for _, p := range permits { if p.effect == id && p.sqlJoined.Load() { settleErr = nil } } if err = settle("sql-joined", settleErr); err != nil { return err } return errors.Join(callErr, b.control.finishIngest(context.WithoutCancel(ctx), h.start)) } func (w *ControlWriter) finishIngest(ctx context.Context, h *StartHandle) error { _, err := w.retryAdmission(ctx, h.account, h.app, h.appID, AdmissionObserved{}, func(v *AppAdmission) error { reg, ok := v.Registrations[h.id] if !ok { return nil } if reg.Kind != RegistrationIngest || reg.Owner != h.owner || !effectsSettled(v, h.id) { return ErrPending } delete(v.Registrations, h.id) return nil }) return err } // SQLRecovery is a trusted console-local adapter. FenceSQL must commit a // durable fence in the exact original receipt database, joining SQLite's // writer lock before returning. A missing database is never an empty receipt. type SQLRecovery interface { FenceSQL(context.Context, SQLReceiptScope) error } // SQLOutcomeRecovery is a SQLRecovery that also reports whether the fence // found the original transaction's committed receipt. Recovery then records // sql-committed instead of sql-fenced. type SQLOutcomeRecovery interface { SQLRecovery FenceSQLOutcome(context.Context, SQLReceiptScope) (committed bool, err error) } type SQLReceiptScope struct { Database string `json:"database"` Account string `json:"account"` App string `json:"app"` AppID string `json:"app_id"` Operation string `json:"operation"` Effect string `json:"effect"` Epoch uint64 `json:"epoch"` Owner RegistrationOwner `json:"owner"` } // RecoverIngest joins/fences SQL first, releases the connection, then uses // the runner's one settlement implementation. It never infers death or // not-applied from lease age, a missing response, or a missing receipt. func (w *ControlWriter) RecoverIngest(ctx context.Context, account, app, appID, id string, local SQLRecovery) error { if local == nil { return ErrContract } a, err := w.read(ctx, account, app, appID) if err != nil { return err } reg, ok := a.Value.Registrations[id] if !ok { return nil } e, ok := a.Value.Effects[id] if reg.Kind != RegistrationIngest || !ok || e.OperationID != id || e.AppID != appID || e.Owner != reg.Owner || e.Action != sqlIngestAction || e.Inputs["sql_database"] == "" { return ErrIdentity } h := &StartHandle{w: w, account: account, app: app, appID: appID, id: id, owner: reg.Owner, epoch: reg.Epoch, kind: RegistrationIngest} if e.State == "complete" { return w.finishIngest(ctx, h) } if e.State != "prepared" && e.State != "issued" && e.State != "uncertain" { return ErrPending } if err = w.RecoverSQLTransaction(ctx, account, app, appID, e.ID, local); err != nil { return err } return w.finishIngest(ctx, h) } // RecoverSQLTransaction proves the one SQL child's terminal outcome without // settling its parent (restore, placement, purge or non-expiring mail). func (w *ControlWriter) RecoverSQLTransaction(ctx context.Context, account, app, appID, id string, local SQLRecovery) error { if local == nil { return ErrContract } a, err := w.read(ctx, account, app, appID) if err != nil { return err } e, ok := a.Value.Effects[id] if !ok || e.AppID != appID || e.Action != sqlIngestAction || e.Resource != "app-sql:"+id || e.Inputs["sql_database"] == "" || !validRegistrationOwner(e.Owner) { return ErrIdentity } if e.State == "complete" { return nil } exclusive := a.Value.Op != nil && a.Value.Op.ID == e.OperationID if !exclusive { reg, ok := a.Value.Registrations[e.OperationID] if !ok || reg.Owner != e.Owner || reg.AppID != appID || reg.Epoch != a.Value.Epoch { return ErrPending } } if e.State != "prepared" && e.State != "issued" && e.State != "uncertain" { return ErrPending } sc := SQLReceiptScope{Database: e.Inputs["sql_database"], Account: account, App: app, AppID: appID, Operation: e.OperationID, Effect: e.ID, Epoch: a.Value.Epoch, Owner: e.Owner} result := "sql-fenced" if o, ok := local.(SQLOutcomeRecovery); ok { committed, ferr := o.FenceSQLOutcome(ctx, sc) if ferr != nil { return ferr } if committed { result = "sql-committed" } } else if err = local.FenceSQL(ctx, sc); err != nil { return err } r := EffectRunner{w: w, account: account, app: app, appID: appID, operation: e.OperationID, owner: e.Owner, now: w.now, exclusive: exclusive} _, err = r.saveSettlement(ctx, e.ID, "", "complete", result) return err } func (w *ControlWriter) sqlChildrenSettled(ctx context.Context, account, app, appID, operation string) error { a, err := w.read(ctx, account, app, appID) if err != nil { return err } for _, e := range a.Value.Effects { if e.OperationID == operation && e.Action == sqlIngestAction && e.State != "complete" { return ErrPending } } return nil } func validIngestRegistration(id string, r Registration, appID string, epoch uint64) bool { return id != "" && r.Kind == RegistrationIngest && r.Host == "" && r.Claim == (SavedClaim{}) && r.AppID == appID && r.Epoch == epoch && validRegistrationOwner(r.Owner) && r.Owner.Console != "" && !r.Expires.IsZero() && r.Incarnation == "" && r.CreatedAt.IsZero() }