laskoviymishka commented on code in PR #1640:
URL: https://github.com/apache/iceberg-go/pull/1640#discussion_r3739108070


##########
catalog/hive/lock_test.go:
##########
@@ -404,3 +404,195 @@ func TestLockConfigurationDefaults(t *testing.T) {
        assert.Equal(t, DefaultLockCheckMaxWaitTime, opts.LockMaxWaitTime)
        assert.Equal(t, DefaultLockCheckRetries, opts.LockRetries)
 }
+
+func TestApplyJitterBelowCapNeverShorterThanInput(t *testing.T) {
+       minWait := time.Millisecond
+       maxWait := time.Minute
+
+       for _, d := range []time.Duration{
+               time.Millisecond,
+               100 * time.Millisecond,
+               time.Second,
+       } {
+               for i := 0; i < 500; i++ {
+                       got := applyJitter(d, minWait, maxWait)
+                       assert.GreaterOrEqual(t, got, d,
+                               "jitter must never wait less than the 
configured interval")
+                       assert.LessOrEqual(t, got, min(2*d, maxWait),
+                               "jitter must not exceed one extra interval or 
the configured maximum")
+               }
+       }
+}
+
+// Once calculateBackoff tops out there is no headroom to add into, so the wait
+// is spread downwards. It must still vary, or every retry after the sequence
+// saturates puts contending clients back in lockstep.
+func TestApplyJitterAtCapStaysWithinBoundsAndVaries(t *testing.T) {
+       minWait := 100 * time.Millisecond
+       maxWait := time.Second
+
+       seen := make(map[time.Duration]struct{})
+       for i := 0; i < 500; i++ {
+               got := applyJitter(maxWait, minWait, maxWait)
+               assert.GreaterOrEqual(t, got, maxWait/2,
+                       "the spread at the cap must not exceed one half 
interval")
+               assert.LessOrEqual(t, got, maxWait,
+                       "the wait must never exceed the configured maximum")
+               seen[got] = struct{}{}
+       }
+
+       assert.Greater(t, len(seen), 1,
+               "waits at the cap must vary so contending clients do not poll 
in lockstep")
+}
+
+// The spread at the cap must never poll sooner than a configured minimum wait,
+// which is reachable whenever minWait is more than half of maxWait.
+func TestApplyJitterAtCapNeverPollsSoonerThanMinWait(t *testing.T) {
+       minWait := 40 * time.Second
+       maxWait := time.Minute
+
+       for i := 0; i < 500; i++ {
+               assert.GreaterOrEqual(t, applyJitter(maxWait, minWait, 
maxWait), minWait,
+                       "a caller who configures a minimum wait must never poll 
sooner than it")
+       }
+}
+
+// The guaranteed minimum must not fall as the sequence saturates. The attempt
+// before the cap waits for its own full interval, so flooring the spread at 
half
+// the cap would let the first saturated attempt poll sooner than the one 
before
+// it, which inverts the property that the wait between checks only ever grows.
+//
+// Reaching the cap takes a lowered lock-check-max-wait-time or a raised retry
+// count; the defaults (100ms, 1 minute, 4 retries) top out at 800ms and never
+// saturate. The values below are the smallest that put the boundary in range.
+func TestApplyJitterMinimumIsMonotonicAcrossTheCap(t *testing.T) {
+       minWait := 100 * time.Millisecond
+       maxWait := time.Second
+
+       // The sequence runs 100ms, 200ms, 400ms, 800ms, then saturates at 1s.
+       // Attempt 3 draws from [800ms, 1s]; attempt 4 is the first capped one.
+       //
+       // The bound compared against is exact, not sampled. Below the cap the 
jitter is
+       // added on top, so the interval itself is the floor and no estimate is 
needed.
+       // Sampling both sides instead would compare two noisy minima that sit 
a hair
+       // above the same true floor, and which of them lands lower is chance.
+       var floor time.Duration
+       for attempt := 0; attempt < 8; attempt++ {
+               d := calculateBackoff(attempt, minWait, maxWait)
+
+               for i := 0; i < 2000; i++ {
+                       assert.GreaterOrEqual(t, applyJitter(d, minWait, 
maxWait), floor,
+                               "attempt %d may not be allowed to wait less 
than an earlier attempt", attempt)
+               }
+
+               if d < maxWait {
+                       floor = d
+               }
+       }
+}
+
+// The specific boundary above, pinned so a future change to the floor cannot
+// quietly reintroduce the dip without failing on the exact numbers.
+func TestApplyJitterAtCapFloorsAtTheLastUncappedInterval(t *testing.T) {
+       minWait := 100 * time.Millisecond
+       maxWait := time.Second
+
+       assert.Equal(t, 800*time.Millisecond, calculateBackoff(3, minWait, 
maxWait),
+               "the last interval before the cap")
+       assert.Equal(t, maxWait, calculateBackoff(4, minWait, maxWait),
+               "the first interval at the cap")
+
+       for i := 0; i < 2000; i++ {
+               assert.GreaterOrEqual(t, applyJitter(maxWait, minWait, 
maxWait), 800*time.Millisecond,

Review Comment:
   The pinned cases all use clean doubling ratios (100ms to 1s, floor 800ms). 
The floor's hard case is a non-power-of-2 ratio where d/2 and the real 
last-uncapped interval diverge, e.g. 300ms/1000ms, where the floor must be 
600ms, not 500ms. I'd add one such case here; that's where the replay loop 
earns its keep and the only spot the arithmetic could quietly go wrong.



##########
catalog/hive/lock_test.go:
##########
@@ -404,3 +404,195 @@ func TestLockConfigurationDefaults(t *testing.T) {
        assert.Equal(t, DefaultLockCheckMaxWaitTime, opts.LockMaxWaitTime)
        assert.Equal(t, DefaultLockCheckRetries, opts.LockRetries)
 }
+
+func TestApplyJitterBelowCapNeverShorterThanInput(t *testing.T) {
+       minWait := time.Millisecond
+       maxWait := time.Minute
+
+       for _, d := range []time.Duration{
+               time.Millisecond,
+               100 * time.Millisecond,
+               time.Second,
+       } {
+               for i := 0; i < 500; i++ {
+                       got := applyJitter(d, minWait, maxWait)
+                       assert.GreaterOrEqual(t, got, d,
+                               "jitter must never wait less than the 
configured interval")
+                       assert.LessOrEqual(t, got, min(2*d, maxWait),
+                               "jitter must not exceed one extra interval or 
the configured maximum")
+               }
+       }
+}
+
+// Once calculateBackoff tops out there is no headroom to add into, so the wait
+// is spread downwards. It must still vary, or every retry after the sequence
+// saturates puts contending clients back in lockstep.
+func TestApplyJitterAtCapStaysWithinBoundsAndVaries(t *testing.T) {
+       minWait := 100 * time.Millisecond
+       maxWait := time.Second
+
+       seen := make(map[time.Duration]struct{})
+       for i := 0; i < 500; i++ {
+               got := applyJitter(maxWait, minWait, maxWait)
+               assert.GreaterOrEqual(t, got, maxWait/2,

Review Comment:
   This lower bound is looser than the code actually guarantees. The floor at 
the cap is 800ms (the last uncapped interval), but this only asserts >= 500ms, 
so a regression letting the spread dip to 600-700ms would pass here. 
TestApplyJitterAtCapFloorsAtTheLastUncappedInterval does pin the real 800ms, so 
we're covered, but this assertion reads stronger than it is. I'd tighten it to 
800ms so the two agree.
   
   Separately, the message ("must not exceed one half interval") is upper-bound 
wording on a GreaterOrEqual check. I'd flip it to something like "must be at 
least half of maxWait" so a failure dump isn't confusing.



##########
catalog/hive/lock.go:
##########
@@ -160,6 +165,62 @@ func calculateBackoff(attempt int, minWait, maxWait 
time.Duration) time.Duration
        return minWait << attempt
 }
 
+// applyJitter spreads a backoff interval so clients contending for the same 
lock
+// stop re-polling in lockstep. calculateBackoff is a pure function of the 
attempt
+// and the configured bounds, and contention is the precondition for retrying 
at
+// all, so without this every waiter issues its CheckLock calls at the same 
instants
+// and each round reaches the metastore as a burst.
+//
+// The invariants, in the order the code establishes them:
+//   - below the cap the jitter is added rather than centred, so the wait is 
never
+//     shorter than the interval calculateBackoff produced;
+//   - at the cap there is no headroom left to add into, so the wait is spread
+//     downward instead, floored at the last interval the sequence produced 
before
+//     it saturated — a bound the schedule has already cleared, which stops a 
later
+//     attempt from being allowed to wait less than an earlier one;
+//   - the result never exceeds maxWait and never falls below minWait.

Review Comment:
   This last bullet isn't quite true when minWait > maxWait. calculateBackoff 
resolves that config to maxWait, and applyJitter returns maxWait too, so the 
result sits below minWait. TestApplyJitterHonoursMinWaitAboveMaxWait actually 
pins exactly that (got == maxWait with minWait=90s, maxWait=60s).
   
   I'd qualify it: never below minWait when minWait <= maxWait, otherwise 
exactly maxWait.



##########
catalog/hive/lock.go:
##########
@@ -160,6 +165,62 @@ func calculateBackoff(attempt int, minWait, maxWait 
time.Duration) time.Duration
        return minWait << attempt
 }
 
+// applyJitter spreads a backoff interval so clients contending for the same 
lock
+// stop re-polling in lockstep. calculateBackoff is a pure function of the 
attempt
+// and the configured bounds, and contention is the precondition for retrying 
at
+// all, so without this every waiter issues its CheckLock calls at the same 
instants
+// and each round reaches the metastore as a burst.
+//
+// The invariants, in the order the code establishes them:
+//   - below the cap the jitter is added rather than centred, so the wait is 
never
+//     shorter than the interval calculateBackoff produced;
+//   - at the cap there is no headroom left to add into, so the wait is spread
+//     downward instead, floored at the last interval the sequence produced 
before
+//     it saturated — a bound the schedule has already cleared, which stops a 
later
+//     attempt from being allowed to wait less than an earlier one;
+//   - the result never exceeds maxWait and never falls below minWait.
+func applyJitter(d, minWait, maxWait time.Duration) time.Duration {
+       if d <= 0 {
+               return d
+       }
+
+       // A caller that hands in an interval already past the cap is outside 
the
+       // contract; leave it exactly as given rather than silently reshaping 
it.
+       headroom := maxWait - d
+       if headroom < 0 {
+               return d
+       }
+
+       // Add up to another full interval, without exceeding the configured 
maximum.
+       extra := d
+       if headroom < extra {
+               extra = headroom
+       }
+       if extra > 0 {
+               return d + time.Duration(rand.Int64N(int64(extra)+1))
+       }
+
+       // Replay the doubling sequence and keep the largest interval that 
still fitted
+       // under the cap. The guard on scheduled keeps a non-positive or 
overflowing
+       // minWait from spinning here.
+       //
+       // minWait is applied before the replay rather than relying on it. When 
minWait
+       // is itself >= maxWait the loop cannot run at all, and options.go 
accepts that
+       // configuration, so leaving the floor at d/2 there would allow a wait 
of a third
+       // of the configured minimum.
+       floor := max(minWait, d/2)
+       for scheduled := minWait; scheduled > 0 && scheduled < maxWait; 
scheduled <<= 1 {
+               if scheduled > floor {
+                       floor = scheduled
+               }

Review Comment:
   This downward-spread branch only runs once the sequence saturates at 
maxWait, and under the defaults (100ms/60s/4 retries) we top out at 800ms and 
never get there, so the replay loop is effectively dead code in a default 
deployment. It's also the most intricate part of the helper, and I'm a little 
uneasy about the most fragile code being the least exercised in practice.
   
   Is the strict monotonicity across the cap worth it? A flat max(minWait, d/2) 
floor drops the loop entirely, at the cost of letting the first capped attempt 
dip slightly below the last uncapped one. If we do keep the loop, I'd at least 
note plainly that it's only reachable under non-default config. Thoughts?



##########
catalog/hive/lock_test.go:
##########
@@ -404,3 +404,195 @@ func TestLockConfigurationDefaults(t *testing.T) {
        assert.Equal(t, DefaultLockCheckMaxWaitTime, opts.LockMaxWaitTime)
        assert.Equal(t, DefaultLockCheckRetries, opts.LockRetries)
 }
+
+func TestApplyJitterBelowCapNeverShorterThanInput(t *testing.T) {
+       minWait := time.Millisecond
+       maxWait := time.Minute
+
+       for _, d := range []time.Duration{
+               time.Millisecond,
+               100 * time.Millisecond,
+               time.Second,
+       } {
+               for i := 0; i < 500; i++ {
+                       got := applyJitter(d, minWait, maxWait)
+                       assert.GreaterOrEqual(t, got, d,
+                               "jitter must never wait less than the 
configured interval")
+                       assert.LessOrEqual(t, got, min(2*d, maxWait),
+                               "jitter must not exceed one extra interval or 
the configured maximum")
+               }
+       }
+}
+
+// Once calculateBackoff tops out there is no headroom to add into, so the wait
+// is spread downwards. It must still vary, or every retry after the sequence
+// saturates puts contending clients back in lockstep.
+func TestApplyJitterAtCapStaysWithinBoundsAndVaries(t *testing.T) {
+       minWait := 100 * time.Millisecond
+       maxWait := time.Second
+
+       seen := make(map[time.Duration]struct{})
+       for i := 0; i < 500; i++ {
+               got := applyJitter(maxWait, minWait, maxWait)
+               assert.GreaterOrEqual(t, got, maxWait/2,
+                       "the spread at the cap must not exceed one half 
interval")
+               assert.LessOrEqual(t, got, maxWait,
+                       "the wait must never exceed the configured maximum")
+               seen[got] = struct{}{}
+       }
+
+       assert.Greater(t, len(seen), 1,
+               "waits at the cap must vary so contending clients do not poll 
in lockstep")
+}
+
+// The spread at the cap must never poll sooner than a configured minimum wait,
+// which is reachable whenever minWait is more than half of maxWait.
+func TestApplyJitterAtCapNeverPollsSoonerThanMinWait(t *testing.T) {
+       minWait := 40 * time.Second
+       maxWait := time.Minute
+
+       for i := 0; i < 500; i++ {
+               assert.GreaterOrEqual(t, applyJitter(maxWait, minWait, 
maxWait), minWait,
+                       "a caller who configures a minimum wait must never poll 
sooner than it")
+       }
+}
+
+// The guaranteed minimum must not fall as the sequence saturates. The attempt
+// before the cap waits for its own full interval, so flooring the spread at 
half
+// the cap would let the first saturated attempt poll sooner than the one 
before
+// it, which inverts the property that the wait between checks only ever grows.
+//
+// Reaching the cap takes a lowered lock-check-max-wait-time or a raised retry
+// count; the defaults (100ms, 1 minute, 4 retries) top out at 800ms and never
+// saturate. The values below are the smallest that put the boundary in range.
+func TestApplyJitterMinimumIsMonotonicAcrossTheCap(t *testing.T) {
+       minWait := 100 * time.Millisecond
+       maxWait := time.Second
+
+       // The sequence runs 100ms, 200ms, 400ms, 800ms, then saturates at 1s.
+       // Attempt 3 draws from [800ms, 1s]; attempt 4 is the first capped one.
+       //
+       // The bound compared against is exact, not sampled. Below the cap the 
jitter is
+       // added on top, so the interval itself is the floor and no estimate is 
needed.
+       // Sampling both sides instead would compare two noisy minima that sit 
a hair
+       // above the same true floor, and which of them lands lower is chance.
+       var floor time.Duration
+       for attempt := 0; attempt < 8; attempt++ {
+               d := calculateBackoff(attempt, minWait, maxWait)
+
+               for i := 0; i < 2000; i++ {
+                       assert.GreaterOrEqual(t, applyJitter(d, minWait, 
maxWait), floor,
+                               "attempt %d may not be allowed to wait less 
than an earlier attempt", attempt)
+               }
+
+               if d < maxWait {
+                       floor = d
+               }
+       }
+}
+
+// The specific boundary above, pinned so a future change to the floor cannot
+// quietly reintroduce the dip without failing on the exact numbers.
+func TestApplyJitterAtCapFloorsAtTheLastUncappedInterval(t *testing.T) {
+       minWait := 100 * time.Millisecond
+       maxWait := time.Second
+
+       assert.Equal(t, 800*time.Millisecond, calculateBackoff(3, minWait, 
maxWait),
+               "the last interval before the cap")
+       assert.Equal(t, maxWait, calculateBackoff(4, minWait, maxWait),
+               "the first interval at the cap")
+
+       for i := 0; i < 2000; i++ {
+               assert.GreaterOrEqual(t, applyJitter(maxWait, minWait, 
maxWait), 800*time.Millisecond,
+                       "the spread at the cap must not reach below the last 
uncapped interval")
+       }
+}
+
+// A caller may configure minWait above maxWait; options.go accepts it, and
+// calculateBackoff resolves it by returning maxWait. The downward spread must 
not
+// then draw below the configured minimum. Flooring at half the interval would
+// permit a third of it, because the replay loop cannot run when minWait is 
already
+// past the cap.
+func TestApplyJitterHonoursMinWaitAboveMaxWait(t *testing.T) {
+       minWait := 90 * time.Second
+       maxWait := 60 * time.Second
+
+       d := calculateBackoff(0, minWait, maxWait)
+       require.Equal(t, maxWait, d, "calculateBackoff resolves this 
configuration to the cap")
+
+       for i := 0; i < 2000; i++ {
+               got := applyJitter(d, minWait, maxWait)
+               assert.Equal(t, maxWait, got,
+                       "with minWait past the cap the wait cannot be spread at 
all without breaking it")
+       }
+}
+
+// Exercises the wiring at the acquireLocks call site rather than the helper 
alone,
+// and pins the aggregate delay. The schedule is 10ms, 20ms, 40ms, 80ms, and 
each
+// draw adds up to one further interval, so the total falls in [150ms, 300ms]. 
Only
+// the lower bound is asserted tightly; the ceiling is loose because a busy CI 
box
+// can add arbitrary scheduling delay on top, and a flaky timing test is worse 
than
+// no timing test.
+func TestAcquireLocksAggregateRetryDelayStaysWithinBound(t *testing.T) {
+       mockClient := new(mockHiveClient)
+       ctx := context.Background()
+       opts := NewHiveOptions()
+       opts.LockMinWaitTime = 10 * time.Millisecond
+       opts.LockMaxWaitTime = time.Second
+       opts.LockRetries = 4
+
+       mockClient.On("Lock", ctx, 
mock.AnythingOfType("*hive_metastore.LockRequest")).
+               Return(&hive_metastore.LockResponse{
+                       Lockid: 901,
+                       State:  hive_metastore.LockState_WAITING,
+               }, nil)
+       mockClient.On("CheckLock", ctx, int64(901)).
+               Return(&hive_metastore.LockResponse{
+                       Lockid: 901,
+                       State:  hive_metastore.LockState_WAITING,
+               }, nil)
+       mockClient.On("Unlock", mock.Anything, int64(901)).Return(nil)
+
+       start := time.Now()
+       lock, err := acquireLocks(ctx, mockClient,
+               []tableLockIdentifier{{database: "testdb", table: 
"testtable"}}, opts)
+       elapsed := time.Since(start)
+
+       require.Error(t, err)
+       require.Nil(t, lock)
+       assert.ErrorIs(t, err, ErrLockAcquisitionFailed)
+
+       assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond,

Review Comment:
   This asserts real elapsed wall-clock time, so on a busy CI box the >= 150ms 
lower bound can flake, and the test sleeps for real every run. The additive 
never-shorter property is already covered by 
TestApplyJitterBelowCapNeverShorterThanInput without touching the clock.
   
   I'd drop the timing assertions and keep this as a wiring check that's 
count-based (assert CheckLock was called exactly 4 times) so we exercise the 
call site without the clock dependency.



##########
catalog/hive/lock.go:
##########
@@ -160,6 +165,62 @@ func calculateBackoff(attempt int, minWait, maxWait 
time.Duration) time.Duration
        return minWait << attempt
 }
 
+// applyJitter spreads a backoff interval so clients contending for the same 
lock
+// stop re-polling in lockstep. calculateBackoff is a pure function of the 
attempt
+// and the configured bounds, and contention is the precondition for retrying 
at
+// all, so without this every waiter issues its CheckLock calls at the same 
instants
+// and each round reaches the metastore as a burst.
+//
+// The invariants, in the order the code establishes them:
+//   - below the cap the jitter is added rather than centred, so the wait is 
never
+//     shorter than the interval calculateBackoff produced;
+//   - at the cap there is no headroom left to add into, so the wait is spread
+//     downward instead, floored at the last interval the sequence produced 
before
+//     it saturated — a bound the schedule has already cleared, which stops a 
later
+//     attempt from being allowed to wait less than an earlier one;
+//   - the result never exceeds maxWait and never falls below minWait.
+func applyJitter(d, minWait, maxWait time.Duration) time.Duration {
+       if d <= 0 {
+               return d
+       }
+
+       // A caller that hands in an interval already past the cap is outside 
the
+       // contract; leave it exactly as given rather than silently reshaping 
it.
+       headroom := maxWait - d
+       if headroom < 0 {
+               return d
+       }
+
+       // Add up to another full interval, without exceeding the configured 
maximum.
+       extra := d
+       if headroom < extra {
+               extra = headroom
+       }
+       if extra > 0 {
+               return d + time.Duration(rand.Int64N(int64(extra)+1))

Review Comment:
   Adding up to a full interval here gives us [d, 2d] below the cap. Java's 
Tasks.exponentialBackoff only jitters by ~10% of the current delay, so a mixed 
Java/Go fleet hitting the same HMS will spread quite differently, with Go 
clients sitting in a window twice as wide.
   
   I'm fine with the wider spread if it's deliberate, I'd just call the 
divergence out in the doc comment so the next person doesn't assume we're 
matching Java. wdyt?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to