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.
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
- Strong Consistency: API returns only after operation is persisted and visible to subsequent
Get calls
- No Data Loss: NATS JetStream stores messages durably until acknowledged
- Better Throughput: Writes are batched and processed asynchronously, reducing I/O contention
- Reduced Lock Contention: Only one worker processes writes per user ID at a time via Queue Groups
- Automatic Crash Recovery: If a node crashes, queued messages remain in JetStream and are processed by another instance
- 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.
When running a cdperf test agains a deployment in kubernetes I see no errors when running a single api pod:
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:
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:
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
Key Components
1. Request ID & Completion Tracking
Each write operation gets a unique request ID that's tracked through the system:
2. Modified
sharecachewith Async Write + Sync Wait3. Write Worker with Completion Signaling
4. NATS JetStream Stream Configuration
Benefits of This Approach
Key Advantages
GetcallsConfiguration Recommendations for Production
Summary
This async infrastructure with synchronous API approach provides:
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.