A ResourceValidator<MyResource> that checks the (fieldA, fieldB) pair against every other resource of the type fails on each update and on each read. validate(MyResource resource) receives only the decoded config object. It gets no name, no enabled flag, and no other metadata, so the check cannot tell "another resource holds this pair" apart from "I hold this pair." Every resource matches itself. The validation API checks one decoded config in isolation and was not designed for cross-resource rules. The permanent repair moves the check into a custom ResourceCodec, where decoding works on the raw Resource and its metadata.
Stop patching the validator: why each quick fix breaks
The validator runs whenever the config is decoded, not only when a user saves. That includes GET requests. When it returns errors on a read, the response still lists every resource with its metadata, but the config field is missing on each one.
| Symptom | Cause |
|---|---|
| Update of an unchanged or edited resource rejected as duplicate | The stored copy of the same resource is in the lookup set, and the validator has no name to exclude it |
GET returns a JSON list of resources with metadata but no config populated |
Validation runs per resource on read, and each resource flags itself |
| Create of a real duplicate passes after a "count > 1" patch | On create the conflicting resource is the only match, so the count is 1, the same count a legitimate self-match gives on update |
| Intermittent pass/fail after stashing "current resource" in a static or thread-local | Decode is not tied to a single request path, so the stashed value is stale or belongs to a different call |
Quick fixes people try first, and why they fail:
- Allow one match, reject two. This passes duplicate creates, because the new resource is not stored yet and the existing duplicate is the only hit.
-
Skip the check if the config is equal to a stored one. A duplicate is by definition equal on
fieldA/fieldB, so this skips the case you are trying to catch. -
Skip validation on reads.
validate()gets no signal telling it whether the call is a read or a write.
Restore reads first: pull uniqueness out of the validator
This is the temporary restore. Get configuration visible and editable again before you build the real fix.
- Remove the cross-resource lookup from
MyResourceValidator.validate(). - Keep only single-resource checks there: field presence, ranges, formats, and anything decidable from
MyResourcealone. - Rebuild and redeploy the module.
Check: run a GET on the resource type. Every entry must come back with its config populated. Save an existing resource without changes, and it must succeed. Duplicates are unguarded until the codec is in place, so keep this build off shared gateways or limit who can create resources.
Wire a custom ResourceCodec into the type meta
Register a codec with DefaultResourceTypeMeta.Builder::withCodec. With a custom codec you decode the raw Resource instead of receiving an already-decoded config, so the resource's name and other metadata are in scope. That name is what lets the uniqueness check exclude the resource being evaluated.
// Sketch: match method signatures to the ResourceCodec interface in your SDK build
public static final ResourceTypeMeta<MyResource> META =
ResourceTypeMeta.newBuilder(MyResource.class)
.resourceType(RESOURCE_TYPE)
.categoryName("My Settings")
.withCodec(new MyResourceCodec()) // uniqueness lives here
.withValidator(new MyResourceValidator()) // single-resource checks only
...;
Two decisions before you write the codec:
-
Delegate or hand-roll. If your build lets you get at the codec the builder would otherwise use, wrap it. Delegate decode and encode to it, and add only the uniqueness check. If it does not, you are decoding the raw resource data into
MyResourceyourself. In that case, write encode and decode together and round-trip test them. - Keep encode untouched. The check belongs on decode only. Encode must write whatever it is given.
Check: add a debug log line in decode that prints the resource name. Trigger a GET and a save, and confirm the codec runs on both and logs the correct name for each resource.
Query existing resources through the NamedResourceHandler
The codec gives you the resource's own identity. You still need the list of other resources. In this installation, a module singleton already holds the resource type's NamedResourceHandler and is the source for existing resources. Call it from the codec.
// Sketch: identifiers other than those named in this article are placeholders
public MyResource decode(/* raw Resource */ raw) throws DecodingException {
MyResource cfg = decodeConfig(raw); // delegate or hand-rolled
String selfName = /* name from raw Resource metadata */;
boolean conflict = MySingleton.handler() // wraps NamedResourceHandler
.existing() // all resources of this type
.stream()
.filter(r -> !r.name().equals(selfName)) // exclude self
.anyMatch(r -> r.fieldA().equals(cfg.fieldA())
&& r.fieldB().equals(cfg.fieldB()));
if (conflict) {
throw new ValidationException(/* message naming fieldA/fieldB and the other resource */);
}
return cfg;
}
Settle these points before you trust the lookup:
-
Recursion. If fetching existing resources through the handler decodes them with this same codec, each lookup re-enters the uniqueness check. Read the stored configs through a path that does not call back into your check, or keep a separate index of
(fieldA, fieldB) → namemaintained by the singleton. -
Disabled resources. Decide whether a disabled resource still reserves its pair. If two resources may share a pair while one is disabled, filter on
enabledin the lookup. Enabling the second one must then be treated as a write that re-runs the check. -
Null or blank fields. Decide whether two resources with empty
fieldBcollide. Apply that rule in the comparison, not in the UI.
Check: with two resources holding different pairs, log the match set for each decode. The match set must be empty for both.
Throw ValidationException so the user sees the rejection
ValidationException is a DecodingException, so throwing it from ResourceCodec::decode reports a failed decode back through the same path a validator failure uses. The user gets an error on save, not a silent drop. No separate pre-create lifecycle hook is needed; decode is the hook.
- Throw only when a resource with a different name holds the same pair.
- Name the conflicting resource in the message, so the operator knows which one to change.
- Do not catch and swallow the exception inside the codec.
Check: create resource A with pair (x, y), then try to create resource B with (x, y). The create must fail with your message, and resource B must not appear in a subsequent GET.
Close the gaps: rename, stored duplicates, concurrent saves
Name-based self-exclusion has edge cases. Test each one before calling the fix permanent.
| Case | Risk | Decision |
|---|---|---|
| Rename of a resource | The incoming name differs from the stored name, so the stored copy is no longer excluded and the rename fails as a self-duplicate | Test rename explicitly. If it fails, the singleton index must track the identity being replaced, or renames must go through a path that updates the index first |
| Duplicates already stored from before the fix | Decode now throws on read for both resources, and GET loses their config again | Find and fix existing duplicates before deploying, or make the read-path check log-only and enforce only on writes if your codec can tell them apart |
| Two creates with the same pair at the same time | Both decodes see no conflict and both commit | Serialize writes for this type in the singleton, or re-check after commit and flag the loser |
| Singleton index out of date | False pass or false conflict after edits made outside the normal save path | Rebuild the index from the NamedResourceHandler on module startup and on every resource change notification the handler exposes |
Check: rename resource A to A2 without changing its pair. The save must succeed, and a GET must show A2 with config populated and no A.
Run the end-to-end acceptance matrix
Run every row on a test gateway after the final build. Every row must pass before the module goes to production.
| # | Action | Expected result |
|---|---|---|
| 1 | GET all resources of the type | Every entry has config populated |
| 2 | Create A (x, y) | Succeeds |
| 3 | Save A unchanged | Succeeds (no self-duplicate) |
| 4 | Edit a non-key field on A | Succeeds |
| 5 | Create B (x, y) | Rejected with ValidationException message naming A |
| 6 | Create B (x, z) | Succeeds |
| 7 | Edit B to (x, y) | Rejected |
| 8 | Rename A to A2 | Succeeds |
| 9 | Disable A2, then create C (x, y) | Matches the policy chosen for disabled resources |
| 10 | Restart the gateway, then GET | All resources load with config; no decode errors in the gateway log |
Row 10 catches startup ordering problems. If the codec decodes resources before the singleton holding the NamedResourceHandler is ready, fix the initialization order. Do not skip the check when the handler is null; that turns into a silent pass.
FAQ
Why does my ResourceValidator flag every resource as a duplicate of itself?
validate() receives only the decoded config object, with no name or enabled metadata. The resource's own stored copy therefore always matches its (fieldA, fieldB) pair. Move cross-resource checks into a custom ResourceCodec, where the raw Resource with its name is available.
Why does a GET return resources without the config field?
Validation runs on read as well as write. When the validator returns errors for a resource, the response keeps the metadata but drops config. Remove the uniqueness check from the validator to restore reads immediately.
How do I return a validation error to the user from a ResourceCodec?
Throw ValidationException inside ResourceCodec::decode. It is a DecodingException, so the save fails and the message reaches the user the same way a validator failure does. Register the codec with DefaultResourceTypeMeta.Builder::withCodec.
Why does renaming a resource trigger the uniqueness error?
Self-exclusion by name compares against the incoming name. During a rename, the stored copy still carries the old name and counts as a conflict. Track the identity being replaced in your index, or update the index before the rename is decoded, then confirm with a rename test that keeps the same pair.
Stop and contact Inductive Automation official support if decode is not called on the write path in your SDK build, or if you cannot read the resource name from the raw Resource. Do the same if the NamedResourceHandler cannot be queried from inside decode without recursion. Bring the module's type meta registration, the codec source, and the gateway log lines from a failed save.