Skip to content

Conversation

@elijah-rou
Copy link

No description provided.

⚠️  This commit does not compile - it's a checkpoint during refactoring.

Phase 1 Complete: Struct definition and capacity calculation

Changed:
- Replaced podTrackers map with pods []podTracker value array
- Replaced assignedTrackers atomic pointer with direct array access
- Added podIndexByIP map[string]int for O(1) IP lookups
- Replaced podTrackersMux with podsMux (clearer naming)
- Removed numActivators, activatorIndex (no more sharding)
- Removed trackerPool (will eliminate filtering entirely)

New struct:
- pods []podTracker - Pre-allocated value array (2× max-scale)
- podLength int - Active pod count
- podIndexByIP map[string]int - IP → index lookup
- podsMux sync.RWMutex - Guards all three

Capacity calculation:
- Renamed: computeInitialPoolCapacity → computeTrackerArrayCapacity
- Now returns 2× base capacity for worst case (n draining + n starting)
- Memory: 5 MB for 10K max-scale (negligible)

Documentation:
- VALUE_ARRAY_REFACTORING.md - Complete implementation plan
- DATA_STRUCTURE_ANALYSIS.md - Current architecture analysis
- REMAINING_ALLOCATIONS.md - Post-optimization allocation sources
- WHY_NOT_SYNC_COND.md - Context cancellation challenges

Next: Phase 2 - Update state_manager.go for array operations
Remaining: ~7.5 hours of implementation + testing
This commit completes the core implementation of the value array
refactoring (Phases 2-5). Production code is complete and compiles.
Tests are partially updated but still have compilation errors.

## Completed Phases

**Phase 2: State Manager Operations**
- processMutatePod(): Array + index lookup, swap-and-shrink
- processRemovePod(): Swap-and-shrink pattern
- processCleanupStalePods(): Backward iteration
- recalculateFromEndpointsLocked(): Two-pass compaction
- updatePodTrackerMetrics(): Array iteration

**Phase 3: Remove Filtering & Pool**
- Deleted filterAvailableTrackers() (~45 lines)
- Deleted assignSlice() (~50 lines)
- Deleted recomputeAssignedTrackers() (~20 lines)
- Removed trackerPool, assignedTrackers, numActivators, activatorIndex

**Phase 4: Update Routing Path**
- acquireDest(): RLock + pass pods[:podLength] to LB
- updateCapacityLocked(): Simplified to cc * podLength
- handlePubEpsUpdate(): No-op (activator tracking removed)

**Phase 5: LB Policies**
- Updated signature: func(ctx, nowUnix, []podTracker) (func(), *podTracker)
- All policies check state inline: state != podReady && state != podRecovering
- randomLBPolicy, randomChoice2Policy, firstAvailableLBPolicy
- newRoundRobinPolicy, leastConnectionsPolicy

## Status

✅ Production code compiles (go build ./pkg/activator/net)
⚠️  Tests partially updated - compilation errors remain
🔄 Next: Complete test updates (Phase 6)

## Impact

- Code reduction: ~660 lines deleted
- Eliminated 3-layer architecture (map→slice→filter)
- Single value array with inline state checking
- No more filtering step or pool allocations
- Simpler capacity calculation
…x [CHECKPOINT]

Completed Phase 6 of value array refactoring with comprehensive test updates
and discovered/fixed a critical bug in new pod validation logic.

Test Updates (5 files, +289/-126 lines):
- Rewrote TestThrottlerUpdateCapacity for simplified capacity calculation
- Rewrote TestThrottlerErrorOneTimesOut for new architecture
- Removed 3 obsolete tests (activator assignment, assignSlice - deleted features)
- Fixed all LB policy calls: added nowUnix parameter, changed to value arrays
- Updated struct literals: podTrackers map → pods array + podIndexByIP
- Fixed newTestTracker signature: *queue.Breaker → breaker interface
- Fixed makeTrackers: added infinite breaker support for cc==0
- Removed "clumping test" (tested deleted activator assignment behavior)
- Fixed tracker access patterns in qp_authority_test.go and qp_validation_test.go

Critical Bug Fix (state_manager.go):
- Bug: New pods claiming "ready" as first event had tracker created BEFORE validation
- Impact: Incomplete trackers left in array during async validation
- Fix: Moved validation check BEFORE tracker creation (lines 366-371)
- Validation now properly blocks tracker creation until success

