Skip to content

jsoncs3 share manager does not scale under load #2445

Description

@butonic

When running a cdperf test agains a deployment in kubernetes I see no errors when running a single api pod:

  █ TOTAL RESULTS 

    checks_total.......................: 9748    25.652572/s
    checks_succeeded...................: 100.00% 9748 out of 9748
    checks_failed......................: 0.00%   0 out of 9748

    ✓ authn -> loginPageResponse - status
    ✓ authn -> authorizationResponse - status
    ✓ authn -> accessTokenResponse - status
    ✓ client -> role.getMyDrives - status
    ✓ client -> search.searchForSharees - status
    ✓ client -> resource.getResourceProperties - status
    ✓ client -> application.createDrive - status
    ✓ client -> resource.createResource - status
    ✓ client -> drive.deactivateDrive - status
    ✓ client -> resource.downloadResource - status
    ✓ client -> tag.getTags - status -- (SKIPPED)
    ✓ client -> tag.createTag - status -- (SKIPPED)
    ✓ client -> drive.deleteDrive - status
    ✓ client -> share.createShare - status
    ✓ client -> share.deleteShare - status
    ✓ client -> resource.deleteResource - status
    ✓ client -> resource.uploadResource - status
    ✓ client -> resource.moveResource - status

    HTTP
    http_req_duration.......................................................: avg=120.86ms min=6.42ms med=40.03ms max=47.6s p(90)=100.52ms p(95)=179.88ms
      { expected_response:true }............................................: avg=120.86ms min=6.42ms med=40.03ms max=47.6s p(90)=100.52ms p(95)=179.88ms
    http_req_failed.........................................................: 0.00%  0 out of 6340
    http_reqs...............................................................: 6340   16.684172/s

    EXECUTION
    iteration_duration......................................................: avg=4.3s     min=1.02s  med=1.04s   max=1m21s p(90)=15.04s   p(95)=15.29s  
    iterations..............................................................: 5324   14.010494/s
    vus.....................................................................: 1      min=0         max=75
    vus_max.................................................................: 75     min=70        max=75

    NETWORK
    data_received...........................................................: 2.0 GB 5.3 MB/s
    data_sent...............................................................: 834 MB 2.2 MB/s




running (6m20.0s), 00/75 VUs, 5324 complete and 4 interrupted iterations
add_remove_tag_100             ✓ [======================================] 0/6 VUs    6m0s
create_remove_group_share_090  ✓ [======================================] 0/6 VUs    6m0s
create_space_080               ✓ [======================================] 0/6 VUs    6m0s
create_upload_rename_delete... ✓ [======================================] 0/6 VUs    6m0s
download_050                   ✓ [======================================] 0/6 VUs    6m0s
navigate_file_tree_020         ✓ [======================================] 0/9 VUs    6m0s
sync_client_110                ✓ [======================================] 00/18 VUs  6m0s
user_group_search_070          ✓ [======================================] 00/18 VUs  6m0s

This is using an nfs noac based rwx storage.

However, when running three pods (yes, and raising the number of VUs) the sharing requests cause the optimistic locking implementation in jsoncs3 to literally give up:

opencloud-api-6d9cfbf48-8q7m9 api {
  "level": "error",
  "service": "sharing",
  "host.name": "opencloud-api-6d9cfbf48-8q7m9",
  "pkg": "rgrpc",
  "traceid": "1178e7d2e777a5504dfe64575087be43",
  "hostname": "opencloud-api-6d9cfbf48-8q7m9",
  "userID": "6ce35cdc-b0b7-1040-8871-1ff3cb45568a",
  "shareID": "10cb695a-ecee-4cdc-a817-d9f5c5630ea8:67868a20-b0b7-1040-850b-1ff3cb45568a:00f1e3c8-e597-4f1d-98e1-1bc8b5936fc4",
  "error": "internal error: /data/spaces/jsoncs3-share-manager-metadata!jsoncs3-share-manager-metadata/groups/6ce35cdc-b0b7-1040-8871-1ff3cb45568a/received.json",
  "time": "2026-03-10T11:33:11Z",
  "message": "persisting added share failed. giving up.",
}

