Skip to content

Add atomic set configuration map entry endpoint - #515

Open
mgaffigan wants to merge 5 commits into
OpenIntegrationEngine:mainfrom
mgaffigan:feat/configuration-map-entry-endpoint
Open

mgaffigan wants to merge 5 commits into
OpenIntegrationEngine:mainfrom
mgaffigan:feat/configuration-map-entry-endpoint

Conversation

@mgaffigan

@mgaffigan mgaffigan commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

The current API does not permit atomic writes to configuration maps. Two unsynchronized writers overwrite each other's settings. Add an atomic write endpoint.

With current endpoint:

User 1 User 2 State after
Read = { A: 1, B: 1 } Read = { A: 1, B: 1 } { A: 1, B: 1 }
Write A = 2 nop { A: 2, B: 1 }
nop Write B = 2 { A: 1, B: 2 }

With new endpoint:

User 1 User 2 State after
Read = { A: 1, B: 1 } Read = { A: 1, B: 1 } { A: 1, B: 1 }
Write A = 2 nop { A: 2, B: 1 }
nop Write B = 2 { A: 2, B: 2 }

Blocking for #449

Signed-off-by: Mitch Gaffigan <mitch.gaffigan@comcast.net>
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Test Results

131 files  +  2  131 suites  +2   3m 9s ⏱️ +28s
807 tests + 70  807 ✅ + 70  0 💤 ±0  0 ❌ ±0 
963 runs  +136  957 ✅ +136  6 💤 ±0  0 ❌ ±0 

Results for commit ccbc629. ± Comparison against base commit fbda474.

♻️ This comment has been updated with latest results.

@NicoPiel NicoPiel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Empty request body breaks the database setup. In database mode, an empty body (a null property) gets saved before the code crashes. The database then holds a bad entry. On restart, loading the config map fails and the server runs with an empty map. The endpoint should reject an empty body or blank key with a 400.

  • Keys containing / can't be updated through the new /configurationMap/{key} path.

  • "Atomic" only holds on one server. It relies on a lock inside one Java process. In database mode each server keeps its own copy of the map, loaded once. Several servers sharing one database would still overwrite each other's changes, the same problem the PR is meant to fix.

  • There's no matching delete endpoint, so removing an entry still means rewriting the whole map, with the same race.

  • The Configuration Map settings panel in the admin UI (SettingsPanelMap.java:127) still saves the whole map, so UI users still have the race.

@mgaffigan

Copy link
Copy Markdown
Contributor Author
  • Empty request body breaks the database setup. In database mode, an empty body (a null property) gets saved before the code crashes. The database then holds a bad entry. On restart, loading the config map fails and the server runs with an empty map. The endpoint should reject an empty body or blank key with a 400.

This appears to be a bug of the pre-existing endpoint. @tonygermano opened #520 to track this. The same fix is applicable both endpoints, since the validation should catch both.

  • Keys containing / can't be updated through the new /configurationMap/{key} path.

Path components should be escaped. PUT /configurationMap/foo%2Fbar sets foo/bar.

  • "Atomic" only holds on one server. It relies on a lock inside one Java process. In database mode each server keeps its own copy of the map, loaded once. Several servers sharing one database would still overwrite each other's changes, the same problem the PR is meant to fix.

Agreed - that may be the subject of future work. That's a larger architectural change than this PR contemplates, and the lore I'm aware of is that two mirth servers do not play nicely on the same database.

  • There's no matching delete endpoint, so removing an entry still means rewriting the whole map, with the same race.

Agreed - that may be the subject of future work.

  • The Configuration Map settings panel in the admin UI (SettingsPanelMap.java:127) still saves the whole map, so UI users still have the race.

Agreed - that may be the subject of future work.

PUT /configurationMap/{key} now responds with 400 Bad Request for a
blank key, as the user servlet does for a blank username. In file
mode, saving the map silently drops blank keys while they stay in
memory, so a blank key would work until the next restart and then
disappear. The key parameter description also says that a / in a key
has to be encoded as %2F.