Test Results:
- ✅ All tests pass without race detector: 9.5s
- ✅ All tests pass WITH race detector: 15.1s
- ✅ Well under 30s budget

Next: Phase 7 - Stress testing and integration validation
Ported benchmarks from baseline branch and measured performance gains
from value array refactoring.

Benchmark Results:

HOT PATH (acquireDest @ 100 pods):
- OLD: 303.5 ns/op, 920 B/op, 2 allocs/op
- NEW: 52.96 ns/op, 24 B/op, 1 alloc/op
- GAIN: 5.7x faster, 38x less memory, 50% fewer allocations

HOT PATH SCALING:
- OLD: O(n) - scales linearly with pod count (86 ns → 1,258 ns for 10-500 pods)
- NEW: O(1) - constant time ~53 ns regardless of pod count
- Eliminated filtering step entirely - RWMutex cost negligible

COLD PATH (Add Pod):
- OLD: 200,522 ns/op, 38,969 B/op, 42 allocs/op
- NEW: 8,042 ns/op, 4,196 B/op, 24 allocs/op
- GAIN: 25x faster, 9.3x less memory, 42% fewer allocations

NET IMPROVEMENT (Full routing path @ 100 pods):
- OLD: acquireDest (303 ns) + filter (257 ns) = 560 ns total
- NEW: acquireDest (53 ns) = 53 ns total
- GAIN: 10.6x faster end-to-end

Trade-off: Endpoint recalculation with 500+ pods is slower due to
compaction algorithm, but this is a rare cold path operation.

The refactoring achieves its primary goal: dramatic hot path optimization.
Eliminated 16x performance regression in endpoint recalculation through
two key optimizations: logging and map allocation.

Optimization 1: Summary logging (avoid per-pod logs)
- Replaced 2 Debugw calls per pod with single summary Debugw
- At 500 pods: 1000 log calls → 1 log call
- Result: 17x faster, 25x less memory, 8.8x fewer allocations

Optimization 2: In-place map mutation (avoid allocation)
- Changed from: allocate new map + copy all entries
- Changed to: delete removed entries, update/add in-place
- Eliminated 269 MB map allocation overhead
- Result: 2x faster, 2.4x less memory, 14x fewer allocations

Combined Results (500 pods):
- Before: 2,053,727 ns/op, 1,592,548 B/op, 7,792 allocs/op
- After: 57,305 ns/op, 26,314 B/op, 64 allocs/op
- Improvement: 36x faster, 60x less memory, 122x fewer allocations

vs Baseline (old architecture):
- Baseline: 123,513 ns/op, 25,013 B/op, 33 allocs/op
- Optimized: 57,305 ns/op, 26,314 B/op, 64 allocs/op
- Improvement: 2.15x faster, similar memory, 2x allocations

Trade-off: 2x more allocations than baseline (64 vs 33) from:
- atomic.Value string boxing
- Struct copying overhead
- Acceptable given cold path nature and hot path gains

Changes:
- state_manager.go: Added stats tracking, summary logging, in-place map mutation
- All tests pass (9.2s without race, 15.5s with race)
…ld path

Eliminates struct copying overhead by allowing holes in the value array.
Maintains cache locality while dramatically improving recalculation performance.

Key changes:
- Sparse array: pods update in-place, no movement during recalculate
- Lazy compaction: only compact when >50% fragmented
- Empty slot detection: LB policies skip holes (dest == "")
- Bounded iteration: hot path scans [0:podLength] with max index tracking

Performance results at 100 pods:
- Cold path: 40µs → 27µs (33% faster)
- Hot path: ~57ns unchanged (O(1) maintained)
- Zero copying in common case (compaction rare)

Benefits:
- Value array cache locality preserved (contiguous memory)
- No copying overhead during normal operation
- Protection against pathological fragmentation
- Simpler algorithm (no multi-pass compaction in hot path)

Trade-offs:
- Hot path: One extra `if dest == ""` check per iteration (~negligible)
- Occasional compaction when fragmentation >50% (amortized cost)
Extract the hardcoded 0.5 fragmentation threshold into a named constant
at the top of state_manager.go for easier tuning. This addresses the PR #62
review concern about hardcoded thresholds potentially being suboptimal for
varied workloads.

The constant includes documentation explaining:
- What it controls (sparse array compaction triggering)
- How to adjust it for different scenarios
- Trade-offs between memory and CPU usage

Default remains 0.5 (50% fragmentation) but can now be easily changed
without searching through the code.
Fixed inconsistent time unit usage in panic recovery system that was causing
infinite worker restart loops, resulting in 10-minute test timeouts.

The panic timestamp was stored as seconds but retrieved as if it were
microseconds, causing time.Since() to return garbage values and preventing
proper exponential backoff.

Changes:
- state_manager.go:140: Store panic time using UnixMicro() instead of Unix()
- revision_throttler.go:365: Reconstruct time using UnixMicro() instead of Unix()

This ensures consistent microsecond precision throughout the panic tracking
system and fixes the worker health check logic.

Tests now pass in 15.6s instead of timing out at 600s.
…entation

- Rename fragmentationThreshold → trailingFragmentationThreshold to clearly indicate we only trim trailing empty slots
- Expand comments explaining that interior holes (empty slots between live pods) are NOT compacted
  - Interior compaction is intentionally deferred as it costs O(n) pointer updates
  - Interior holes are implicitly filled when new pods are added at rt.podLength
- Update log message from 'Compacted sparse array' to 'Trimmed trailing empty slots' for clarity
- Add variable 'trailingEmptySlots' to make the calculation more explicit
- This improves code clarity for future maintainers about the sparse array design tradeoff
…ctoring design

- Add comprehensive 'Sparse Array Memory Management' section explaining:
  - Pre-allocation at 2× max-scale capacity
  - Trailing compaction controlled by trailingFragmentationThreshold (50% default)
  - Interior holes intentionally left in place (lazy reuse, not expensive compaction)
  - Memory math: ~500KB per 1000-pod revision, negligible overhead
  - Performance tradeoffs and observability

- Update 'Value Array Refactoring' to recent changes section:
  - 5-10x faster routing hot path (52.96 ns/op @ 100 pods)
  - 10.6x faster end-to-end request routing
  - ~660 lines of code removed (simplified architecture)

- Update File Structure to reflect current sparse array design:
  - Value array `[]podTracker` with `podIndexByIP` map for O(1) lookups
  - Inline state checks in LB policies (no separate filtering step)
  - RWMutex guards pod array for safe concurrent reads

- Update Performance Optimizations section:
  - Document sparse array architecture improvement
  - O(1) hot path vs O(n) baseline
  - Memory efficiency: 24 B/op vs 920 B/op at 100 pods

- Expand Testing Considerations with sparse array and LB policy testing guidance:
  - Verify trailingFragmentationThreshold behavior
  - Verify interior holes are NOT compacted (intentional)
  - Verify all LB policies check state inline
  - Verify no allocation overhead

All changes align with commit 015ac57 (value array refactoring) and f7b8a40 (sparse array clarification)
The defer callback in try() was reading tracker.id after the state worker
could have zeroed the slot via releaseSlotLocked(). This caused a data race
when goroutines raced to read/write the same struct field.

Fix: Capture tracker.id immediately when the tracker pointer is valid,
before the defer callback executes. Store it in trackerId variable and
reference the captured copy in the defer callback.

This ensures the defer callback never reads from a potentially-zeroed struct,
eliminating the race condition while preserving all functionality.

Verified: All activator/net tests pass with -race flag.
The qp_authority_test.go TestMain was setting a global mock for
podReadyCheckFunc to always return nil (health checks always pass),
but never restoring the original function before os.Exit(). This caused
the mock to persist and interfere with stale_tracker_test.go tests,
which need the real podReadyCheck function to detect IP reuse.

Changes:
- Save the original podReadyCheckFunc before mocking it
- Restore the original function after m.Run() completes
- Ensures proper test isolation between test files

This fixes the occasional "IP reuse detected" error logs appearing
in unit tests when they shouldn't, and ensures IP reuse detection
works correctly in all test scenarios.

All tests pass with -race flag after this fix.
@elijah-rou elijah-rou changed the title Perf/sparse array no compaction Perf/sparse array lazy compaction Nov 17, 2025
Changed the fragmentation calculation to include both interior holes and
trailing empty slots, providing a more accurate metric for overall memory
waste in the sparse array. The compaction strategy remains unchanged (still
only trims trailing slots), but the threshold measurement now reflects the
true memory overhead.

- Calculate totalEmptySlots (interior holes + trailing)
- Measure total fragmentation ratio against this value
- Updated log field to total-fragmentation-ratio
- Added clarifying comment on compaction strategy
@CerebriumAI CerebriumAI deleted a comment from claude bot Nov 17, 2025
@claude
Copy link

claude bot commented Nov 17, 2025

Comprehensive Code Review - PR #63: Sparse Array with Lazy Compaction

Executive Summary

This PR implements a major architectural refactoring of the activator's pod tracking system, replacing a 3-layer architecture (map→slice→filter) with a sparse value array. The changes deliver impressive 5-10x performance improvements on the hot path while maintaining correctness. Overall, this is excellent work with strong attention to detail, comprehensive testing, and thorough documentation.

Recommendation: ✅ APPROVE with minor suggestions


🎯 Architecture & Design: EXCELLENT

Strengths

  1. Sparse Array Design - The value array with lazy compaction is well-thought-out:

    • Pre-allocation at 2× max-scale capacity handles worst-case rollout scenarios
    • Interior holes are intentionally NOT compacted (smart trade-off)
    • Trailing slot trimming with configurable threshold (50% default) balances memory vs CPU
    • O(1) hot path maintained regardless of fragmentation
  2. Clear Separation of Concerns:

    • allocateSlotLocked() - slot allocation with hole reuse
    • releaseSlotLocked() - slot zeroing with trailing trimming
    • trimPodLengthLocked() - compact trailing empty slots
    • Each function has single responsibility with clear contracts
  3. Lock-Free Hot Path:

    • RWMutex allows concurrent reads during routing
    • LB policies check state inline (no separate filtering step)
    • Stable addresses in pre-allocated array enable safe pointer returns

🐛 Potential Bugs & Issues

1. Race Condition in allocateSlotLocked() - CRITICAL

Location: state_manager.go:502-517

func (rt *revisionThrottler) allocateSlotLocked() (int, bool) {
    for i := range rt.pods[:rt.podLength] {
        if rt.pods[i].dest == "" {  // ← Check
            return i, true              // ← Return (TOCTOU gap!)
        }
    }
    // ...
}

Problem: Time-of-check-to-time-of-use (TOCTOU) issue. Between checking dest == "" and returning the index, another concurrent operation could modify that slot if the lock is released and re-acquired.

Impact: Slot could be double-allocated if caller doesn't immediately fill it.

Fix: Callers already hold write lock during entire allocation+fill sequence, so this is already protected. However, add assertion to make this explicit:

func (rt *revisionThrottler) allocateSlotLocked() (int, bool) {
    // MUST be called while holding podsMux write lock
    // Caller is responsible for filling the slot immediately
    for i := range rt.pods[:rt.podLength] {
        if rt.pods[i].dest == "" {
            return i, true
        }
    }
    // ...
}

Verification: Checked processMutatePod:380-406 - write lock is held throughout allocation and fill. ✅ Safe.


2. Integer Overflow in Capacity Calculation

Location: revision_throttler.go:133-150

func computeTrackerArrayCapacity(annotations map[string]string) int {
    // ...
    return 2 * baseCapacity  // ← Potential overflow if baseCapacity is large
}

Problem: If baseCapacity is close to math.MaxInt/2, multiplication by 2 could overflow.

Impact: Low probability in practice (would require max-scale > 1 billion), but violates defensive programming.

Fix:

func computeTrackerArrayCapacity(annotations map[string]string) int {
    // ...
    const maxCapacity = 1<<30 // 1 billion pods (reasonable upper bound)
    if baseCapacity > maxCapacity/2 {
        return maxCapacity
    }
    return 2 * baseCapacity
}

3. Potential Nil Pointer Dereference in LB Policies

Location: lb_policy.go:43-73 (randomLBPolicy and others)

func randomLBPolicy(ctx context.Context, nowUnix int64, targets []podTracker) (func(), *podTracker) {
    // ...
    tracker := &targets[idx]  // ← Taking address of value in slice
    
    if tracker.dest == "" {
        continue  // Skip empty slots
    }
    // ...
    return cb, tracker  // ← Returning pointer to array element
}

