-
Notifications
You must be signed in to change notification settings - Fork 0
/
exponential_gas_manager.go
425 lines (344 loc) · 13.1 KB
/
exponential_gas_manager.go
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
package tx
import (
"fmt"
"math"
"sync"
"github.com/tessellated-io/pickaxe/log"
txtypes "github.com/cosmos/cosmos-sdk/types/tx"
)
// Default gas factor to use
const defaultGasFactor float64 = 1.1
// Gas manager using exponential backoff.
//
// Rough algorithm:
// - Given some number of consecutive successes, decrement price by a step size.
// Formula: price_new = price_old - step_size
// - Given a failure, increase step sizes exponentially.
// Formula: price_new = price_old - (step_size * (1 + scale_factor)^(consecutive_failures))
//
// / bounded to maxStepSize
type geometricGasManager struct {
// Parameters
stepSize float64
maxStepSize float64
scaleFactor float64
// State
consecutiveGasPriceSuccesses map[string]int
consecutiveGasPriceFailures map[string]int
consecutiveGasFactorSuccesses map[string]int
consecutiveGasFactorFailures map[string]int
lock *sync.Mutex
// Core Services
gasPriceProvider GasPriceProvider
logger *log.Logger
}
var _ GasManager = (*geometricGasManager)(nil)
func NewGeometricGasManager(
stepSize float64,
maxStepSize float64,
scaleFactor float64,
gasPriceProvider GasPriceProvider,
logger *log.Logger,
) (GasManager, error) {
if scaleFactor < 0 || scaleFactor >= 1 {
return nil, fmt.Errorf("invalid scale factor: %f. Must conform to: 0 < scale_factor < 1", scaleFactor)
}
gasLogger := logger.ApplyPrefix("⛽️")
lock := &sync.Mutex{}
gasManager := &geometricGasManager{
stepSize: stepSize,
maxStepSize: maxStepSize,
scaleFactor: scaleFactor,
consecutiveGasPriceSuccesses: make(map[string]int),
consecutiveGasPriceFailures: make(map[string]int),
consecutiveGasFactorSuccesses: make(map[string]int),
consecutiveGasFactorFailures: make(map[string]int),
lock: lock,
logger: gasLogger,
gasPriceProvider: gasPriceProvider,
}
return gasManager, nil
}
// Initialize a price. If already initialized, this is a no-op.
func (g *geometricGasManager) InitializePrice(chainName string, gasPrice float64) error {
// Check if the price is initialized and warn if so
hasPrice, err := g.gasPriceProvider.HasGasPrice(chainName)
if err != nil {
return err
}
if hasPrice {
g.logger.Warn().Str("chain_name", chainName).Msg("requested initialization of previously initialized price. this is a no-op.")
return nil
}
return g.gasPriceProvider.SetGasPrice(chainName, gasPrice)
}
// Get a gas price
func (g *geometricGasManager) GetGasPrice(chainName string) (float64, error) {
// Attempt to get a gas price, and return if successful.
gasPrice, err := g.gasPriceProvider.GetGasPrice(chainName)
if err == ErrNoGasPrice {
g.logger.Warn().Str("chain_name", chainName).Msg("no gas price found for chain, setting gas to be zero")
return 0, nil
} else if err != nil {
return 0, err
}
return gasPrice, err
}
func (gm *geometricGasManager) GetGasFactor(chainName string) (float64, error) {
// Attempt to get a gas factor, and return if successful.
gasFactor, err := gm.gasPriceProvider.GetGasFactor(chainName)
if err == ErrNoGasFactor {
gm.logger.Warn().Str("chain_name", chainName).Float64("default_gas_factor", defaultGasFactor).Msg("no gas factor found for chain, initializing as default")
err := gm.gasPriceProvider.SetGasFactor(chainName, defaultGasFactor)
if err != nil {
gm.logger.Error().Err(err).Str("chain_name", chainName).Float64("default_gas_factor", defaultGasFactor).Msg("unable to set set default gas factor for chain. recovering by returning default gas factor")
}
return defaultGasFactor, nil
} else if err != nil {
return 0.0, err
}
return gasFactor, err
}
// Feedback methods
// Provides feedback to the gas manager.
// Call one of these function after you know if the last provided gas price was high enough. Generally this is after either:
// - A `broadcast` RPC call (but you don't necessarily know that it is a gas error)
// - Polling for a transaction after a call and finding it included or not, or a broadcast result you know is a gas error.
// NOTE: You probably don't want to call after both, as that provides duplicate feedback.
func (g *geometricGasManager) ManageFailingBroadcastResult(chainName string, broadcastResult *txtypes.BroadcastTxResponse) error {
if broadcastResult == nil {
return fmt.Errorf("received nil broadcast tx result")
}
if broadcastResult.TxResponse == nil {
return fmt.Errorf("received nil tx response in broadcast tx result")
}
// Extract the code and logs from broadcast tx response
codespace := broadcastResult.TxResponse.Codespace
code := broadcastResult.TxResponse.Code
logs := broadcastResult.TxResponse.RawLog
// 1. If code was success, then ditch since this method only manages failures.
isSuccess, err := IsSuccess(broadcastResult)
if err != nil {
return err
}
if isSuccess {
g.logger.Warn().Str("chain_name", chainName).Msg("tx broadcast result was successful, but asked gas manager to track a failure.")
return nil
}
return g.trackFailingCodeAndCodespace(code, codespace, chainName, logs, uint(broadcastResult.TxResponse.GasWanted))
}
func (g *geometricGasManager) ManageIncludedTransactionStatus(chainName string, txStatus *txtypes.GetTxResponse) error {
// Extract the code and logs from broadcast tx response
codespace := txStatus.TxResponse.Codespace
code := txStatus.TxResponse.Code
logs := txStatus.TxResponse.RawLog
// 1. If code was success, then ditch since this method only manages failures.
if IsSuccessTxStatus(txStatus) {
err := g.trackGasPriceSuccess(chainName)
if err != nil {
return err
}
err = g.trackGasFactorSuccess(chainName)
if err != nil {
return err
}
return nil
}
// Otherwise, use core tracking logic
return g.trackFailingCodeAndCodespace(code, codespace, chainName, logs, uint(txStatus.TxResponse.GasWanted))
}
// This only tracks gas price
func (g *geometricGasManager) ManageInclusionFailure(chainName string) error {
return g.trackGasPriceFailure(chainName)
}
// Helpers - state tracking
func (g *geometricGasManager) trackFailingCodeAndCodespace(code uint32, codespace, chainName, logs string, gasWanted uint) error {
// 2. If the code was not a gas error, then it is non-deterministic, so do nothing.
if !IsGasRelatedError(codespace, code) {
g.logger.Info().Str("chain_name", chainName).Uint32("code", code).Str("logs", logs).Str("codespace", codespace).Msg("broadcast result was unrelated to gas. not adjusting gas prices or gas factor")
return nil
}
// 3. Manage failures do to gas price
if IsGasPriceError(codespace, code) {
// 3a. Grab the old price, which is useful for logging.
oldGasPrice, err := g.GetGasPrice(chainName)
if err != nil {
return err
}
// 3b. Otherwise, it is a gas error so track a failure (which might auto adjust)
err = g.trackGasPriceFailure(chainName)
if err != nil {
return err
}
// 3c. If the network gave us a price, we can just use that one though.
chainSuggestedFee, err := extractMinGlobalFee(logs)
if err == nil {
// Determine the gas price by dividing the fee by the gas units requested
if gasWanted == 0 {
return fmt.Errorf("gas wanted cannot be zero")
}
newGasPrice := chainSuggestedFee / float64(gasWanted)
// Set and log
err = g.gasPriceProvider.SetGasPrice(chainName, newGasPrice)
if err != nil {
return err
}
g.logger.Info().Str("chain_name", chainName).Float64("new_gas_price", newGasPrice).Float64("old_gas_price", oldGasPrice).Str("logs", logs).Msg("calculated exact price from chain suggestion")
}
return nil
} else if isGasAmountError(codespace, code) {
return g.trackGasFactorFailure(chainName)
} else {
// This should never happen...
panic(fmt.Errorf("unexpected condition in gas manager adjustments with code %d and codespace %s", code, codespace))
}
}
func (g *geometricGasManager) trackGasFactorFailure(chainName string) error {
// Lock for map updates
g.lock.Lock()
defer g.lock.Unlock()
// Accounting
g.consecutiveGasFactorSuccesses[chainName] = 0
failures := g.consecutiveGasFactorFailures[chainName] + 1
g.consecutiveGasFactorFailures[chainName] = failures
return g.adjustFactor(chainName, 0, failures)
}
func (g *geometricGasManager) trackGasFactorSuccess(chainName string) error {
// Lock for map updates
g.lock.Lock()
defer g.lock.Unlock()
// Accounting
g.consecutiveGasFactorFailures[chainName] = 0
successes := g.consecutiveGasFactorSuccesses[chainName] + 1
g.consecutiveGasFactorSuccesses[chainName] = successes
return g.adjustFactor(chainName, successes, 0)
}
func (g *geometricGasManager) trackGasPriceFailure(chainName string) error {
// Lock for map updates
g.lock.Lock()
defer g.lock.Unlock()
// Accounting
g.consecutiveGasPriceSuccesses[chainName] = 0
failures := g.consecutiveGasPriceFailures[chainName] + 1
g.consecutiveGasPriceFailures[chainName] = failures
// Adjustments
return g.adjustPrice(chainName, 0, failures)
}
func (g *geometricGasManager) trackGasPriceSuccess(chainName string) error {
// Lock for map updates
g.lock.Lock()
defer g.lock.Unlock()
// Accounting
g.consecutiveGasPriceFailures[chainName] = 0
successes := g.consecutiveGasPriceSuccesses[chainName] + 1
g.consecutiveGasPriceSuccesses[chainName] = successes
g.consecutiveGasPriceFailures[chainName]++
// Adjustments
return g.adjustPrice(chainName, successes, 0)
}
// Helpers - adjustments
// TODO: config params
const (
baseFactorSuccessThreshold = 10
factorStepSize = 0.01
)
var (
maxFactorThreshold = 100
currentFactorSuccessThreshold = baseFactorSuccessThreshold
isTryingToStepDownFactor = false
)
// TODO: Theoretically this could just be injected to allow generalization. That feels over-optimizey for now.
func (g *geometricGasManager) adjustFactor(chainName string, successes, failures int) error {
// Get starting factor
oldFactor, err := g.GetGasFactor(chainName)
if err != nil {
return err
}
var newFactor float64
// See if we were testing a lower gas factor
if isTryingToStepDownFactor {
// We're through our stepping.
isTryingToStepDownFactor = false
// If we were trying to step down and we failed, increase threshold (bounding) and step back
if failures > 0 {
currentFactorSuccessThreshold += baseFactorSuccessThreshold
if currentFactorSuccessThreshold > maxFactorThreshold {
currentFactorSuccessThreshold = maxFactorThreshold
}
newFactor = oldFactor + factorStepSize
} else {
// New gas factor worked. Reset factor to baseline and reset successes to zero
currentFactorSuccessThreshold = baseFactorSuccessThreshold
g.consecutiveGasFactorSuccesses[chainName] = 0
return nil
}
} else {
// Do nothing if we don't have a failure or a consecutive success
if failures == 0 && successes < currentFactorSuccessThreshold {
return nil
}
if failures > 0 {
newFactor = oldFactor + factorStepSize
} else {
newFactor = oldFactor - factorStepSize
if newFactor < 0 {
newFactor = 0
}
// Set that we're trying to step down.
isTryingToStepDownFactor = true
}
}
err = g.gasPriceProvider.SetGasFactor(chainName, newFactor)
if err != nil {
return err
}
g.logger.Info().Str("chain_name", chainName).Float64("old_gas_factor", oldFactor).Int("consecutive_successes", successes).Int("consecutive_failures", failures).Float64("new_gas_factor", newFactor).Msg("adjusted gas factor in response to feedback")
return nil
}
// TODO: Theoretically this could just be injected to allow generalization. That feels over-optimizey for now.
func (g *geometricGasManager) adjustPrice(chainName string, successes, failures int) error {
// Do nothing if we don't have a failure or a consecutive success
successThreshold := 5
if failures == 0 && successes < 5 {
return nil
}
// Get starting price
oldPrice, err := g.GetGasPrice(chainName)
if err != nil {
return err
}
var newPrice float64
if failures > 0 {
// Failures increasing. Scale price up according to consecutive failures
scale := math.Pow((1 + g.scaleFactor), float64(failures))
scaledStepSize := g.stepSize * scale
if scaledStepSize > g.maxStepSize {
g.logger.Warn().Float64("desired_step_size", scaledStepSize).Float64("max_step_size", g.maxStepSize).Msg("bounding step size")
scaledStepSize = g.maxStepSize
}
newPrice = oldPrice + scaledStepSize
} else {
// // Successes increasing, test a decrease of one step
// newPrice = oldPrice - g.stepSize
// if newPrice < 0 {
// newPrice = 0
// }
// Failures increasing. Scale price up according to consecutive failures
scale := math.Pow((1 + g.scaleFactor), float64(successes-successThreshold))
scaledStepSize := g.stepSize * scale
if scaledStepSize > g.maxStepSize {
g.logger.Warn().Float64("desired_step_size", scaledStepSize).Float64("max_step_size", g.maxStepSize).Msg("bounding step size")
scaledStepSize = g.maxStepSize
}
newPrice = oldPrice - scaledStepSize
if newPrice < 0 {
newPrice = 0
}
}
err = g.gasPriceProvider.SetGasPrice(chainName, newPrice)
if err != nil {
return err
}
g.logger.Info().Str("chain_name", chainName).Float64("old_gas_price", oldPrice).Int("consecutive_successes", successes).Int("consecutive_failures", failures).Float64("new_gas_price", newPrice).Msg("adjusted gas price in response to feedback")
return nil
}