The shares are all done to the same group, causing congestion on the received.json file. This is not likely to happen in production environments, but we need to find a better solution here, as it leads to test failures:

  █ TOTAL RESULTS 

    checks_total.......................: 31648  81.586693/s
    checks_succeeded...................: 99.67% 31544 out of 31648
    checks_failed......................: 0.32%  104 out of 31648

    ✓ authn -> loginPageResponse - status
    ✓ authn -> authorizationResponse - status
    ✓ authn -> accessTokenResponse - status
    ✓ client -> search.searchForSharees - status
    ✓ client -> role.getMyDrives - status
    ✓ client -> resource.getResourceProperties - status
    ✓ client -> application.createDrive - status
    ✓ client -> resource.createResource - status
    ✓ client -> drive.deactivateDrive - status
    ✓ client -> resource.downloadResource - status
    ✓ client -> tag.getTags - status -- (SKIPPED)
    ✓ client -> tag.createTag - status -- (SKIPPED)
    ✓ client -> drive.deleteDrive - status
    ✗ client -> share.createShare - status
      ↳  78% — ✓ 130 / ✗ 36
    ✗ client -> share.deleteShare - status
      ↳  59% — ✓ 98 / ✗ 68
    ✓ client -> resource.deleteResource - status
    ✓ client -> resource.uploadResource - status
    ✓ client -> resource.moveResource - status

    HTTP
    http_req_duration.......................................................: avg=557.21ms min=4.28ms med=318.81ms max=1m6s  p(90)=707.97ms p(95)=1.38s 
      { expected_response:true }............................................: avg=544.86ms min=4.28ms med=318.37ms max=1m6s  p(90)=688.24ms p(95)=1.32s 
    http_req_failed.........................................................: 0.49%  104 out of 20806
    http_reqs...............................................................: 20806  53.636651/s

    EXECUTION
    iteration_duration......................................................: avg=5.36s    min=1.05s  med=1.39s    max=1m44s p(90)=15.37s   p(95)=16.84s
    iterations..............................................................: 17167  44.255522/s
    vus.....................................................................: 1      min=0            max=300
    vus_max.................................................................: 300    min=80           max=300

    NETWORK
    data_received...........................................................: 7.9 GB 20 MB/s
    data_sent...............................................................: 2.7 GB 6.9 MB/s




running (6m27.9s), 000/300 VUs, 17167 complete and 6 interrupted iterations
add_remove_tag_100             ✓ [======================================] 00/24 VUs  6m0s
create_remove_group_share_090  ✓ [======================================] 00/24 VUs  6m0s
create_space_080               ✓ [======================================] 00/24 VUs  6m0s
create_upload_rename_delete... ✓ [======================================] 00/24 VUs  6m0s
download_050                   ✓ [======================================] 00/24 VUs  6m0s
navigate_file_tree_020         ✓ [======================================] 00/36 VUs  6m0s
sync_client_110                ✓ [======================================] 00/72 VUs  6m0s
user_group_search_070          ✓ [======================================] 00/72 VUs  6m0s

Since we were discussing touching the code and using, e.g. pessimistic locking I doubt the problem will go away. Our tests are hitting a corner case ... and hard.

I think we should consider a nats jetstream based solution that uses workers to batch writes to the same files. @aduffeck @rhafer please see summary of a discussion with an AI below. I'm not as pumped about the approach as qwen3.5-27b but still.

AI discussion

Feel free to ignore AI content, but It does explain the approach I have in mind quite well.

You're describing a synchronous API over asynchronous infrastructure pattern — essentially implementing strong consistency guarantees while leveraging the throughput benefits of eventual consistency internally. This is an excellent approach that gives you the best of both worlds.

Here's how to implement it using NATS JetStream with a back-channel completion mechanism:


Architecture Overview

┌─────────────┐     ┌──────────────────┐     ┌──────────────────┐
│  jsoncs3    │────▶│ NATS JetStream   │◀────│  jsoncs3         │
│  Instance A │     │ (Write Queue)    │     │  Instance B      │
└─────────────┘     └──────────────────┘     └──────────────────┘
                         │
                         ▼
              ┌─────────────────────┐
              │ Write Workers       │
              │ (Process & Persist) │
              └─────────────────────┘
                         │
                         ▼
              ┌─────────────────────┐
              │ Completion Channel  │
              │ (Back channel for   │
              │  caller to wait)    │
              └─────────────────────┘

Key Components

1. Request ID & Completion Tracking

Each write operation gets a unique request ID that's tracked through the system:

// pkg/share/manager/jsoncs3/writequeue/types.go

type WriteOperation struct {
    RequestID string `json:"request_id"`      // Unique per API call
    UserID    string `json:"userid"`          // User/group ID for routing
    ShareID   string `json:"shareid"`         // Share being added/removed
    Operation int    `json:"operation"`       // 0=Add, 1=Remove
    Timestamp time.Time `json:"timestamp"`
}