Analysis: This is SAFE because:

  • pods []podTracker is pre-allocated and never reallocated (stable addresses)
  • Documentation confirms: "Never reallocated after creation - addresses are stable" (revision_throttler.go:69)
  • Pointers remain valid for the lifetime of the revisionThrottler

Suggestion: Add runtime assertion in debug builds to verify stability:

// In newRevisionThrottler() after allocating pods array:
if rt.logger != nil {
    originalAddr := &rt.pods[0]
    defer func() {
        if &rt.pods[0] != originalAddr {
            rt.logger.Fatal("pods array was reallocated - breaks pointer stability invariant!")
        }
    }()
}

🚀 Performance: EXCELLENT

Benchmarks Analysis

The performance gains are impressive and well-documented:

Metric Baseline (Old) Optimized (New) Improvement
Hot path @ 100 pods 303.5 ns/op 52.96 ns/op 5.7x faster
Memory per request 920 B/op 24 B/op 38x less
Allocations 2 allocs/op 1 alloc/op 50% fewer
End-to-end routing 560 ns (filter+route) 53 ns 10.6x faster

Performance Concerns

  1. Linear Scan in allocateSlotLocked(): state_manager.go:502-506

    • Scans [0:podLength] to find empty slots
    • Worst case: O(n) when many interior holes exist
    • Acceptable because:
      • Only runs during pod creation (cold path)
      • podLength is typically < 100 pods
      • Adding/removing pods is rare vs routing requests
    • Optimization idea (future): Maintain free list for O(1) allocation
  2. Trailing Trimming Overhead: state_manager.go:533-542

    • Called on every releaseSlotLocked()
    • Scans backwards from podLength
    • Acceptable because:
      • Early exit when non-empty slot found
      • Only runs during pod removal (cold path)

🧪 Test Coverage: EXCELLENT

Coverage Analysis

  1. New Test File: revision_throttler_sparse_array_test.go

    • ✅ Tests capacity calculation with routable vs non-routable pods
    • ✅ Tests hole reuse via allocateSlotLocked()
    • ✅ Tests empty slot skipping in resetTrackersLocked()
  2. Existing Tests Updated:

    • ✅ All 20 test files updated to work with value array
    • ✅ Race detector passes (15.6s runtime)
    • ✅ Comprehensive coverage of all operating modes

Missing Test Cases

  1. Fragmentation Edge Cases:

    // Suggested test in revision_throttler_sparse_array_test.go
    func TestFragmentationThresholdTriggering(t *testing.T) {
        // Test that trimming only occurs when > 50% fragmented
        // Test that interior holes are NOT trimmed
        // Test memory is reclaimed after scale-down
    }
  2. Concurrent Allocation/Release:

    func TestConcurrentSlotOperations(t *testing.T) {
        // Goroutine 1: allocate slots
        // Goroutine 2: release slots
        // Verify no double-allocation or lost slots
    }

🔒 Security & Correctness

Strengths

  1. Panic Recovery: state_manager.go:136-156

    • Worker panics are caught and logged
    • Supervisor restarts with exponential backoff
    • In-flight requests are signaled to prevent hangs
    • ✅ Well-designed failure handling
  2. IP Reuse Detection: state_manager.go:336-342

    • Validates tracker ID on validated-ready events
    • Prevents stale IPs from being promoted to ready
    • ✅ Robust against IP reuse scenarios
  3. Time Unit Consistency: Fixed in commit fd6076b1

    • All timestamps use UnixMicro() consistently
    • ✅ Prevents time calculation bugs

Concerns

  1. Potential Memory Leak in waitForTrackerDrain(): state_manager.go:602-640

    func (rt *revisionThrottler) waitForTrackerDrain(idx int, trackerID string) {
        ticker := time.NewTicker(10 * time.Millisecond)
        defer ticker.Stop()
        
        for {
            if rt.closed.Load() {
                return  // ← Early exit, but tracker not cleaned up
            }
            // ...
        }
    }

    Issue: If throttler closes while draining, slot is never released.

    Fix:

    defer func() {
        if rt.closed.Load() {
            // Clean up on forced exit
            rt.podsMux.Lock()
            if idx < rt.podLength && rt.pods[idx].id == trackerID {
                rt.releaseSlotLocked(idx)
            }
            rt.podsMux.Unlock()
        }
    }()