Add a test that setting one key keeps the other entries, both in memory
and in what is saved, and a smoke test for the blank key, since the
check is in the servlet.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Tony Germano <tony@germano.name>
PUT /configurationMap/{key} takes a single ConfigurationProperty as the
request body, so give it XML and JSON examples like the neighbouring
configuration map endpoints.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Tony Germano <tony@germano.name>
ConfigurationProperty.versionOf returns the lowercase hex SHA-256 of an
entry's value, a unit separator (U+001F), and its comment, with a null
value and a null or blank comment treated as empty, and the empty
string for a missing entry. It depends only on the entry's contents, so
a client can calculate it from what the server returned and send it
back as a precondition without the API storing or returning a revision.

The unit separator is used instead of NUL because some clients can't
easily hold a NUL in a string, such as C strings and shell variables.

It is a static method rather than a getter so that it doesn't show up
as a property of ConfigurationProperty in the OpenAPI schema.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Tony Germano <tony@germano.name>
PUT /configurationMap/{key} takes an optional expectedVersion query
parameter. If it is set and doesn't match the current entry's version
from ConfigurationProperty.versionOf, nothing is changed and the
endpoint returns false. An empty string requires that the key doesn't
exist yet. Without the parameter the entry is updated unconditionally,
as before, and the endpoint returns true.

This lets two clients updating the same key detect a lost update
without storing a revision. Reporting it as a false result follows the
revision checks on channels, channel groups and code templates, which
return false instead of an error status. The check runs inside the
controller's synchronized update.

The smoke test runs the update against a live server, including
calculating the version from the entry the server returned.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Tony Germano <tony@germano.name>

@tonygermano tonygermano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the per-key endpoint is a real improvement. Right now two writers changing different keys can erase each other, and doing the read-modify-write on the server under the controller's lock fixes that. Rather than send you a list of changes, I pushed four commits to the branch. Please look them over.

Blank keys and request body examples

  • Reject blank keys. The endpoint now responds with 400 for a blank key, as UserServlet does for a blank username. That covers the blank-key half of @NicoPiel's first point. In file mode, saving drops blank keys while they stay in memory, so a blank key would work until the next restart and then disappear. The key description also says a / has to be encoded as %2F.
  • Request body examples. Added XML/JSON @ExampleObject refs for the single ConfigurationProperty body, like the neighbouring endpoints.

Stale-write check

Channels, code templates and their groups/libraries all refuse a stale save unless the caller passes override=true. The configuration map had nothing like that, so two writers on the same key still lost one update silently. A stored revision number doesn't fit, because file mode keeps the map in a hand-editable configuration.properties. The new commits use a content-based version instead:

  • ConfigurationProperty.versionOf(property) is the lowercase hex SHA-256 of the UTF-8 bytes of value + "\u001F" + comment (a unit separator rather than NUL, which C strings and shell variables can't easily hold), with a null value or a null/blank comment treated as empty, and "" for a missing entry. Blank comments count as empty because file mode saves them as null, so a restart doesn't cause false conflicts. Clients compute it from what GET /configurationMap returned, so the GET response doesn't change.
  • PUT /configurationMap/{key} takes an optional expectedVersion query parameter and now returns a boolean. If the version doesn't match, nothing is changed and it returns false, the same way updateChannel, updateChannelGroups and updateCodeTemplate report a stale revision. An empty string means "the key must not exist yet", and leaving it out keeps last-write-wins. The comparison ignores case, so clients whose hex encoders produce uppercase don't need to convert. The operation description spells out the algorithm for REST callers.
  • A query parameter is used instead of ETag/If-Match because the Client proxy only exposes response bodies, so Java callers could never read an ETag.

This changes the Client signature and return type, so it has to go in before the endpoint ships.

Tests

DefaultConfigurationControllerTest covers keeping the other entries and the version matching rules, and ConfigurationPropertyTest checks known hash values. A ConfigurationMapEntryTest smoketest covers what needs a live server: the blank key, how a null or empty expectedVersion is sent, a version calculated from the GET response, and a rejected update coming back as false. Claude ran it in both file and database mode.

As you said, #520 applies to both endpoints. I'll send the fix as a follow-up PR once this one merges.


🤖 Drafted with Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants