Repository navigation
Expand file tree
/
Copy pathapply.go
More file actions
2084 lines (1983 loc) · 102 KB
/
Copy pathapply.go
File metadata and controls
2084 lines (1983 loc) · 102 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package postgres
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"slices"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/block/pg-sprite/pkg/dbconn"
"github.com/block/pg-sprite/pkg/executor"
"github.com/block/pg-sprite/pkg/preflight"
"github.com/block/pg-sprite/pkg/progress"
pgstatement "github.com/block/pg-sprite/pkg/statement"
"github.com/block/schemabot/pkg/apitypes"
"github.com/block/schemabot/pkg/ddl"
"github.com/block/schemabot/pkg/engine"
"github.com/block/schemabot/pkg/schema"
)
const (
optimisticLockTimeout = 3 * time.Second
optimisticStatementLimit = 30 * time.Second
// optimisticApplyCeiling bounds one whole background apply client-side:
// pool dial, preflight, and every execution attempt with its backoff.
// Server-side statement/lock timeouts bound queries once a session is
// healthy, but they cannot unwedge a hung dial or a black-holed
// connection — the ceiling guarantees the drive always reaches a
// terminal progress state. Generously above the worst legitimate run of
// the retry path (retry attempts x statement limit plus backoffs) so it
// only fires on genuine hangs. A concurrent index build does not run
// under this constant: its ceiling is the configured build bound plus
// concurrentIndexHeadroom. The constructor seeds the engine's field of
// the same name from it; the apply reads the field.
optimisticApplyCeiling = 5 * time.Minute
// executorProgressReadTimeout bounds one read of the executor's tracker.
// For an active concurrent index build that read is a single-row query
// on the session the executor reserved for the build's failure verdict,
// and the tracker serializes it against the executor's own end-of-build
// fence — so a poll that hangs on a half-open socket would hold up the
// apply's verdict and its terminal publish. A deadline of the engine's
// own keeps that coupling bounded regardless of what the caller's
// context does.
//
// The bound sits above the statement_timeout the apply pool runs its
// sessions under (spritePoolConfig leaves it at pg-sprite's default) so
// that on a live connection the server ends a slow read first: a
// statement_timeout cancels the query and hands the session back
// intact, whereas a client deadline expiring mid-query closes the
// connection — and that connection is the one the build's failure
// verdict needs. The engine's deadline is therefore the bound of last
// resort, for the socket the server's cancellation can no longer reach.
executorProgressReadTimeout = dbconn.DefaultStatementTimeout + executorProgressReadHeadroom
// executorProgressReadHeadroom is how far the read deadline sits past
// the session's statement_timeout: long enough that the server's
// cancellation error reaches the client before the client gives up on
// the socket.
executorProgressReadHeadroom = 5 * time.Second
// concurrentIndexHeadroom is how far a concurrent index apply's ceiling
// sits above the configured build bound. The ceiling wraps the whole
// apply while the build bound — the build's server-side statement
// timeout, or the recovery's deadline — starts only when the build
// starts, so the gap is the time granted to everything executeOptimistic
// runs before the build: the pool dial, the privilege check, the
// preflight table read and the partition admission facts lookup. As long
// as that setup finishes within the headroom, the bound is what ends an
// over-long build — the server's timer fires first and exhaustion
// surfaces as the typed budget verdict — and the build gets the full
// bound the operator configured; setup that outruns the headroom shortens
// the build by the excess, because the ceiling then fires first and the
// outcome degrades to a cancellation. Nothing after the build
// charges the gap: pg-sprite runs its post-failure catalog verdict on
// a detached context of its own, the cleanup of an operator-cancelled
// build runs detached under cancelledIndexCleanupBound, and the terminal
// publish reads the tracker the same way. The bound is sized to cover
// setup even when every one of its steps runs to the server-side limit
// the pool sets for it — the connect timeout for the dial, the
// statement_timeout for each preflight read.
concurrentIndexHeadroom = 2 * time.Minute
// cancelledIndexCleanupBound is the whole budget for removing the
// invalid index an operator-cancelled build left behind. The cleanup
// runs after the apply context is cancelled and before the terminal
// outcome is published, so Cancel's settle wait spans it: the bound
// must stay well inside cancelSettleTimeout, or a slow cleanup would
// turn a cancel that is about to succeed into a not-settled answer.
// The work it covers is a session dial, catalog reads, and the
// abandonment proof and drop, each of which pg-sprite runs under its
// own short lock timeout, so the bound is seconds; when it is spent the
// entry stays for the invalid-index recovery guidance and the apply
// still settles cancelled.
cancelledIndexCleanupBound = 30 * time.Second
)
type nativeApply struct {
rowSecurity *pgstatement.DesiredWithRowSecurity
reviewedSecurity []string
namespace string
table string
sql string
steps int
concurrentIndex bool
concurrentIndexMaxDuration time.Duration
cancelRequested func() bool
}
// targetConn carries one background apply's connection inputs: the raw DSN
// (normalized at open time) and the CA bundle path the pool verifies the
// target against — empty when the embedded RDS trust or the DSN's own
// settings apply.
type targetConn struct {
dsn string
caCertPath string
}
// Apply starts one native-safe PostgreSQL change under pg-sprite's bounded
// optimistic executor. A greenfield table and its declared indexes are
// admitted as one create-set task whose sequence steps commit independently.
func (e *Engine) Apply(ctx context.Context, req *engine.ApplyRequest) (*engine.ApplyResult, error) {
change, err := validateOptimisticApply(req)
if err != nil {
return nil, err
}
// Resolve the CA reference at acceptance so a trust root the engine
// cannot honor refuses the apply before any background work is queued.
caPath, err := caCertPath(req.Credentials)
if err != nil {
return nil, fmt.Errorf("apply PostgreSQL database %q: %w", req.Database, err)
}
// The bundle itself is held to the same bar: an unreadable or
// certificate-free bundle can never dial, so it refuses the apply here
// rather than failing the background drive. This proves the reference is
// honorable at acceptance; the pool re-reads the path when it dials.
if caPath != "" {
if _, err := loadCABundle(caPath); err != nil {
return nil, fmt.Errorf("apply PostgreSQL database %q: %w", req.Database, err)
}
}
// One tracker per apply: pg-sprite's executor records each step it
// starts on it, and Progress reads the position back, so the poller
// sees the step in flight rather than only the accept and terminal
// states the engine itself publishes.
tracker, err := progress.NewTracker(progress.WallClock{})
if err != nil {
return nil, fmt.Errorf("apply PostgreSQL database %q: %w", req.Database, err)
}
logger := req.Logger
if logger == nil {
logger = slog.Default()
}
started := time.Now()
key := progressIdentity(req.ResumeState)
// An accepted apply must survive the request that accepted it, so the
// drive's context is detached from the caller. It is cancellable so the
// engine itself can end the drive — a cancel of last resort, and the
// halt on shutdown.
applyCtx, cancelApply := context.WithCancel(context.WithoutCancel(ctx))
done := e.claimProgress(key, progressResult(engine.StateRunning, "preflight", started, change, ""), tracker, logger, change.concurrentIndex, cancelApply, engine.WorkOwnerFromContext(ctx))
conn := targetConn{dsn: req.Credentials.DSN, caCertPath: caPath}
e.wg.Go(func() {
defer close(done)
defer cancelApply()
e.runOptimisticApply(applyCtx, conn, change, key, started, logger, tracker)
})
return &engine.ApplyResult{
Accepted: true,
Message: "Started native-safe PostgreSQL schema change",
ResumeState: req.ResumeState,
}, nil
}
// validateOptimisticApply validates only engine-level applicability. Blocked
// verdicts are enforced at admission by storage.Plan.BlockedApplyError before
// tasks are created; task rows are rebuilt from stored plans and do not carry
// the plan's ExecutionMode verdict.
func validateOptimisticApply(req *engine.ApplyRequest) (nativeApply, error) {
if req == nil {
return nativeApply{}, fmt.Errorf("apply PostgreSQL schema: request is required")
}
if req.Credentials == nil || req.Credentials.DSN == "" {
return nativeApply{}, fmt.Errorf("apply PostgreSQL database %q: DSN credentials are required", req.Database)
}
if len(req.Changes) != 1 || len(req.Changes[0].TableChanges) != 1 {
return nativeApply{}, fmt.Errorf("apply PostgreSQL database %q: native-safe increment requires exactly one planned change", req.Database)
}
tc := req.Changes[0].TableChanges[0]
if operation, err := pgstatement.ParseRowSecurityChange(tc.DDL); err == nil {
return validateRowSecurityApply(req, operation)
}
hasRLS, err := pgstatement.HasRowSecurityDeclaration(tc.DDL)
if err != nil {
slog.Warn("PostgreSQL apply admission rejected planned DDL", "database", req.Database, "table", tc.Table, "error", err)
return nativeApply{}, fmt.Errorf("apply PostgreSQL table %q: planned DDL is not one statement or a valid greenfield create set", tc.Table)
}
if hasRLS {
return nativeApply{}, fmt.Errorf("apply PostgreSQL table %q: row security changes require one supported atomic operation for a single qualified table", tc.Table)
}
if req.Options["defer_cutover"] == "true" {
return nativeApply{}, fmt.Errorf("apply PostgreSQL table %q: deferred cutover is unsupported", tc.Table)
}
// The same tier derivation runs again inside the background apply; this
// synchronous check exists so a statement shape the native-safe path
// cannot execute is refused at acceptance, before any work is queued.
statements, err := postgresCreateSetStatements(tc.DDL)
if err != nil {
slog.Warn("PostgreSQL apply admission rejected planned DDL", "database", req.Database, "table", tc.Table, "error", err)
return nativeApply{}, fmt.Errorf("apply PostgreSQL table %q: planned DDL is not one statement or a valid greenfield create set", tc.Table)
}
if _, err := preflight.RequiredTier(statements); err != nil {
return nativeApply{}, fmt.Errorf("apply PostgreSQL table %q: SchemaBot's PostgreSQL support does not execute this statement shape yet", tc.Table)
}
if err := plannedSchemaMatchesTarget(statements, req.Changes[0].Namespace); err != nil {
return nativeApply{}, fmt.Errorf("apply PostgreSQL table %q: %w", tc.Table, err)
}
concurrentIndex := false
if len(statements) == 1 {
// The tier derivation above parsed this statement already, so a
// parse failure here is an invariant guard; the message stays curated
// like the refusals above because it reaches the operator surface.
concurrentIndex, err = concurrentIndexStatement(statements[0])
if err != nil {
return nativeApply{}, fmt.Errorf("apply PostgreSQL table %q: planned statement could not be classified", tc.Table)
}
}
return nativeApply{namespace: req.Changes[0].Namespace, table: tc.Table, sql: tc.DDL, steps: len(statements), concurrentIndex: concurrentIndex}, nil
}
// plannedSchemaMatchesTarget refuses an apply whose planned DDL names a
// different schema than the one the request addresses. The plan qualified
// every statement with the schema it was made against and the apply preflights
// the request's namespace, so the two agree unless the route to the physical
// schema changed after planning; executing then would preflight one schema and
// alter another. An unqualified statement carries no claim to check.
func plannedSchemaMatchesTarget(statements []string, namespace string) error {
for i, sql := range statements {
statement, err := pgstatement.ParseOne(sql)
if err != nil {
return fmt.Errorf("parse planned statement %d: %w", i+1, err)
}
if planned := statement.Schema(); planned != "" && planned != namespace {
return fmt.Errorf("planned statement %d names schema %q but the apply targets schema %q; the target's schema route changed after planning, so plan again", i+1, planned, namespace)
}
}
return nil
}
// postgresCreateSetStatements parses one statement or a greenfield create set
// using the PostgreSQL parser and returns its canonical execution sequence.
func postgresCreateSetStatements(script string) ([]string, error) {
parser, err := ddl.ParserForDialect(schema.DialectPostgres)
if err != nil {
return nil, fmt.Errorf("select PostgreSQL statement parser: %w", err)
}
statements, err := ddl.CreateSetStatements(parser, script)
if err != nil {
return nil, fmt.Errorf("admit PostgreSQL DDL script: %w", err)
}
return statements, nil
}
func (e *Engine) runOptimisticApply(ctx context.Context, conn targetConn, change nativeApply, key string, started time.Time, logger *slog.Logger, tracker *progress.Tracker) {
// The context arrives detached from the caller (an accepted apply must
// survive the request) and ends only when the engine cancels the drive,
// so boundedness comes from the ceiling instead.
ceiling := e.optimisticApplyCeiling
if change.concurrentIndex {
ceiling = e.concurrentIndexMaxDuration + concurrentIndexHeadroom
change.concurrentIndexMaxDuration = e.concurrentIndexMaxDuration
change.cancelRequested = func() bool { return e.cancelRequested(key) }
}
ctx, cancel := context.WithTimeout(ctx, ceiling)
defer cancel()
// Every terminal result carries the executor's final position: the
// executor finishes its tracker before it returns, so the read is
// memory-only and a failed create reports the step that failed, not the
// sequence's first step. The error branch is an invariant guard, not an
// expected outcome — a tracker still reporting a live build here means
// the executor returned without finishing it, and the terminal result
// then carries the tracker's last-known position instead of a fresh
// read the reserved session can no longer answer.
publish := func(result *engine.ProgressResult) {
if err := executorProgressMetadata(ctx, tracker, result); err != nil {
logger.Warn("PostgreSQL apply terminal progress reports the last-known executor position",
"namespace", change.namespace, "table", change.table, "task_id", key, "error", err)
}
e.publishProgress(key, result, logger)
}
execute := e.execute
if execute == nil {
execute = func(ctx context.Context, conn targetConn, change nativeApply, tableSizeLimit int64, tracker *progress.Tracker, logger *slog.Logger) error {
return executeOptimistic(ctx, conn, change, tableSizeLimit, tracker, logger, e.tableOwner)
}
}
err := execute(ctx, conn, change, e.tableSizeLimit, tracker, logger)
if err == nil {
publish(progressResult(engine.StateCompleted, "completed", started, change, ""))
return
}
var invalidErr *executor.InvalidIndexError
if isCancellation(err) && e.cancelRequested(key) {
// The cancellation is the operator's only when this engine recorded
// that it acted on the apply: a backend cancelled from outside
// SchemaBot is a failure the next drive retries, never a cancel
// nobody asked for.
detail := cancelledIndexCleanupDetail(err)
if detail == "" && errors.As(err, &invalidErr) {
detail = invalidIndexDetail(invalidErr)
}
if detail == "" {
detail = "Concurrent index build cancelled"
}
publish(progressResult(engine.StateCancelled, "cancelled", started, change, detail))
return
}
if errors.As(err, &invalidErr) && !invalidErr.Code().Permanent() {
// An invalid index a retry recovers or an operator clears — a build's
// own leftover, abandoned debris, another backend's build to wait
// out, or a builder this role cannot observe — is operational: once
// it is gone a retry can succeed. Checked before the refusal and
// budget arms because the verdict wraps the build failure that
// produced it (a budget-cancelled build leaves its own invalid
// index), and that inner cause must not be read as the outcome — the
// index the retry acts on is. The permanent verdicts (the name is occupied
// on another table, or by an index the server will not drop
// concurrently) fall through to classifyRefusal: retrying unchanged
// reproduces them. The detail is built from the typed identifiers
// and verdict code, never the wrapped build or cleanup errors, which
// may carry raw server text; the full cause lands in the server log
// below it.
logger.Error("PostgreSQL concurrent index build left or found an invalid index",
"namespace", change.namespace, "table", change.table,
"index_schema", invalidErr.Schema, "index", invalidErr.Index, "error", err)
result := progressResult(engine.StateFailed, "failed", started, change,
invalidIndexDetail(invalidErr))
result.Retryable = true
publish(result)
return
}
if r := classifyRefusal(err, change.table); r != nil {
// The taxonomy's reason survives in the operator-facing detail; no
// metadata carries it because nothing downstream consumes one yet.
// The server error is what identifies the occupant or the missing
// grant, and only the log carries it: the detail is kept generic
// because it is published to GitHub.
logger.Warn("PostgreSQL schema change refused",
"namespace", change.namespace, "table", change.table, "reason", r.reason, "error", err)
publish(progressResult(engine.StateFailed, "refused", started, change, recoveryContextDetail(err, r.detail)))
return
}
failure := classifyApplyFailure(err, change.table)
failure.detail = recoveryContextDetail(err, failure.detail)
switch {
case failure.committedPrefix:
logger.Error("PostgreSQL create set failed after committing a prefix",
"namespace", change.namespace, "table", change.table, "error", err)
case failure.lockBudget:
logger.Warn("PostgreSQL native-safe schema change lost its lock budget; the drive will retry",
"namespace", change.namespace, "table", change.table, "error", err)
default:
logger.Error("PostgreSQL native-safe schema change failed", "namespace", change.namespace, "table", change.table, "error", err)
}
result := progressResult(engine.StateFailed, "failed", started, change, failure.detail)
result.Retryable = failure.retryable
publish(result)
}
// applyFailure is the drive-facing disposition of an operational apply
// failure: the operator detail and whether a retry can succeed. Refusals never
// reach it — classifyRefusal consumes them first.
type applyFailure struct {
detail string
retryable bool
committedPrefix bool
lockBudget bool
}
// classifyApplyFailure decides retryability from the failure's shape, in the
// order the shapes nest: a create sequence that failed after its CREATE TABLE
// committed is never retryable, even when the failing step lost a lock budget
// that would otherwise be retryable — the table exists, so a retry can only
// collide. classifyRefusal already consumed the statement-budget cause, so a
// budget exceeded here is the lock budget: the statement is native-safe and
// merely lost a bounded race with concurrent lock holders, and marking it
// blocked would falsely tell the operator retrying cannot succeed.
func classifyApplyFailure(err error, table string) applyFailure {
if detail, committed := committedCreatePrefixDetail(err, table); committed {
return applyFailure{detail: detail, committedPrefix: true}
}
var budgetErr *executor.BudgetError
if errors.As(err, &budgetErr) {
return applyFailure{
detail: budgetErr.Error() + "; retry once lock contention subsides",
retryable: true,
lockBudget: true,
}
}
return applyFailure{detail: "PostgreSQL schema change failed; see server logs", retryable: true}
}
// invalidIndexAdvice renders the operator-facing cause and next step for an
// invalid-index verdict, matching pg-sprite's own ownership standard: a
// removal is named only where the recovery proves the entry is a failed
// build's debris on the target table before it drops — this build's own
// leftover, an abandoned entry with no builder, or an entry whose builder
// this role cannot see, which the recovery settles under the table's lock —
// and there the retry performs it, so the operator's step is to let the
// retry run. A build still in flight says wait; an entry on another table,
// one the server will not drop concurrently, and an unproven verdict get
// investigation steps, never a statement to run — the index under the name
// may be healthy or may be exactly what it is meant to be. Only the typed
// identifiers are interpolated, never the wrapped build or cleanup errors,
// which may carry raw server text, and they are rendered inside the room the
// narrowest operator surface leaves them once the prose, the step clause the
// detail will carry, and the remedy's lead have taken theirs, so the lead
// survives that surface for identifiers of any legal length and spelling.
// The two halves leave unsanitized so classifyRefusal can compose a
// sequence-step clause between them and sanitize the whole; the operational
// path composes them through invalidIndexDetail.
func invalidIndexAdvice(invalidErr *executor.InvalidIndexError, stepClause string) (cause, remedy string) {
w := invalidIndexWordingFor(invalidErr)
names := invalidIndexNames(invalidErr, w.namesTable, w.namesRoom(stepClause))
return w.causeBefore + names + w.causeAfter, w.lead + w.remedyRest
}
// invalidIndexWording is the operator-facing prose of one invalid-index
// verdict: the cause on either side of the identifiers it names, and the
// remedy split at its lead — the first clause, the verb and its object,
// which the narrowest operator surface must still show once the detail is
// cut from the tail. Keeping the lead apart is what lets the identifiers be
// given the room that surface leaves rather than a guess at it.
type invalidIndexWording struct {
causeBefore, causeAfter string
// namesTable marks a cause that names the table the entry sits on,
// beside the index name, because that table is where the operator acts.
namesTable bool
lead string
remedyRest string
}
// namesRoom is the byte room the wording leaves the identifiers inside the
// narrowest operator surface's clamp: what remains of the kept width once
// the cause's prose, the step clause the detail will carry with its
// separator, and the remedy's lead with the separator before it have taken
// theirs.
func (w invalidIndexWording) namesRoom(stepClause string) int {
room := apitypes.StatusFailureReasonKeptWidth -
len(w.causeBefore) - len(w.causeAfter) - len(clauseSeparator) - len(w.lead)
if stepClause != "" {
room -= len(clauseSeparator) + len(stepClause)
}
return room
}
// invalidIndexRetryRemovesRemedy is the remedy for the verdicts whose
// entry the retry itself removes and rebuilds.
const (
invalidIndexRetryRemovesLead = "the retry removes it"
invalidIndexRetryRemovesRemedy = " and rebuilds the index; drop it yourself only if the retries are exhausted"
)
func invalidIndexWordingFor(invalidErr *executor.InvalidIndexError) invalidIndexWording {
switch invalidErr.Code() {
case executor.CodeInvalidIndexOwnLeftover:
// A leftover the bound's statement timeout produced says so: the
// retry rebuilds under the same bound, so the operator's step is to
// raise it, and the budget the verdict carries is that bound.
var budgetErr *executor.BudgetError
if errors.As(invalidErr.Build, &budgetErr) && budgetErr.Cause == executor.CauseStatement {
return invalidIndexWording{
causeBefore: "this build left its own invalid index ",
causeAfter: fmt.Sprintf(" on the target after running past %s (%s)", concurrentIndexBoundOption, budgetErr.Budget),
lead: invalidIndexRetryRemovesLead,
remedyRest: fmt.Sprintf(" and rebuilds the index under the same bound, so raise %s first; drop it yourself only if the retries are exhausted",
concurrentIndexBoundOption),
}
}
return invalidIndexWording{
causeBefore: "this build left its own invalid index ",
causeAfter: " on the target",
lead: invalidIndexRetryRemovesLead,
remedyRest: invalidIndexRetryRemovesRemedy,
}
case executor.CodeInvalidIndexAbandoned:
return invalidIndexWording{
causeBefore: "an abandoned invalid index ",
causeAfter: " occupies the name on the target table with no backend building it",
lead: invalidIndexRetryRemovesLead,
remedyRest: invalidIndexRetryRemovesRemedy,
}
case executor.CodeInvalidIndexBuildInFlight:
return invalidIndexWording{
causeBefore: "an invalid index ",
causeAfter: fmt.Sprintf(" occupies the name and backend %d is still building it", invalidErr.BuilderPID),
lead: "wait for that build to finish",
remedyRest: " or fail, then retry",
}
case executor.CodeInvalidIndexBuilderUnobservable:
return invalidIndexWording{
causeBefore: "an invalid index ",
causeAfter: " occupies the name on the target table with a builder the engine role cannot observe",
lead: "the retry proves it abandoned under the table's lock",
remedyRest: ", removes it, and rebuilds the index; a build still holding the table stops that retry at its lock budget instead",
}
case executor.CodeInvalidIndexOtherTable:
return invalidIndexWording{
causeBefore: "an invalid index ",
causeAfter: " already occupies the name on a different table",
namesTable: invalidErr.Table != "",
lead: "rename the index in the schema file",
remedyRest: " and re-plan, or clear the entry through that table's own change; this change cannot claim it",
}
case executor.CodeInvalidIndexNotDroppable:
// The kinds of index the server will not drop concurrently are
// named after the lead, not in the cause: they are why an operator
// is needed, and the cause has to leave the lead its room.
return invalidIndexWording{
causeBefore: "an invalid index ",
causeAfter: " occupies the name and is not a failed build's leftover",
lead: "an operator must resolve it on the target",
remedyRest: ", since it is a partitioned table's index, an index partition, or a constraint's index, or rename the index in the schema file and re-plan",
}
default:
// CodeInvalidIndexUnproven and any future verdict fail safe with
// investigation steps: the index under the name may be healthy.
return invalidIndexWording{
causeBefore: "index ",
causeAfter: " may be invalid but its catalog state could not be verified",
lead: "inspect pg_index.indisvalid on the target before any recovery",
remedyRest: ", then retry",
}
}
}
// invalidIndexNames renders the identifiers an invalid-index cause carries —
// the index name qualified by its schema and, when the cause names it, the
// table the entry sits on — quoted inside one byte room. When the quoted
// forms do not all fit, the index name is cut last: it is the name the
// remedy tells the operator to rename or resolve. The table is cut next and
// the schema first, each keeping at least its cut mark, so the cause always
// shows where a name stood. A cut costs the operator little — the server
// log beside the failure carries the identifiers whole.
func invalidIndexNames(invalidErr *executor.InvalidIndexError, withTable bool, room int) string {
const dot, openParen, closeParen = ".", " (", ")"
schema, index := strconv.Quote(invalidErr.Schema), strconv.Quote(invalidErr.Index)
table := ""
if withTable {
table = openParen + strconv.Quote(invalidErr.Table) + closeParen
}
if len(schema)+len(dot)+len(index)+len(table) <= room {
return schema + dot + index + table
}
reserved := len(dot) + quotedCutWidth
if withTable {
reserved += len(openParen) + quotedCutWidth + len(closeParen)
}
index = boundedQuote(invalidErr.Index, room-reserved)
if withTable {
table = openParen + boundedQuote(invalidErr.Table, room-len(index)-len(dot)-quotedCutWidth-len(openParen)-len(closeParen)) + closeParen
}
schema = boundedQuote(invalidErr.Schema, room-len(index)-len(dot)-len(table))
return schema + dot + index + table
}
// invalidIndexDetail composes the advice for the operational (retryable)
// publish path, where no sequence-step clause intervenes.
func invalidIndexDetail(invalidErr *executor.InvalidIndexError) string {
cause, remedy := invalidIndexAdvice(invalidErr, "")
return sanitizeReasonText(cause + clauseSeparator + remedy)
}
// refusal is a typed apply outcome that retrying cannot fix: the schema
// change, the target table, or role provisioning must change first.
//
// cause names what failed and remedy names what the operator changes; they
// are kept apart so the failed sequence step can be placed between them
// without parsing the rendered text. detail is the operator-facing line
// classifyRefusal composes from them — the only field consumers read.
//
// provisionable marks a refusal whose remedy is something the operator adds
// to the target — a grant, a role, a schema — with the plan left as it is.
// The plan-time gates use it to decide whether a refusal earns a place beside
// a verdict another gate already gave: a provisionable one names work the
// operator will have to do anyway, while a refusal about the table itself
// (vanished, not a table) names nothing to provision and only a re-plan can
// answer it. It is set by the arms that know their remedy is provisioning
// and stays false for every other refusal, so a new refusal defaults to the
// conservative side.
type refusal struct {
reason string
cause string
remedy string
detail string
provisionable bool
}
// classifyRefusal maps pg-sprite's typed refusal inputs to permanent
// refusals, for both the plan-time privilege check and the apply path — one
// classifier so the same underlying failure carries the same reason and
// detail on both surfaces; the plan surface prefixes the detail with the
// statement it blocks, the apply surface publishes it bare. A nil result
// means the failure is operational — a retry may succeed once conditions
// change. Lock-budget exhaustion is deliberately
// operational: the statement is native-safe and only lost a bounded race
// with concurrent lock holders. Every cause and remedy is built from typed
// error fields and identifiers, never from wrapped server output, and the
// composed detail is sanitized at this single exit — a refusal is safe to
// render on operator-facing surfaces by construction, whichever branch
// produced it.
//
// The detail reads cause, then the failed sequence step when the statement
// was one of several, then the remedy, so the remedy stays the last clause
// the operator reads. A failure past the first step leaves the CREATE TABLE
// committed; a refusal that carries no remedy of its own then gets a re-plan,
// because the plan that produced the sequence no longer matches the target.
func classifyRefusal(err error, table string) *refusal {
r := refusalForCause(err, table)
if r == nil {
return nil
}
r.compose(err)
return r
}
// compose sets the detail from the cause, the failed step of a create set
// when there is one, and the remedy, joined by the clause separator. A
// refusal without a remedy of its own that failed past the committed CREATE
// TABLE is told to re-plan, because the plan that produced the sequence no
// longer matches the target.
func (r *refusal) compose(err error) {
clauses := []string{r.cause}
if clause := sequenceStepClause(err); clause != "" {
clauses = append(clauses, clause)
}
remedy := r.remedy
var stepErr *executor.SequenceStepError
if remedy == "" && errors.As(err, &stepErr) && stepErr.Step > 1 {
remedy = replanRemedy
}
if remedy != "" {
clauses = append(clauses, remedy)
}
r.detail = sanitizeReasonText(strings.Join(clauses, clauseSeparator))
}
const (
replanRemedy = "re-plan against the current schema"
clauseSeparator = "; "
)
// sequenceStepClause names the failed step of a multi-statement create set
// and, past the first step, that the CREATE TABLE committed; a statement that
// was not one of several gets no clause. It sits between a cause and a remedy
// and does not repeat the table: the cause before it names the table where
// that matters, and every surface renders the detail beside the table it
// belongs to. Keeping the clause short is what lets the remedy's lead survive
// the narrowest operator surface, which truncates the detail from the tail,
// for a table name of any legal length; a cause that spends byte room on
// identifiers charges the clause's width before the identifiers take theirs.
func sequenceStepClause(err error) string {
var stepErr *executor.SequenceStepError
if !errors.As(err, &stepErr) || stepErr.Total <= 1 {
return ""
}
clause := fmt.Sprintf("step %d of %d failed", stepErr.Step, stepErr.Total)
if stepErr.Step > 1 {
clause += " after the CREATE TABLE committed"
}
return clause
}
// committedCreatePrefixDetail reports the non-retryable recovery action for a
// create sequence that failed after at least one earlier step committed. No
// cause precedes this detail, so it names the table itself.
func committedCreatePrefixDetail(err error, table string) (string, bool) {
var stepErr *executor.SequenceStepError
if !errors.As(err, &stepErr) || stepErr.Step <= 1 {
return "", false
}
detail := fmt.Sprintf("step %d of %d failed after the CREATE TABLE for %s committed; %s",
stepErr.Step, stepErr.Total, quotedTable(table), replanRemedy)
return sanitizeReasonText(detail), true
}
// refusalForCause holds classifyRefusal's cause-to-refusal mapping; causes
// and remedies leave unsanitized and classifyRefusal sanitizes the composed
// detail at its return.
func refusalForCause(err error, table string) *refusal {
var privilegeErr *preflight.PrivilegeError
if errors.As(err, &privilegeErr) {
object := "on table " + quotedTable(table)
if privilegeErr.Tier == preflight.TierCreateTable {
// The create tier's grant is schema-scoped: the table does not
// exist yet, so pointing the operator at a table-level grant
// would send them hunting for an object the target lacks.
object = "in the schema that would hold table " + quotedTable(table)
}
remedy := fmt.Sprintf("provision with: %s (verified by: %s)", privilegeErr.Grant, privilegeErr.Check)
if privilegeErr.Hint != "" {
remedy += "; " + privilegeErr.Hint
}
return &refusal{reason: "insufficient-privileges",
cause: fmt.Sprintf("the engine role lacks access for %s %s", privilegeErr.Tier, object),
remedy: remedy,
provisionable: true}
}
// An invalid-index verdict is decided by its own code, never by the build
// failure it wraps — a budget-cancelled concurrent build leaves its own
// invalid index, and the index a retry recovers or an operator clears is
// the outcome, not the inner budget exhaustion. Decided before the budget arm so the
// nested cause can never shadow the verdict. Only the permanent members
// of the family refuse: the name is occupied on another table, or by an
// index the server will not drop concurrently, so retrying unchanged
// reproduces the verdict. Every other member is operational.
var invalidErr *executor.InvalidIndexError
if errors.As(err, &invalidErr) {
r, _ := refusalForOutcome(invalidErr.Code(), table)
if r != nil {
r.cause, r.remedy = invalidIndexAdvice(invalidErr, sequenceStepClause(err))
}
return r
}
// A concurrent build that ran past its bound and provably left nothing
// is refused like any other statement exhaustion — retrying unchanged
// spends the same bound again — but names the option that grants more
// time, since the bound is the operator's to set. The wrapper is decided
// on its own: the bound also ends the executor's catalog reads ahead of
// the build, which carry no typed budget verdict.
var boundErr *concurrentIndexBoundError
if errors.As(err, &boundErr) {
return &refusal{reason: "concurrent-index-bound-exceeded", cause: boundErr.Error(),
remedy: fmt.Sprintf("raise %s and re-run", concurrentIndexBoundOption)}
}
var budgetErr *executor.BudgetError
if errors.As(err, &budgetErr) && budgetErr.Cause == executor.CauseStatement {
return &refusal{reason: "not-native-safe-budget-exceeded", cause: budgetErr.Error()}
}
var partitionErr *preflight.UnsupportedPartitionedParentError
if errors.As(err, &partitionErr) {
// The typed error's message is a fixed English sentence with no
// interpolated identifiers or server text — a deliberate pg-sprite
// property — so rendering it verbatim is safe by construction.
return &refusal{reason: "unsupported-partitioned-parent", cause: partitionErr.Error()}
}
var sizeErr *preflight.SizeError
if errors.As(err, &sizeErr) {
// Name whose limit this is: read cold, the size text sounds like a
// property of PostgreSQL or of the change, when it is SchemaBot's
// own conservatism for the native-safe path.
return &refusal{reason: "table-too-large",
cause: sizeErr.Error() + "; this threshold is SchemaBot's ceiling for a native-safe apply, not a PostgreSQL limit"}
}
// Decided before the bare table sentinels below: the unverified-create
// wrap carries the read-back's own cause inside it, and a table no longer
// at its name or no longer a table are two of those causes. The outcome
// is the state the step left — a committed table whose names are
// unproven — not the fault that kept them from being proven, so the
// inner sentinel must not claim the verdict.
if errors.Is(err, executor.ErrCreateNamesUnverified) {
return createNamesUnverifiedRefusal(table)
}
if errors.Is(err, executor.ErrCreateOwnerUnverified) {
return createOwnerUnverifiedRefusal(table)
}
if errors.Is(err, preflight.ErrTableNotFound) {
return tableNotFoundRefusal(table)
}
if errors.Is(err, preflight.ErrNotTable) {
return &refusal{reason: "not-a-table",
cause: quotedTable(table) + " exists but is not an ordinary or partitioned table"}
}
if preflight.IsNameOccupied(err) {
return createCollisionRefusal(table)
}
var mismatchErr *executor.CreateNameMismatchError
if errors.As(err, &mismatchErr) {
return createNameMismatchRefusal(createNameMismatchCause(table, mismatchErr, sequenceStepClause(err)))
}
var ownerMismatchErr *executor.CreateOwnerMismatchError
if errors.As(err, &ownerMismatchErr) {
return createOwnerMismatchRefusal(table, ownerMismatchErr)
}
if errors.Is(err, preflight.ErrCreateOwnerNotFound) {
// The owner is target configuration and the server has no such
// role: the same environmental class as a missing grant, decided
// before anything runs, but with no grantee for a GRANT to name.
return &refusal{reason: "insufficient-privileges",
cause: fmt.Sprintf("the configured table owner for %s is not a role on the target", quotedTable(table)),
remedy: "create the role or correct the target's table_owner, then re-plan",
provisionable: true}
}
if errors.Is(err, preflight.ErrSchemaNotFound) {
return &refusal{reason: "schema-not-found",
cause: fmt.Sprintf("the schema that would hold table %s does not exist on the target", quotedTable(table)),
remedy: "create the schema first",
provisionable: true}
}
r, _ := refusalForOutcome(executor.OutcomeCode(err), table)
return r
}
// refusalForOutcome maps pg-sprite's stable executor outcome vocabulary to
// apply dispositions: a refusal when retrying the identical plan cannot
// succeed, nil when the outcome is operational and a retry may. The switch
// is total over executor.Codes() — the exhaustiveness test pins every code
// to an explicit decision, so a new pg-sprite outcome surfaces as a failing
// test here instead of silently falling through to the retryable tail. The
// boolean reports whether the code is a known member of the vocabulary.
func refusalForOutcome(code executor.Code, table string) (*refusal, bool) {
switch code {
case executor.CodeCreateCollision:
return createCollisionRefusal(table), true
case executor.CodeCreateNameMismatch:
// The code is only ever carried by the typed error that names the
// relations involved, and refusalForCause decides on that error
// before control reaches here; this arm keeps the switch total and
// gives the bare code the same verdict, with a cause that knows only
// that a name differs.
return createNameMismatchRefusal(
fmt.Sprintf(mismatchCauseFormat, quotedTable(table), "a name")), true
case executor.CodeCreateNamesUnverified:
// The sentinel is present whenever this code is, so refusalForCause
// decides the verdict before control reaches here; this arm keeps
// the switch total and gives the bare code the same verdict.
return createNamesUnverifiedRefusal(table), true
case executor.CodeCreateOwnerMismatch:
// Like the name mismatch, the code is only ever carried by the typed
// error naming both roles, which refusalForCause decides on first;
// this arm gives the bare code the same verdict with a cause that
// knows only that the owner differs.
return createOwnerMismatchRefusal(table, nil), true
case executor.CodeCreateOwnerUnverified:
// The sentinel is present whenever this code is, so refusalForCause
// decides the verdict before control reaches here; this arm keeps
// the switch total and gives the bare code the same verdict.
return createOwnerUnverifiedRefusal(table), true
case executor.CodeDuplicateCreateName:
// The cause says only what the set did; the remedy names the
// relation kinds that can repeat a name — a CREATE INDEX name that
// is also the table's implicit constraint-index name, or another
// index's — so the cause stays short enough for the remedy to
// survive the narrowest surface beside a table name of any length.
return &refusal{reason: "duplicate-create-name",
cause: fmt.Sprintf("the create set for %s claims the same relation name twice", quotedTable(table)),
remedy: "rename the index or constraint that repeats it in the schema file, then " + replanRemedy}, true
case executor.CodePartitionOfUnsupported:
return &refusal{reason: "unsupported-create-step",
cause: fmt.Sprintf("the CREATE TABLE for %s attaches a partition to a live parent, which the native-safe create path does not run", quotedTable(table))}, true
case executor.CodeIfNotExistsUnsupported:
return &refusal{reason: "unsupported-create-step",
cause: fmt.Sprintf("the planned statement for %s carries IF NOT EXISTS, whose no-op outcome the native-safe path cannot prove", quotedTable(table)),
remedy: "drop the clause and re-plan"}, true
case executor.CodeUnsupportedCreateStep:
return &refusal{reason: "unsupported-create-step",
cause: fmt.Sprintf("the CREATE TABLE for %s is not a shape the native-safe create path can run", quotedTable(table)),
remedy: "rewrite the schema file and re-plan"}, true
case executor.CodeTableNotFound:
return tableNotFoundRefusal(table), true
case executor.CodeEmptySequence, executor.CodeUnsupportedSequenceStep,
executor.CodeUnsupportedPartitionedParent, executor.CodeNotConcurrentIndexBuild,
executor.CodeUnnamedIndex, executor.CodeUnqualifiedTable,
executor.CodeUnsupportedAcceptedBlocking:
// Shape refusals: the executor refused the statement's form at
// admission, so retrying the identical plan refails the same way.
return &refusal{reason: "unsupported-statement-shape",
cause: fmt.Sprintf("the planned statement for %s is not a shape the native-safe path can run", quotedTable(table)),
remedy: "rewrite the schema change and re-plan"}, true
case executor.CodeInvalidBlockingBudget:
// The bound is the engine's configuration, not the statement's
// form: no edit to a schema file changes it, so the remedy points
// at the budget instead of at the schema change.
return &refusal{reason: "invalid-blocking-budget",
cause: fmt.Sprintf("the blocking budget for the change to %s is not a bound the engine can enforce", quotedTable(table)),
remedy: "correct the engine's blocking budget configuration, then re-run"}, true
case executor.CodeRowSecurityPlanChanged:
return &refusal{reason: "row-security-plan-changed",
cause: fmt.Sprintf("the row security plan for %s changed after review", quotedTable(table)),
remedy: "re-plan and review the new SQL before applying"}, true
case executor.CodeRowSecurityRefused:
// Keep every pg-sprite outcome classified at the adapter boundary.
return &refusal{reason: "row-security-refused",
cause: fmt.Sprintf("the row security change for %s was refused by the engine", quotedTable(table)),
remedy: "check the declaration, target, and privileges before re-planning"}, true
case executor.CodeRowSecurityOutcomeUnknown:
// Keep every pg-sprite outcome classified at the adapter boundary.
// pg-sprite requires catalog inspection before any retry.
return &refusal{reason: "row-security-outcome-unknown",
cause: fmt.Sprintf("whether the row security change for %s committed is unknown", quotedTable(table)),
remedy: "inspect the target policies before re-running"}, true
case executor.CodeBlockingOutcomeUnknown:
// The statement's commit was sent and its answer never arrived, so
// whether the change landed is open. The engine leaves the retry
// decision to its adapter; SchemaBot's retry re-runs the identical
// statement, which replays a committed blocking DDL onto its own
// result, so the apply fails closed until an operator has read the
// catalog.
return &refusal{reason: "blocking-outcome-unknown",
cause: fmt.Sprintf("whether the blocking statement for %s committed is unknown", quotedTable(table)),
remedy: "inspect the target catalog before re-running"}, true
case executor.CodeBudgetStatementExceeded:
// Normally consumed upstream by the typed BudgetError arm, which
// renders the budget's own figures; this mapping keeps the outcome
// vocabulary total.
return &refusal{reason: "not-native-safe-budget-exceeded",
cause: fmt.Sprintf("the statement for table %s ran past its statement budget and was cancelled", quotedTable(table))}, true
case executor.CodeInvariantViolation:
// Never a retry candidate per the executor's contract: an invariant
// breach means the engine's own safety accounting failed, so the
// apply fails closed until an operator has inspected the target.
return &refusal{reason: "engine-invariant-violation",
cause: fmt.Sprintf("the engine's safety invariants did not hold while changing table %s", quotedTable(table)),
remedy: "inspect the target and server logs before re-running"}, true
case executor.CodeInvalidIndexOtherTable, executor.CodeInvalidIndexNotDroppable:
// The permanent members of the invalid-index family: the requested
// name is held by an entry this change can never clear — an invalid
// index on a different table, or one the server will not drop
// concurrently (a partitioned table's index, an index partition, a
// constraint's index). Retrying unchanged reproduces the verdict.
// The typed-verdict path replaces this cause and remedy with the
// code's own advice; this mapping keeps the vocabulary total.
return &refusal{reason: "invalid-index-occupied",
cause: fmt.Sprintf("an invalid index the change to %s cannot clear holds a name it needs", quotedTable(table)),
remedy: "rename the index in the schema file and re-plan, or resolve the entry on the target"}, true
case executor.CodePoolTooSmall:
// The pool is sized by the target DSN, so every retry against the
// same configuration is refused at admission the same way; only an
// operator raising the pool ceiling changes the outcome.
return &refusal{reason: "pool-too-small",
cause: fmt.Sprintf("the target's connection pool cannot hold every session the change to %s needs at once", quotedTable(table)),
remedy: "raise the pool size on the target DSN, then re-run"}, true
case executor.CodeBudgetLockExceeded, executor.CodeCancelledByCaller,
executor.CodeCancelledExternally, executor.CodeInvalidIndexOwnLeftover,
executor.CodeInvalidIndexAbandoned, executor.CodeInvalidIndexBuildInFlight,
executor.CodeInvalidIndexBuilderUnobservable, executor.CodeInvalidIndexUnproven,
executor.CodeExecutionFailed:
// Operational outcomes: a bounded lock race, the caller's own
// context ending or an external stop, an invalid-index state a retry
// recovers or an operator clears or waits out, or a failure outside
// the typed set. A retry can succeed once conditions change, so
// none is a permanent refusal.
return nil, true
}
return nil, false
}
// createCollisionRemedy leads with a re-plan because that alone resolves a
// lost race for the table name: the next plan sees the occupant and diffs
// against it. Only a collision that survives a re-plan is a schema-file
// problem — an explicitly named constraint or index claims a name another
// relation holds (an unnamed one picks a free name on its own), or a serial
// column's auto-named sequence lands on a standalone type of that name —
// and then the operator changes the name on one side or the other.
const createCollisionRemedy = "re-plan, and if it recurs drop or rename the occupant or give the constraint, index, or sequence another name"
// createCollisionRefusal names the kinds of relation that can hold a name
// the create set claims, so the operator knows where to look; the server
// error identifying the occupant stays in the logs. The list is the
// occupant's side, not the claimant's — a named constraint claims a name
// through the index it creates, so the index is what occupies it. The
// wording is kept short because the composed detail must survive the
// narrowest operator surface with its remedy's lead intact.
func createCollisionRefusal(table string) *refusal {
return &refusal{
reason: "create-collision",
cause: fmt.Sprintf("a name the create set for %s needs is already occupied (table, view, index, or sequence)", quotedTable(table)),
remedy: createCollisionRemedy,
}
}
// createNameMismatchRemedy is an operator's, not a retry's: the CREATE TABLE
// committed, so re-running the identical plan collides with the table this
// apply created, and the table stands under a name the schema file did not
// choose. Re-planning comes last because only the current schema, with the
// relation renamed or the table gone, tells the next plan what remains. The
// lead is the clause the cause's enumerations make room for: it is what the
// narrowest operator surface must still show once the detail is cut from
// the tail, because it alone says the table is standing and needs the name
// freed.
const (
createNameMismatchLead = "free the first-choice name"
createNameMismatchRemedy = createNameMismatchLead + " and rename the owned relation to it, or drop the table, then " + replanRemedy
)