type CompletionEvent struct {
    RequestID string `json:"request_id"`
    Success   bool   `json:"success"`
    Error     string `json:"error,omitempty"`
}

2. Modified sharecache with Async Write + Sync Wait

// sharecache.go modifications

type Cache struct {
    lockMap sync.Map
    UserShares mtimesyncedcache.Map[string, *UserShareCache]
    storage   metadata.Storage
    namespace string
    filename  string
    ttl       time.Duration
    
    // NATS JetStream for async writes and completion signaling
    js jetstream.JetStreamContext
    queueGroup string
}

func New(s metadata.Storage, namespace, filename string, ttl time.Duration) Cache {
    return Cache{
        UserShares: mtimesyncedcache.Map[string, *UserShareCache]{},
        storage:    s,
        namespace:  namespace,
        filename:   filename,
        ttl:        ttl,
        lockMap:    sync.Map{},
        // js and queueGroup initialized via config or dependency injection
    }
}

func (c *Cache) Add(ctx context.Context, userid, shareID string) error {
    ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "Add")
    defer span.End()

    // Generate unique request ID for this operation
    requestID := uuid.New().String()

    // 1. Update in-memory cache immediately (for read consistency)
    c.initializeIfNeeded(userid, "")
    us, _ := c.UserShares.LoadOrStore(userid, &UserShareCache{
        UserShares: map[string]*SpaceShareIDs{},
    })
    
    storageid, spaceid, _ := shareid.Decode(shareID)
    ssid := storageid + shareid.IDDelimiter + spaceid
    
    if us.UserShares[ssid] == nil {
        us.UserShares[ssid] = &SpaceShareIDs{IDs: map[string]struct{}{}}
    }
    us.UserShares[ssid].IDs[shareID] = struct{}{}

    // 2. Queue the write operation for durable persistence
    if c.js != nil {
        op := WriteOperation{
            RequestID: requestID,
            UserID:    userid,
            ShareID:   shareID,
            Operation: OpAddShare,
            Timestamp: time.Now(),
        }
        
        data, _ := json.Marshal(op)
        _, err := c.js.Publish(ctx, "sharecache.write."+userid, data)
        if err != nil {
            span.RecordError(err)
            return errors.Wrap(err, "failed to queue write operation")
        }

        // 3. Wait for completion via back channel (synchronous API guarantee)
        if err := c.waitForCompletion(ctx, requestID); err != nil {
            span.RecordError(err)
            return errors.Wrap(err, "write operation failed")
        }
    } else {
        // Fallback to synchronous persist if NATS not configured
        if err := c.Persist(ctx, userid); err != nil {
            span.RecordError(err)
            return errors.Wrap(err, "failed to persist share")
        }
    }

    span.SetStatus(codes.Ok, "")
    return nil
}

func (c *Cache) Remove(ctx context.Context, userid, shareID string) error {
    ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "Remove")
    defer span.End()

    requestID := uuid.New().String()

    // 1. Update in-memory cache immediately
    us, loaded := c.UserShares.LoadOrStore(userid, &UserShareCache{
        UserShares: map[string]*SpaceShareIDs{},
    })
    
    if loaded {
        storageid, spaceid, _ := shareid.Decode(shareID)
        ssid := storageid + shareid.IDDelimiter + spaceid
        
        if us.UserShares[ssid] != nil {
            delete(us.UserShares[ssid].IDs, shareID)
        }
    }

    // 2. Queue the write operation for durable persistence
    if c.js != nil {
        op := WriteOperation{
            RequestID: requestID,
            UserID:    userid,
            ShareID:   shareID,
            Operation: OpRemoveShare,
            Timestamp: time.Now(),
        }
        
        data, _ := json.Marshal(op)
        _, err := c.js.Publish(ctx, "sharecache.write."+userid, data)
        if err != nil {
            span.RecordError(err)
            return errors.Wrap(err, "failed to queue write operation")
        }

        // 3. Wait for completion via back channel (synchronous API guarantee)
        if err := c.waitForCompletion(ctx, requestID); err != nil {
            span.RecordError(err)
            return errors.Wrap(err, "write operation failed")
        }
    } else {
        // Fallback to synchronous persist if NATS not configured
        if err := c.Persist(ctx, userid); err != nil {
            span.RecordError(err)
            return errors.Wrap(err, "failed to persist removal")
        }
    }

    span.SetStatus(codes.Ok, "")
    return nil
}