📝 Code Quality & Style: EXCELLENT

Strengths

  1. Documentation:

    • Comprehensive inline comments explain design trade-offs
    • CLAUDE.md updated with sparse array architecture
    • Commit messages are detailed and informative
  2. Magic Number Extraction:

    • trailingFragmentationThreshold extracted as constant
    • PodNotReadyStaleThreshold extracted (10 minutes)
    • ✅ Improves maintainability
  3. Consistent Naming:

    • pods[], podLength, podIndexByIP - clear and consistent
    • allocateSlotLocked(), releaseSlotLocked() - verb-noun pattern

Minor Style Issues

  1. Comment Redundancy: state_manager.go:499-500

    // allocateSlotLocked returns an index that can store a pod tracker.
    // Preference order: reuse an empty slot, otherwise extend podLength if capacity allows.

    Second line is redundant - code is self-documenting.

  2. Variable Shadowing: state_manager.go:941

    slot, ok := rt.allocateSlotLocked()

    Consider idx, ok for consistency with other usage.


📊 Metrics & Observability: EXCELLENT

Metrics Added/Updated

  1. stateUpdateQueueTime - Tracks queue saturation
  2. stateUpdateProcessingTime - Identifies slow operations
  3. stateWorkerPanics - Monitors panic frequency
  4. qpReadyValidations - Tracks validation success/failure
  5. podStateTransitions - Audit trail of state changes

Suggestions

  1. Add Fragmentation Metric:
    sparseArrayFragmentation = promauto.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "activator_sparse_array_fragmentation_ratio",
            Help: "Ratio of empty slots to total slots in sparse array",
        },
        []string{"namespace", "revision"},
    )
    Update in updateCapacityLocked():
    emptySlots := 0
    for i := range rt.pods[:rt.podLength] {
        if rt.pods[i].dest == "" {
            emptySlots++
        }
    }
    fragmentation := float64(emptySlots) / float64(rt.podLength)
    sparseArrayFragmentation.WithLabelValues(rt.revID.Namespace, rt.revID.Name).Set(fragmentation)

🎓 Documentation: EXCELLENT

CLAUDE.md Updates

The documentation updates are thorough and well-organized:

  1. ✅ Sparse Array Memory Management section added
  2. ✅ Performance numbers documented with benchmarks
  3. ✅ Testing considerations expanded
  4. ✅ File structure reflects modular design

Minor Suggestions

  1. Add Memory Overhead Example:
    ## Sparse Array Memory Overhead
    
    Example calculation for 1000-pod max-scale revision:
    - Array capacity: 2 × 1000 = 2000 slots
    - Per-slot size: ~250 bytes (podTracker struct)
    - Total memory: 2000 × 250 = 500 KB per revision
    - At 10 pods active: 1990 empty slots (99.5% fragmentation)
    - Memory "waste": Still only 500 KB (negligible)

✅ Final Checklist

  • Code compiles without errors
  • All tests pass (with and without race detector)
  • Performance benchmarks show significant improvement
  • Documentation updated (CLAUDE.md, inline comments)
  • No security vulnerabilities identified
  • Metrics and observability comprehensive
  • Code follows repository conventions
  • Race conditions addressed with proper synchronization
  • Panic recovery handles edge cases
  • IP reuse detection prevents stale routing

🎉 Summary

This PR is a masterclass in performance optimization with:

  • 5-10x faster hot path
  • 660 lines of code removed
  • Maintained correctness and test coverage
  • Comprehensive documentation
  • Well-designed failure handling

The few issues identified are minor and mostly involve defensive programming improvements.

Great work! 🚀


Recommended Actions

  1. Before Merge (Optional but recommended):

    • Add overflow protection in computeTrackerArrayCapacity()
    • Add memory leak fix in waitForTrackerDrain()
    • Add fragmentation metric for production monitoring
  2. After Merge (Future improvements):

    • Monitor fragmentation metric in production
    • Consider free list optimization for allocateSlotLocked() if pod churn is high
    • Add concurrent stress tests for slot operations

Reviewed with reference to: CLAUDE.md guidelines, Knative best practices, and Go concurrency patterns.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants