-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcallbacks.go
More file actions
302 lines (272 loc) · 9.38 KB
/
Copy pathcallbacks.go
File metadata and controls
302 lines (272 loc) · 9.38 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
package autobatch
import (
"context"
"errors"
"time"
"gorm.io/gorm"
)
// Instance / context keys. Defined as named consts to avoid string-concat
// surprises and to make grep-ability trivial.
const (
startTimeKey = "gorm:autobatch:start_time"
batchedMarker = "gorm:autobatch:batched"
batchedErrMarker = "gorm:autobatch:batched_err"
gormStartedTransaction = "gorm:started_transaction"
)
// errSkipCore is an internal sentinel set on db.Error so GORM's core
// create/update/delete callbacks early-return without executing SQL. afterOp
// strips it before returning to the caller. Using db.Error instead of
// db.DryRun avoids racing on the shared *gorm.Config field.
var errSkipCore = errors.New("autobatch: skip core (internal)")
type flushCtxKey struct{}
// flushContext marks a context as originating from the plugin's internal flush,
// so beforeOp can skip intercepting it and avoid a re-entrancy deadlock.
func flushContext(ctx context.Context) context.Context {
return context.WithValue(ctx, flushCtxKey{}, true)
}
func isFlushContext(ctx context.Context) bool {
v, _ := ctx.Value(flushCtxKey{}).(bool)
return v
}
// beforeOp returns a callback that intercepts a GORM operation. In batch mode
// it enqueues the op, blocks until the batch flushes, then sets DryRun so the
// core callback skips execution (the batch already ran the operation).
// In individual mode it is a no-op and the core callback runs normally.
func (p *Plugin) beforeOp(b *batcher) func(*gorm.DB) {
return func(db *gorm.DB) {
db.InstanceSet(startTimeKey, time.Now())
// Skip batching if:
// 1. Database is already in an error state.
// 2. This is a recursive call from the flush itself.
// 3. Batch mode is inactive based on P95 latency.
// 4. Operation is inside an explicit user transaction (to maintain
// atomicity/rollback semantics).
if db.Error != nil || isFlushContext(db.Statement.Context) || !p.isBatchMode() || isTransaction(db) {
p.cfg.log(LogLevelDebug, "autobatch: individual mode, op will run directly",
"table", db.Statement.Table,
"in_tx", isTransaction(db),
)
return
}
p.cfg.log(LogLevelDebug, "autobatch: enqueuing op into batch",
"table", db.Statement.Table,
)
op := newPendingOp(db)
if err := b.submit(op); err != nil {
db.AddError(err)
return
}
var realErr error
if err := wait(db.Statement.Context, op); err != nil {
p.cfg.log(LogLevelError, "autobatch: batch op returned error",
"table", db.Statement.Table,
"error", err,
)
realErr = err
}
// Signal afterOp to skip its latency recording (already recorded by
// flush) and to strip the sentinel so the caller sees only realErr.
db.InstanceSet(batchedMarker, true)
if realErr != nil {
db.InstanceSet(batchedErrMarker, realErr)
}
// Setting db.Error to the sentinel makes the core callback (gorm:create/
// gorm:update/gorm:delete) early-return without executing SQL. We strip
// the sentinel back out in afterOp. This is safer than setting
// db.DryRun because DryRun lives on the shared *gorm.Config and would
// race across goroutines using the same root *gorm.DB.
db.Statement.Error = errSkipCore
db.Error = errSkipCore
}
}
// isTransaction returns true if the DB is inside an explicit user transaction
// (e.g. db.Transaction(...) or db.Begin()). It returns false for the default
// per-statement transaction that GORM opens automatically when
// SkipDefaultTransaction is false — those are marked with the
// "gorm:started_transaction" instance key by GORM's BeginTransaction callback.
func isTransaction(db *gorm.DB) bool {
if db.Statement.ConnPool == nil {
return false
}
if _, ok := db.Statement.ConnPool.(gorm.TxCommitter); !ok {
return false
}
_, autoTx := db.InstanceGet(gormStartedTransaction)
return !autoTx
}
// afterOp records the elapsed time into the latency window for non-batched
// ops. Batched ops have their latency recorded inside the flush function;
// here we also strip the internal sentinel error and restore the real error
// (if any) from the batch.
func (p *Plugin) afterOp() func(*gorm.DB) {
return func(db *gorm.DB) {
if _, wasBatched := db.InstanceGet(batchedMarker); wasBatched {
// Strip the sentinel so the caller doesn't see it.
if errors.Is(db.Error, errSkipCore) {
db.Error = nil
db.Statement.Error = nil
}
// Surface the real batch error (if any).
if v, ok := db.InstanceGet(batchedErrMarker); ok {
if realErr, ok := v.(error); ok && realErr != nil {
db.AddError(realErr)
}
}
return
}
if v, ok := db.InstanceGet(startTimeKey); ok {
if start, ok := v.(time.Time); ok {
elapsed := time.Since(start)
p.latency.Record(elapsed)
p.cfg.log(LogLevelDebug, "autobatch: individual op completed",
"table", db.Statement.Table,
"elapsed", elapsed,
)
}
}
}
}
// makeFlush returns a flush function that executes a slice of buffered ops
// inside a single transaction.
//
// Atomicity model: each op runs inside its own SAVEPOINT so that a failure on
// one op (e.g. a unique-constraint violation) only fails that op — the rest of
// the batch still commits. This avoids the "noisy neighbour" problem where one
// caller's bad input would otherwise roll back every other caller in the same
// batch.
func makeFlush(rootDB *gorm.DB, lat *window, cfg *resolved, opFn func(base *gorm.DB, op *pendingOp) *gorm.DB) func([]*pendingOp) {
return func(ops []*pendingOp) {
start := time.Now()
cfg.log(LogLevelDebug, "autobatch: flushing batch",
"size", len(ops),
)
// Separate already-cancelled ops before the transaction so they never
// hold up the batch and receive their own error immediately after.
var cancelled []*pendingOp
var active []*pendingOp
for _, op := range ops {
if op.ctx.Err() != nil {
cancelled = append(cancelled, op)
} else {
active = append(active, op)
}
}
// perOpErrs[i] is the error (or nil) for active[i] after savepoint
// execution. txErr is set only on infrastructure failures (BEGIN/COMMIT).
perOpErrs := make([]error, len(active))
var txErr error
if len(active) > 0 {
txErr = rootDB.Transaction(func(tx *gorm.DB) error {
for i, opReq := range active {
// Recheck ctx right before executing — closes the window
// between the initial pre-tx check and the actual op.
if err := opReq.ctx.Err(); err != nil {
perOpErrs[i] = err
continue
}
spName := savepointName(i)
if err := tx.SavePoint(spName).Error; err != nil {
// Savepoint unsupported (or tx broken) — fall back to
// shared-atomicity mode for the remainder: every op
// here on shares fate. This is rare.
perOpErrs[i] = runOpDirect(tx, opReq, opFn)
if perOpErrs[i] != nil {
return perOpErrs[i]
}
continue
}
perOpErrs[i] = runOpDirect(tx, opReq, opFn)
if perOpErrs[i] != nil {
// Roll back just this op; keep the outer tx alive.
if rbErr := tx.RollbackTo(spName).Error; rbErr != nil {
// Can't recover the tx — propagate to all.
return rbErr
}
}
}
return nil
})
}
elapsed := time.Since(start)
lat.Record(elapsed)
if txErr != nil {
cfg.log(LogLevelError, "autobatch: batch transaction failed, rolling back",
"size", len(active),
"elapsed", elapsed,
"error", txErr,
)
} else if len(active) > 0 {
cfg.log(LogLevelInfo, "autobatch: batch flushed successfully",
"size", len(active),
"elapsed", elapsed,
)
}
for _, op := range cancelled {
op.err = op.ctx.Err()
close(op.done)
}
for i, op := range active {
if txErr != nil {
op.err = txErr
} else {
op.err = perOpErrs[i]
}
close(op.done)
}
}
}
// runOpDirect executes a single op against the given tx, captures its
// RowsAffected on the pendingOp, and returns its error.
func runOpDirect(tx *gorm.DB, opReq *pendingOp, opFn func(base *gorm.DB, op *pendingOp) *gorm.DB) error {
base := tx.Session(&gorm.Session{
Context: flushContext(opReq.ctx),
SkipDefaultTransaction: true,
NewDB: true,
})
if opReq.model != nil {
base = base.Model(opReq.model)
}
if opReq.table != "" {
base = base.Table(opReq.table)
}
res := opFn(base, opReq)
opReq.rows = res.RowsAffected
return res.Error
}
// savepointName returns a deterministic, SQL-safe savepoint identifier.
// Underscore-only / numeric suffix is portable across PostgreSQL, MySQL, etc.
func savepointName(i int) string {
// Pre-allocated small set is cheaper than fmt.Sprintf; flushes are bounded
// by MaxBatchSize so a fixed namespace works.
const prefix = "ab_sp_"
// itoa without fmt to keep this hot path allocation-light.
var buf [20]byte
pos := len(buf)
n := i
if n == 0 {
pos--
buf[pos] = '0'
} else {
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
}
return prefix + string(buf[pos:])
}
func makeCreateFlush(rootDB *gorm.DB, lat *window, cfg *resolved) func([]*pendingOp) {
return makeFlush(rootDB, lat, cfg, func(base *gorm.DB, op *pendingOp) *gorm.DB {
return base.Create(op.dest)
})
}
func makeUpdateFlush(rootDB *gorm.DB, lat *window, cfg *resolved) func([]*pendingOp) {
return makeFlush(rootDB, lat, cfg, func(base *gorm.DB, op *pendingOp) *gorm.DB {
return base.Updates(op.dest)
})
}
func makeDeleteFlush(rootDB *gorm.DB, lat *window, cfg *resolved) func([]*pendingOp) {
return makeFlush(rootDB, lat, cfg, func(base *gorm.DB, op *pendingOp) *gorm.DB {
return base.Delete(op.dest)
})
}