// waitForCompletion blocks until the operation completes or times out
func (c *Cache) waitForCompletion(ctx context.Context, requestID string) error {
    // Create a subscription for this specific request ID on the completion channel
    sub, err := c.js.SubscribeSync("sharecache.complete."+requestID)
    if err != nil {
        return errors.Wrap(err, "failed to subscribe to completion channel")
    }
    defer sub.Unsubscribe()

    // Set up a timeout for waiting (e.g., 5 seconds max)
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    // Wait for the completion message
    msg, err := sub.NextMsgWithContext(ctx)
    if err != nil {
        return errors.Wrap(err, "timeout waiting for operation completion")
    }

    var event CompletionEvent
    if err := json.Unmarshal(msg.Data(), &event); err != nil {
        return errors.Wrap(err, "failed to unmarshal completion event")
    }

    // Verify this is the right request ID (should always be)
    if event.RequestID != requestID {
        return fmt.Errorf("unexpected request ID in completion: %s", event.RequestID)
    }

    // Return error if operation failed
    if !event.Success {
        return errors.New(event.Error)
    }

    return nil
}

3. Write Worker with Completion Signaling

// pkg/share/manager/jsoncs3/writequeue/worker.go

type WriteWorker struct {
    js jetstream.JetStreamContext
    cache *sharecache.Cache
    queueGroup string
}

func NewWriteWorker(js jetstream.JetStreamContext, cache *sharecache.Cache) *WriteWorker {
    return &WriteWorker{
        js:       js,
        cache:    cache,
        queueGroup: "sharecache-writer", // Queue group for load balancing
    }
}

func (w *WriteWorker) Start(ctx context.Context) error {
    log := appctx.GetLogger(ctx).With().Str("component", "writequeue-worker").Logger()
    
    // Subscribe to all write operations with a queue group
    sub, err := w.js.PullSubscribe("sharecache.write.*", w.queueGroup)
    if err != nil {
        return errors.Wrap(err, "failed to subscribe to write queue")
    }

    log.Info().Msg("write worker started")

    // Process messages in batches for efficiency
    go func() {
        ticker := time.NewTicker(100 * time.Millisecond)
        defer ticker.Stop()

        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                w.processBatch(ctx, sub, log)
            }
        }
    }()

    return nil
}

func (w *WriteWorker) processBatch(ctx context.Context, sub jetstream.PullSubscription, log logger.Logger) {
    // Fetch up to 100 messages at a time for efficiency
    msgs, err := sub.Fetch(100, jetstream.MaxWait(2*time.Second))
    if len(msgs) == 0 || (err != nil && !errors.Is(err, context.DeadlineExceeded)) {
        return
    }

    // Group messages by user ID for serialization
    userOps := make(map[string][]*jetstream.Msg)
    for _, msg := range msgs {
        var op WriteOperation
        if err := json.Unmarshal(msg.Data(), &op); err != nil {
            log.Error().Err(err).Msg("failed to unmarshal write operation")
            continue
        }
        userOps[op.UserID] = append(userOps[op.UserID], msg)
    }

    // Process each user's operations sequentially (no concurrent writes per user)
    for userID, ops := range userOps {
        w.processUserOperations(ctx, userID, ops, log)
    }
}

func (w *WriteWorker) processUserOperations(ctx context.Context, userID string, msgs []*jetstream.Msg, log logger.Logger) {
    // Acquire local lock for this user to ensure serialization within this instance
    unlock := w.cache.lockUser(userID)
    defer unlock()

    // Sync with server state first (optimistic locking base)
    if err := w.cache.syncWithLock(ctx, userID); err != nil {
        log.Error().Err(err).Str("userid", userID).Msg("failed to sync before processing")
        return
    }

    // Apply all operations for this user sequentially
    for _, msg := range msgs {
        var op WriteOperation
        if err := json.Unmarshal(msg.Data(), &op); err != nil {
            log.Error().Err(err).Str("userid", userID).Msg("failed to unmarshal operation")
            w.publishCompletion(op.RequestID, false, "unmarshal error")
            continue
        }

        // Apply the operation to in-memory cache (already done by caller, but ensure consistency)
        switch op.Operation {
        case OpAddShare:
            w.cache.initializeIfNeeded(userID, "")
            us, _ := w.cache.UserShares.LoadOrStore(userID, &sharecache.UserShareCache{
                UserShares: map[string]*sharecache.SpaceShareIDs{},
            })
            
            storageid, spaceid, _ := shareid.Decode(op.ShareID)
            ssid := storageid + shareid.IDDelimiter + spaceid
            
            if us.UserShares[ssid] == nil {
                us.UserShares[ssid] = &sharecache.SpaceShareIDs{IDs: map[string]struct{}{}}
            }
            us.UserShares[ssid].IDs[op.ShareID] = struct{}{}

        case OpRemoveShare:
            if us, loaded := w.cache.UserShares.Load(userID); loaded {
                storageid, spaceid, _ := shareid.Decode(op.ShareID)
                ssid := storageid + shareid.IDDelimiter + spaceid
                
                if us.UserShares[ssid] != nil {
                    delete(us.UserShares[ssid].IDs, op.ShareID)
                }
            }
        }

        // Persist to storage with optimistic locking (retry on conflict)
        persistErr := w.cache.Persist(ctx, userID)
        
        if persistErr != nil {
            log.Error().Err(persistErr).Str("userid", userID).Msg("failed to persist")
            w.publishCompletion(op.RequestID, false, persistErr.Error())
            msg.Nak() // Re-queue for retry
            continue
        }

        // Publish completion event on back channel
        w.publishCompletion(op.RequestID, true, "")
        
        // Acknowledge successful processing
        msg.Ack()
    }
}

// publishCompletion sends a completion event to the back channel
func (w *WriteWorker) publishCompletion(requestID string, success bool, errorMsg string) {
    event := CompletionEvent{
        RequestID: requestID,
        Success:   success,
        Error:     errorMsg,
    }
    
    data, _ := json.Marshal(event)
    _, err := w.js.Publish(context.Background(), "sharecache.complete."+requestID, data)
    if err != nil {
        appctx.GetLogger(context.Background()).Error().Err(err).Msg("failed to publish completion event")
    }
}

4. NATS JetStream Stream Configuration

# nats-server config or via API

streams:
  - name: "sharecache-operations"
    subjects: ["sharecache.write.*"]
    storage: file                 # File-backed for durability
    max_bytes: 1073741824         # 1GB retention
    discard_policy: old           # Drop oldest if full
    
  - name: "sharecache-completions"
    subjects: ["sharecache.complete.*"]
    storage: memory               # Memory-backed (short-lived)
    max_msgs_per_subject: 1       # Only one completion per request ID
    max_age: 60s                  # Auto-expire after 60 seconds

queue_groups:
  - name: "sharecache-writer"
    stream: "sharecache-operations"

Benefits of This Approach

Feature Optimistic Locking Pessimistic Locking Async + Sync Wait
Consistency Eventual (retry window) Strong (synchronous) Strong (synchronous API)
Data Loss Risk Low Zero Zero (JetStream durable)
Latency Medium (retries add delay) High (lock acquisition) Low (async + wait)
Throughput Medium (contention causes retries) Low (serialized writes) High (batched async processing)
Scalability Poor (thundering herd) Poor (single lock per user) Excellent (queue groups distribute load)

Key Advantages

  1. Strong Consistency: API returns only after operation is persisted and visible to subsequent Get calls
  2. No Data Loss: NATS JetStream stores messages durably until acknowledged
  3. Better Throughput: Writes are batched and processed asynchronously, reducing I/O contention
  4. Reduced Lock Contention: Only one worker processes writes per user ID at a time via Queue Groups
  5. Automatic Crash Recovery: If a node crashes, queued messages remain in JetStream and are processed by another instance
  6. Timeout Protection: Callers don't wait forever; 5-second timeout prevents hanging

Configuration Recommendations for Production

# NATS JetStream Stream Configuration
stream:
  name: "sharecache-operations"
  subjects: ["sharecache.write.*"]
  storage: file
  max_bytes: 1073741824 # 1GB retention
  discard_policy: old

completion_stream:
  name: "sharecache-completions"
  subjects: ["sharecache.complete.*"]
  storage: memory
  max_msgs_per_subject: 1
  max_age: 60s

# Queue Group for Load Balancing
queue_group: "sharecache-writer"

# Worker Configuration
max_batch_size: 100
batch_timeout: 100ms
retry_attempts: -1 # Infinite retry (JetStream handles this)

# Wait Timeout Configuration
wait_timeout: 5s   # Max time to wait for completion

Summary

This async infrastructure with synchronous API approach provides:

  • Strong consistency — no eventual consistency window visible to callers
  • Zero data loss on crash — JetStream durability + message acknowledgment
  • Better throughput — batched async processing reduces I/O contention
  • Reduced lock contention — queue groups distribute load across instances
  • Automatic recovery — queued messages persist and are processed by other nodes
  • Timeout protection — callers don't wait forever

This is the recommended solution for your use case where atomicity and data integrity are critical, but you need to handle high write contention across multiple instances while maintaining a synchronous API contract.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions