StringKeyDictionary Resulting in two clients getting an entry on the same key
Hey guys!
So we use a StringKeyDictionary for a task system whereby players all race to put an identical key (the task's nametag basically e.g. "round 1 / generateCourse") into the dictionary with a "claimModel" that they put their own client ID into
However, when we've been testing and logging it appears like the transactional side of the dictionary is failing and:
UniTask<bool> TryClaimTaskAsync(string taskKey, GSGTaskClaimModel claim)
{
var tcs = new UniTaskCompletionSource<bool>();
model.taskDictionary.Insert(taskKey, claim, success => tcs.TrySetResult(success));
return tcs.Task;
}
is coming back successful for multiple clients.
The docs appear to say pretty specifically that this shouldn't be the case so I'm thinking we're missing something fundamental here.
Any clarity on the system would be a huge help!
Happy to provide any more context you guys need
9 Replies
Hi @foundrey. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:
----------------------
You're correct that the documentation for
Reference: StringKeyDictionary
Collections Overview. Let me know if you can share more details or logs—I'm happy to help you dig deeper! ---------------------- Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: ----------------------
StringKeyDictionary states it is transactional: a model is not added to the collection until the server confirms the transaction, and if the key has been modified before your change reaches the server, your operation should be rejected. The relevant doc says:
"If any key has been modified before your change reaches the server, it will be rejected. It is up to your application to decide whether to retry the operation."Reference: StringKeyDictionary Given this, your expectation is correct: only one client should be able to successfully insert a model for a given key if they race to claim it. If multiple clients are seeing
success == true for the same key, this suggests one of the following:
1. Clients are not truly racing: There may be enough delay between the insertions that the server processes them sequentially, allowing each to succeed in turn (e.g., if the key is removed and then re-inserted quickly).
2. The key is not actually identical: Double-check that the taskKey string is byte-for-byte identical on all clients.
3. A bug or misunderstanding in the test setup: For example, if the dictionary is cleared or the key is removed between attempts, both clients could succeed if they are not truly simultaneous.
4. A possible bug in Normcore: If you have confirmed the above and are still seeing this, it may be worth reaching out to the Normcore team.
What you can do:
- Log the exact taskKey and timestamps for each insert attempt on all clients.
- Ensure you are not removing or clearing the key between attempts.
- Use the completionHandler's success value to only proceed if the insert was truly accepted.
- If you continue to see this issue, provide a minimal reproducible example to the Normcore team.
The docs are clear that the transactional guarantee should prevent multiple clients from successfully inserting on the same key at the same time, so if you are seeing otherwise, it is either a race condition not being triggered as you expect, or a deeper issue that may require Normcore support to investigate furtherReference: StringKeyDictionary
Collections Overview. Let me know if you can share more details or logs—I'm happy to help you dig deeper! ---------------------- Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: ----------------------
<@719968242445058051> Kapa.ai is still learning and improving, please let me know how I did by reacting below.
if any client receives the update from another client before it makes the change locally, it will succeed since the local state matches the server when the update was sent. how are you triggering the race between all clients at the same time?
We're either going off of your stopwatch pattern (i.e. using room time to keep everyone synced and kick off a batch of task claims after a countdown) or based on a discrete event a single client triggers (e.g. hitting a button). We've experienced the multi-successes in both scenarios
Correct me if I'm misunderstanding, what you're saying is that you can overwrite the key/value so long as your submission isn't during the same server tick as another's essentially? We started to think as much and rather embarrasingly😅 resorted to triple checking and chucked in a wait for a second to double check that you have the claim and are still getting duplicate tasks run:
bool success = await TryClaimTaskAsync(taskKey, myClaim);
if (success)
{
Debug.Log($"[StageTaskExecutor] Successfully claimed task {taskKey} as master (ID: {me}).");
await UniTask.WaitForSeconds((1));
var claimedTask = model.taskDictionary[taskKey];
if (claimedTask.taskMasterId != me) { Debug.LogWarning($"Overwritten task do not execute)"); return; } break; } await UniTask.Delay((int)(taskMasterClaimRetryDelay * 1000)); } try { if (!model.taskDictionary.TryGetValue(taskKey, out var claimNow) || claimNow.taskMasterId != me) { Debug.LogWarning($"[StageTaskExecutor] Lost mastership for {taskKey} before execution; aborting."); return; }
if (claimedTask.taskMasterId != me) { Debug.LogWarning($"Overwritten task do not execute)"); return; } break; } await UniTask.Delay((int)(taskMasterClaimRetryDelay * 1000)); } try { if (!model.taskDictionary.TryGetValue(taskKey, out var claimNow) || claimNow.taskMasterId != me) { Debug.LogWarning($"[StageTaskExecutor] Lost mastership for {taskKey} before execution; aborting."); return; }
I think you might have the wrong idea about stringkeydictionary, it's useful if you want to make sure the server got your message such as sending a message before disconnecting. If you want to prevent other people from making the same edits you can request ownership of the model instead
there’s no server tick involved but basically the way it works is “If the state of this key when you made your change matches what’s on the server, you’re good, but if not, it’s rejected so you can observe the current dictionary key state and decide what you want to do”
So if two clients see a key that has state that says “someone needs to take this over” they’ll both fight and only one will win and one will be rejected.
but if you do it based on time alone, there’s a chance you received an update from a client that beat you to it and so your change was made when you had the full picture and so the server allows it
honestly makes sense when I think about it... lowkey a miracle it seemed to work totally fine across multiple connections for a while now so I never really questioned it 😅 I can do a simple workaround and instead just have every client look at their client ID and compare to the rest of the lobby => lowest gets to do the task. The realtimeAvatarManager.Avatars is keyed by clientID right?
Yeah I think it is I'll go with that
Really appreciate the help guys you're always quick to answer at whatever time it's wild hahaha
my go to simple solution is lowest client id
or just have everyone race but only if their current local state says no one else has won yet. if you do that, all will be rejected except one client