700 lines
18 KiB
Markdown
700 lines
18 KiB
Markdown
# Remediation Action Plan
|
|
|
|
## Quick Reference
|
|
|
|
| Priority | Count | Effort | Timeline | Status |
|
|
|----------|-------|--------|----------|--------|
|
|
| Critical | 3 | High | Week 1 | 🔴 Not Started |
|
|
| High | 7 | High | Week 2-3 | 🔴 Not Started |
|
|
| Medium | 30 | Medium | Sprint 2-3 | 🔴 Not Started |
|
|
| Low | 10 | Low | Ongoing | 🔴 Not Started |
|
|
|
|
---
|
|
|
|
## Phase 1: Critical Issues (Week 1)
|
|
|
|
### CRITICAL-1: Unbounded Memory Growth
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 4-6 hours
|
|
**File**: `internal/service/service.go`
|
|
|
|
#### Implementation Steps:
|
|
|
|
1. Create retention policy configuration:
|
|
```go
|
|
// In config.go
|
|
type NotificationRetentionConfig struct {
|
|
Enabled bool `mapstructure:"enabled"`
|
|
TTL time.Duration `mapstructure:"ttl"` // Default: 7 days
|
|
CheckFrequency time.Duration `mapstructure:"check_frequency"` // Default: 1 hour
|
|
MaxSize int `mapstructure:"max_size"` // Default: 100,000
|
|
}
|
|
```
|
|
|
|
2. Add to service initialization:
|
|
```go
|
|
// In service.go
|
|
type NotificationService struct {
|
|
// ... existing fields ...
|
|
retentionConfig *config.NotificationRetentionConfig
|
|
cleanupDone chan struct{}
|
|
}
|
|
|
|
func NewNotificationService(
|
|
factory domain.NotifierFactory,
|
|
q domain.Queue,
|
|
workerCount int,
|
|
cfg *config.Config,
|
|
retentionCfg *config.NotificationRetentionConfig,
|
|
logger *logging.Logger,
|
|
) *NotificationService {
|
|
// ... existing code ...
|
|
svc := &NotificationService{
|
|
// ... initialization ...
|
|
retentionConfig: retentionCfg,
|
|
cleanupDone: make(chan struct{}),
|
|
}
|
|
|
|
// Start cleanup goroutine if enabled
|
|
if retentionCfg.Enabled {
|
|
go svc.cleanupLoop()
|
|
}
|
|
|
|
return svc
|
|
}
|
|
|
|
func (s *NotificationService) cleanupLoop() {
|
|
ticker := time.NewTicker(s.retentionConfig.CheckFrequency)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-s.stopChan:
|
|
return
|
|
case <-ticker.C:
|
|
s.cleanupExpiredNotifications()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *NotificationService) cleanupExpiredNotifications() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-s.retentionConfig.TTL)
|
|
count := 0
|
|
|
|
for id, notif := range s.notifications {
|
|
if notif.CreatedAt.Before(cutoff) {
|
|
delete(s.notifications, id)
|
|
count++
|
|
}
|
|
}
|
|
|
|
// Also check max size
|
|
if len(s.notifications) > s.retentionConfig.MaxSize {
|
|
// Sort by creation time and remove oldest
|
|
var notifs []*domain.Notification
|
|
for _, n := range s.notifications {
|
|
notifs = append(notifs, n)
|
|
}
|
|
sort.Slice(notifs, func(i, j int) bool {
|
|
return notifs[i].CreatedAt.Before(notifs[j].CreatedAt)
|
|
})
|
|
|
|
toRemove := len(notifs) - s.retentionConfig.MaxSize
|
|
for i := 0; i < toRemove; i++ {
|
|
delete(s.notifications, notifs[i].ID)
|
|
count++
|
|
}
|
|
}
|
|
|
|
if count > 0 {
|
|
s.logger.Infof("Cleaned up %d expired notifications", count)
|
|
}
|
|
}
|
|
|
|
func (s *NotificationService) Stop() error {
|
|
// ... existing stop code ...
|
|
<-s.cleanupDone // Wait for cleanup to finish
|
|
return nil
|
|
}
|
|
```
|
|
|
|
3. Update configuration defaults:
|
|
```yaml
|
|
# config.yaml
|
|
notification:
|
|
retention:
|
|
enabled: true
|
|
ttl: 168h # 7 days
|
|
check_frequency: 1h
|
|
max_size: 100000
|
|
```
|
|
|
|
4. Add tests:
|
|
```go
|
|
func TestNotificationCleanup(t *testing.T) {
|
|
// Test TTL-based removal
|
|
// Test max size enforcement
|
|
// Test cleanup frequency
|
|
}
|
|
```
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] Notifications older than TTL are removed
|
|
- [ ] Maximum size limit is enforced
|
|
- [ ] Cleanup runs at specified frequency
|
|
- [ ] Memory doesn't grow indefinitely
|
|
- [ ] Tests pass with 100% coverage
|
|
|
|
---
|
|
|
|
### CRITICAL-2: Remove TLS Verification Bypass
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 2-3 hours
|
|
**File**: `internal/notifier/ntfy.go`
|
|
|
|
#### Implementation Steps:
|
|
|
|
1. Update NtfyConfig:
|
|
```go
|
|
// Remove InsecureSkipVerify, add custom CA support
|
|
type NtfyConfig struct {
|
|
ServerURL string `mapstructure:"server_url"`
|
|
Token string `mapstructure:"token"`
|
|
Username string `mapstructure:"username"`
|
|
Password string `mapstructure:"password"`
|
|
DefaultTopic string `mapstructure:"default_topic"`
|
|
// REMOVED: InsecureSkipVerify bool
|
|
|
|
// ADD: Custom CA certificate support
|
|
CACertPath string `mapstructure:"ca_cert_path"` // Path to CA cert file
|
|
Default bool `mapstructure:"default"`
|
|
AllowedRoles []string `mapstructure:"allowed_roles"`
|
|
}
|
|
|
|
func (nc *NtfyConfig) Validate() error {
|
|
if nc.ServerURL == "" {
|
|
return fmt.Errorf("server_url is required")
|
|
}
|
|
if nc.Token == "" && (nc.Username == "" || nc.Password == "") {
|
|
return fmt.Errorf("either token or username/password required")
|
|
}
|
|
// CACertPath is optional but if provided, must exist
|
|
if nc.CACertPath != "" {
|
|
if _, err := os.Stat(nc.CACertPath); err != nil {
|
|
return fmt.Errorf("ca_cert_path file not found: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
2. Update HTTP client creation:
|
|
```go
|
|
func NewNtfyNotifier(config *NtfyConfig) (*NtfyNotifier, error) {
|
|
if config == nil {
|
|
return nil, fmt.Errorf("ntfy config is required")
|
|
}
|
|
|
|
if err := config.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpClient, err := createNtfyHTTPClient(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
notifier := &NtfyNotifier{
|
|
config: config,
|
|
httpClient: httpClient,
|
|
}
|
|
notifier.BaseNotifier.notificationType = domain.TypeNtfy
|
|
return notifier, nil
|
|
}
|
|
|
|
func createNtfyHTTPClient(config *NtfyConfig) (*http.Client, error) {
|
|
var tlsConfig *tls.Config
|
|
|
|
if config.CACertPath != "" {
|
|
caCert, err := ioutil.ReadFile(config.CACertPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read CA cert: %w", err)
|
|
}
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
if !caCertPool.AppendCertsFromPEM(caCert) {
|
|
return nil, fmt.Errorf("failed to parse CA cert")
|
|
}
|
|
|
|
tlsConfig = &tls.Config{
|
|
RootCAs: caCertPool,
|
|
}
|
|
} else {
|
|
// Use system default CA pool
|
|
tlsConfig = &tls.Config{}
|
|
}
|
|
|
|
return &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: tlsConfig,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
},
|
|
}, nil
|
|
}
|
|
```
|
|
|
|
3. Update documentation:
|
|
```markdown
|
|
## TLS Configuration
|
|
|
|
### System Default (Recommended)
|
|
```yaml
|
|
notifiers:
|
|
ntfy:
|
|
default:
|
|
server_url: "https://ntfy.sh"
|
|
token: "your-token"
|
|
# Uses system CA certificates automatically
|
|
```
|
|
|
|
### Custom CA Certificate (Self-Signed)
|
|
```yaml
|
|
notifiers:
|
|
ntfy:
|
|
default:
|
|
server_url: "https://internal-ntfy.example.com"
|
|
token: "your-token"
|
|
ca_cert_path: "/etc/certs/ca.pem"
|
|
```
|
|
|
|
**IMPORTANT**: TLS verification is ALWAYS enabled. Insecure self-signed certificates cannot be accepted without providing a valid CA certificate.
|
|
```
|
|
|
|
4. Add tests:
|
|
```go
|
|
func TestNtfyTLSValidation(t *testing.T) {
|
|
// Test that invalid certs are rejected
|
|
// Test custom CA cert acceptance
|
|
// Test system CA pool usage
|
|
}
|
|
```
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] InsecureSkipVerify option removed
|
|
- [ ] Custom CA certificate support works
|
|
- [ ] TLS validation always enabled
|
|
- [ ] Error messages clear when certs invalid
|
|
- [ ] Documentation updated
|
|
- [ ] Tests pass
|
|
|
|
---
|
|
|
|
### CRITICAL-3: Fix CORS Configuration
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 2-3 hours
|
|
**File**: `api/rest/router.go`
|
|
|
|
#### Implementation Steps:
|
|
|
|
1. Update CORS middleware:
|
|
```go
|
|
type CORSConfig struct {
|
|
AllowedOrigins []string
|
|
AllowedMethods []string
|
|
AllowedHeaders []string
|
|
AllowCredentials bool
|
|
MaxAge int
|
|
}
|
|
|
|
func newCORSMiddleware(config *CORSConfig) func(http.Handler) http.Handler {
|
|
// Build origin map for O(1) lookup
|
|
originMap := make(map[string]bool)
|
|
for _, origin := range config.AllowedOrigins {
|
|
originMap[origin] = true
|
|
}
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
|
|
// Check if origin is allowed
|
|
if origin != "" && originMap[origin] {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
if config.AllowCredentials {
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
|
|
w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
|
|
w.Header().Set("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
|
|
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, corsConfig *CORSConfig) *mux.Router {
|
|
handler := NewHandler(service, logger)
|
|
router := mux.NewRouter()
|
|
|
|
// API v1 routes with CORS
|
|
v1 := router.PathPrefix("/api/v1").Subrouter()
|
|
v1.Use(newCORSMiddleware(corsConfig))
|
|
|
|
// ... rest of router setup ...
|
|
}
|
|
```
|
|
|
|
2. Add configuration:
|
|
```yaml
|
|
server:
|
|
cors:
|
|
allowed_origins:
|
|
- "https://example.com"
|
|
- "https://app.example.com"
|
|
allowed_methods:
|
|
- "GET"
|
|
- "POST"
|
|
- "OPTIONS"
|
|
- "DELETE"
|
|
allowed_headers:
|
|
- "Content-Type"
|
|
- "Authorization"
|
|
allow_credentials: true
|
|
max_age: 3600
|
|
```
|
|
|
|
3. Update main.go:
|
|
```go
|
|
corsConfig := &rest.CORSConfig{
|
|
AllowedOrigins: cfg.Server.CORS.AllowedOrigins,
|
|
AllowedMethods: cfg.Server.CORS.AllowedMethods,
|
|
AllowedHeaders: cfg.Server.CORS.AllowedHeaders,
|
|
AllowCredentials: cfg.Server.CORS.AllowCredentials,
|
|
MaxAge: cfg.Server.CORS.MaxAge,
|
|
}
|
|
|
|
restServer := startRESTServer(ctx, &wg, cfg, svc, logger, authStore, corsConfig)
|
|
```
|
|
|
|
4. Add tests and validation:
|
|
```go
|
|
func TestCORSOriginValidation(t *testing.T) {
|
|
// Test allowed origins accepted
|
|
// Test disallowed origins rejected
|
|
// Test preflight requests
|
|
// Test credentials header handling
|
|
}
|
|
```
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] CORS origins are configurable
|
|
- [ ] Wildcard (*) is not accepted
|
|
- [ ] Only configured origins are allowed
|
|
- [ ] Preflight requests handled correctly
|
|
- [ ] Configuration validated at startup
|
|
- [ ] Tests pass
|
|
|
|
---
|
|
|
|
## Phase 2: High Priority Issues (Week 2-3)
|
|
|
|
### HIGH-1: Add Request Size Limits
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 2 hours
|
|
**File**: `api/rest/handlers.go`
|
|
|
|
**Steps**:
|
|
1. Add constant: `const MaxRequestSize = 10 * 1024 * 1024 // 10MB`
|
|
2. Update SendNotification handler: Add `http.MaxBytesReader()`
|
|
3. Update SendBatchNotifications handler: Add `http.MaxBytesReader()`
|
|
4. Add configuration option for max size
|
|
5. Add tests for size limit enforcement
|
|
|
|
### HIGH-2: Implement Sharded Locks
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 6-8 hours
|
|
**File**: `internal/service/service.go`
|
|
|
|
**Steps**:
|
|
1. Create sharded storage structure
|
|
2. Implement shard index function (fnv hash)
|
|
3. Update all notification operations
|
|
4. Add benchmarks comparing to original
|
|
5. Add concurrency tests
|
|
|
|
### HIGH-3: Fix Lock Ordering Issues
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 4 hours
|
|
**File**: `internal/queue/local.go`
|
|
|
|
**Steps**:
|
|
1. Release locks before channel operations
|
|
2. Copy references while holding locks
|
|
3. Add timeout for channel operations
|
|
4. Add deadlock detection tests
|
|
|
|
### HIGH-4: Separate Service Concerns
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 16-20 hours
|
|
**File**: `internal/service/service.go` + new files
|
|
|
|
**Steps**:
|
|
1. Create `internal/repository/repository.go`
|
|
2. Create `internal/filter/filter.go`
|
|
3. Create `internal/stats/stats.go`
|
|
4. Update service to use these components
|
|
5. Add comprehensive tests
|
|
|
|
### HIGH-5: Fix Filtering Algorithm
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 3 hours
|
|
**File**: `internal/service/service.go`
|
|
|
|
**Steps**:
|
|
1. Replace nested loops with map-based lookup
|
|
2. Add benchmarks
|
|
3. Update tests
|
|
4. Document O(n) vs O(n*m) improvement
|
|
|
|
### HIGH-6: Fix RWMutex Usage in Factory
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 2 hours
|
|
**File**: `internal/notifier/notifier.go`
|
|
|
|
**Steps**:
|
|
1. Copy keys under lock
|
|
2. Process outside lock
|
|
3. Add tests for concurrent access
|
|
|
|
### HIGH-7: Fix Goroutine Lifecycle
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 4 hours
|
|
**File**: `internal/service/service.go`
|
|
|
|
**Steps**:
|
|
1. Add workerDoneChan
|
|
2. Implement graceful shutdown
|
|
3. Add timeout for worker stoppage
|
|
4. Add stress tests
|
|
|
|
---
|
|
|
|
## Phase 3: Medium Priority (Sprint 2-3)
|
|
|
|
### MEDIUM-1: Add Custom Error Types
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 4 hours
|
|
**File**: New `internal/errors/errors.go`
|
|
|
|
**Implementation**:
|
|
```go
|
|
// errors.go
|
|
var (
|
|
ErrNotFound = errors.New("notification not found")
|
|
ErrQueueClosed = errors.New("queue is closed")
|
|
ErrNotifierNotFound = errors.New("notifier not found")
|
|
ErrRateLimited = errors.New("rate limit exceeded")
|
|
ErrInvalidConfig = errors.New("invalid configuration")
|
|
)
|
|
|
|
// Use in code:
|
|
if err := doSomething(); err != nil {
|
|
if errors.Is(err, ErrQueueClosed) {
|
|
// Handle specific error
|
|
}
|
|
}
|
|
```
|
|
|
|
### MEDIUM-2: Add Structured Logging
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 8-10 hours
|
|
**File**: `internal/logging/logger.go`
|
|
|
|
**Implementation**:
|
|
- Migrate from custom logger to `log/slog` (Go 1.21+)
|
|
- Support JSON output
|
|
- Add structured fields
|
|
- Update all log calls
|
|
|
|
### MEDIUM-3: Extract Logger Interface
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 3 hours
|
|
|
|
**Implementation**:
|
|
```go
|
|
type Logger interface {
|
|
Debug(msg string, keysAndValues ...interface{})
|
|
Info(msg string, keysAndValues ...interface{})
|
|
Warn(msg string, keysAndValues ...interface{})
|
|
Error(msg string, keysAndValues ...interface{})
|
|
}
|
|
```
|
|
|
|
### MEDIUM-4: Add Input Validation
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 6 hours
|
|
|
|
**Add validation for**:
|
|
- Email addresses (use `net/mail`)
|
|
- URLs (use `url.Parse()`)
|
|
- Domain checks
|
|
- Recipient limits
|
|
- Message size limits
|
|
|
|
### MEDIUM-5: Add Configuration Validation
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 4 hours
|
|
**File**: `internal/config/config.go`
|
|
|
|
**Validate**:
|
|
- Worker count > 0
|
|
- Queue size > 0
|
|
- Timeouts reasonable
|
|
- Port ranges valid
|
|
|
|
---
|
|
|
|
## Phase 4: Low Priority (Ongoing)
|
|
|
|
### LOW-1: Add Package Documentation
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 8 hours
|
|
**Action**: Create `doc.go` in each package
|
|
|
|
### LOW-2: Consistent Naming
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 2 hours
|
|
**Action**:
|
|
- Use `svc` for service receivers
|
|
- Use `n` for notifier receivers
|
|
- Use `h` for handler receivers
|
|
|
|
### LOW-3: Remove Unused Code
|
|
**Status**: 🔴 Not Started
|
|
**Effort**: 1 hour
|
|
**Action**:
|
|
- Remove unused config fields
|
|
- Remove TODO comments
|
|
- Remove stub implementations
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests to Add
|
|
```
|
|
- [x] Notification TTL/cleanup
|
|
- [x] CORS origin validation
|
|
- [x] Request size limits
|
|
- [x] Sharded lock functionality
|
|
- [x] Filter algorithm performance
|
|
- [x] Error type handling
|
|
- [x] Configuration validation
|
|
- [x] Custom error type usage
|
|
```
|
|
|
|
### Integration Tests to Add
|
|
```
|
|
- [x] End-to-end with authentication
|
|
- [x] Concurrent notifications
|
|
- [x] Graceful shutdown
|
|
- [x] TLS certificate validation
|
|
- [x] Rate limiting boundaries
|
|
- [x] Queue overflow handling
|
|
```
|
|
|
|
### Benchmarks to Add
|
|
```
|
|
- [x] Filtering performance (nested vs map)
|
|
- [x] Lock contention (single vs sharded)
|
|
- [x] Memory usage over time
|
|
- [x] Concurrent operations
|
|
```
|
|
|
|
---
|
|
|
|
## Deployment Checklist
|
|
|
|
Before deploying each phase:
|
|
|
|
- [ ] All tests pass
|
|
- [ ] No race condition warnings
|
|
- [ ] Code reviewed
|
|
- [ ] Documentation updated
|
|
- [ ] Backward compatibility verified
|
|
- [ ] Performance benchmarks acceptable
|
|
- [ ] Security review completed
|
|
- [ ] Monitoring alerts configured
|
|
|
|
---
|
|
|
|
## Timeline
|
|
|
|
| Phase | Effort | Timeline | Status |
|
|
|-------|--------|----------|--------|
|
|
| Phase 1 (Critical) | 20 hours | Week 1 | 🔴 Planned |
|
|
| Phase 2 (High) | 40 hours | Week 2-3 | 🔴 Planned |
|
|
| Phase 3 (Medium) | 40 hours | Sprint 2-3 | 🔴 Planned |
|
|
| Phase 4 (Low) | 20 hours | Ongoing | 🔴 Planned |
|
|
| **Total** | **120 hours** | **4-6 weeks** | |
|
|
|
|
---
|
|
|
|
## Success Metrics
|
|
|
|
After remediation:
|
|
|
|
- [ ] Zero memory leaks in long-running tests
|
|
- [ ] 95%+ latency unchanged under load
|
|
- [ ] 16x improvement in lock contention
|
|
- [ ] 100% TLS validation in place
|
|
- [ ] All critical security issues resolved
|
|
- [ ] 90%+ test coverage
|
|
- [ ] Structured logging enabled
|
|
- [ ] Custom error types in use
|
|
- [ ] Configuration validation at startup
|
|
- [ ] Documentation complete
|
|
|
|
---
|
|
|
|
## Notes for Implementation
|
|
|
|
1. **Backward Compatibility**: Each phase should maintain backward compatibility
|
|
2. **Gradual Rollout**: Test thoroughly before deploying to production
|
|
3. **Monitoring**: Add metrics to detect improvements
|
|
4. **Documentation**: Update docs with each change
|
|
5. **Code Review**: Require review for security-critical changes
|
|
6. **Testing**: Add tests before implementation where possible
|
|
|
|
---
|
|
|
|
## Questions & Clarifications
|
|
|
|
1. **Configuration retention TTL**: Should this be per-notification or global?
|
|
- Recommendation: Global with override per environment
|
|
|
|
2. **CORS origins**: Should these be environment-specific?
|
|
- Recommendation: Yes, different for dev/staging/prod
|
|
|
|
3. **Custom error types**: Should these be exported?
|
|
- Recommendation: Yes, for consumers to use `errors.Is()`
|
|
|
|
4. **Logging migration**: Breaking change or gradual?
|
|
- Recommendation: Gradual, add structured logging alongside current
|
|
|
|
5. **Sharded locks**: How many shards optimal?
|
|
- Recommendation: Start with 16, benchmark for your use case
|