Ensure deterministic outcomes when multiple create listener APIs are racing - #181
Conversation
… deterministic Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
| // Do the DB persistence - which includes a global uniqueness check on name in PSQL | ||
| if err := m.persistence.WriteListener(ctx, u.Spec); err != nil { | ||
| return nil, err | ||
| } | ||
| written = true | ||
|
|
There was a problem hiding this comment.
Given the break up of AddOrUpdateListener function. The Prepare and Apply steps are no longer under the same lock.
This creates a race window for any other function that mutates the map while persistence.WriteListener is running.
Therefore a concurrent delete listener request from a user can complete in that gap, but the listener will get recreated if the u (PreparedListenerUpdate) contains it.
There was a problem hiding this comment.
Yes, that is correct.
This PR I assert makes things significantly better not worse, as before the window was we didn't lock between writing potential future config to the map, and then attempting the DB update. The fundamental issue.
Now, we have two phases. Deliberately separated as with the existing locks they cannot be in the same scope.
- Prepare a safe change to the in-memory structure, without applying
- Update the database - which is the point of control for duplicate
- If successful, apply the safe change to the in-memory structure
The dual insert that was the root issue, is fully protected by this change. Hence the proposal for it.
You are correct that when I was writing this, I was concerned about the fact that a different competing action could mutate the database separately.
The key one I looked at was a delete. The other would be an edit that makes a different change - such as a dual reset to different from blocks.
The feedback is fair, and I'll look if there's a low-risk enhancement I can make to the code to cover more cases.
There was a problem hiding this comment.
Thanks again for the feedback, I went back and established it is safe from a lock hierarchy perspective to introduce a course grain lock on admin functions (without affecting the runtime scoping covered in the primary change).
Mind taking another look?
Signed-off-by: Peter Broadhurst <peter.broadhurst@kaleido.io>
Chengxuan
left a comment
There was a problem hiding this comment.
Thanks for adding the extra admin mutex. Looks great!
When multiple APIs are creating/update a listener concurrently under an event stream, there are cases where we could return the UUID of one running listener, but actually internally in the runtime be running a different one.
This PR works through a number of sub-items to protect in these situations.
Duplicate listener names were not checked until the database write
createOrUpdateListenercalledAddOrUpdateListenerfirst, which inserted into the stream's in-memory listener map and calledconnector.EventListenerAddstarting the listener in the connect.Only afterwards did
WriteListenerattempt the insert in Postgres, where there is alisteners_nameunique index.Two parallel
POST /eventstreams/{id}/listenerswith the same name were therefore both registered and started in the connector before either was checked.Outcomes:
FF00177500, both listeners were live in the connector concurrently (duplicate event delivery), and the cleanup was a best-effortRemoveListenerwhose error was only logged.Note an empty name defaults to the connector-resolved signature, so two parallel creates with identical filters and no name collide deterministically.
Runtime and connector state were updated before DB write
Any failure after
AddOrUpdateListenerhad to be unwound by removing the listener again, which is correct for a create but wrong for an update.lockedListenerUpdatehad already overwrittenl.specin place, so the rollback deleted a pre-existing listener rather than restoring it.Fix
Split
AddOrUpdateListenerinto resolve / prepare / apply, so the manager can persist between validating and applying:VerifyListenerOptionsresolves the full spec (including the defaulted name) without touching runtime state or the connector.PrepareListenerUpdatevalidates the spec against the stream and returns aPreparedListenerUpdate;Apply()makes it live.AddOrUpdateListenerkeeps its signature as a thin wrapper over the three steps, so connectors and existing callers are unaffected.New error
FF21098(Duplicate listener name '%s' used by listener '%s') returns 409 where callers previously saw a 500 or silent duplication.Names are released only after the persistence delete succeeds, and every release is guarded on the ID still holding the name, so a losing racer can never evict the winner's reservation.
Startup
restoreStreamspopulates the name index from persisted listeners. Pre-existing LevelDB duplicates warn and first-wins rather than failing startup (a hard failure would turn an upgrade into an unrecoverable boot loop).