AI conclusion but applying the workaround fixed my failing tests
I don't have time at this moment to check this diagnosis myself.
LockingResourceStore.setRepresentation() (PUT) and deleteResource() (DELETE) only acquire a write lock on the child resource, but both operations modify the parent container's metadata (dcterms:modified) via DataAccessorBasedStore.updateContainerModifiedDate(). This read-modify-write on the parent runs outside any parent lock, creating a race condition.
Two concurrent PUTs to different children in the same container can both read the parent's metadata before either writes, producing duplicate dcterms:modified triples in the SPARQL store. Any subsequent RepresentationMetadata.get(DC.terms.modified) then throws "Multiple results for http://purl.org/dc/terms/modified", returning a 500 to the client and effectively bricking the container until the duplicate is manually removed.
Affected methods in LockingResourceStore:
| Method |
Locks |
Should also lock |
addResource (POST) |
Container |
✅ Correct |
setRepresentation (PUT) |
Child only |
❌ Parent container |
deleteResource (DELETE) |
Child only |
❌ Parent container |
Root cause:
LockingResourceStore.setRepresentation() at dist/storage/LockingResourceStore.js calls this.source.setRepresentation(identifier, ...) inside this.locks.withWriteLock(identifier), where identifier is the child resource. Inside the store, DataAccessorBasedStore.writeData() (called for new resources created via PUT) calls this.updateContainerModifiedDate(parent) — this reads, modifies, and writes back the parent container's dcterms:modified — all while holding only the child's lock.
The same pattern exists in deleteResource(), which also calls updateContainerModifiedDate(parent) inside the child's lock.
Contrast: addResource() (POST) correctly locks the container (this.locks.withWriteLock(container)), so the parent metadata update is serialized.
Steps to reproduce:
- Configure CSS 8.0.0-alpha.2 with any storage backend (SPARQL, file, etc.)
- PUT two or more new resources into the same container concurrently (e.g.
Promise.all([PUT /container/a, PUT /container/b]))
- GET the container — returns 500
Expected behavior:
Either:
setRepresentation and deleteResource should acquire a write lock on the parent container before modifying its metadata, or
updateContainerModifiedDate should use an atomic DELETE/INSERT SPARQL update or equivalent atomic metadata write instead of a read-modify-write cycle
Server-side logs:
RepresentationMetadata {Primary} error: Multiple results for http://purl.org/dc/terms/modified
WrappedExpiringReadWriteLocker {Primary} error: Lock expired after 6000ms on
(The lock expiration is a downstream symptom — the long-running container metadata write is blocked by or racing with concurrent operations.)
Environment:
- CSS version:
8.0.0-alpha.2
- Affected lockers: all (
memory.json, file.json, redis.json) — the locking scope bug is in LockingResourceStore, not the locker implementation
- Storage backends: all (SPARQL, file, etc.) — the race is in the lock boundary, not the storage layer
Workaround:
Serialize PUTs and DELETEs to children of the same container (no concurrency), or switch to POST (which correctly locks the parent) where possible.
AI conclusion but applying the workaround fixed my failing tests
I don't have time at this moment to check this diagnosis myself.
LockingResourceStore.setRepresentation()(PUT) anddeleteResource()(DELETE) only acquire a write lock on the child resource, but both operations modify the parent container's metadata (dcterms:modified) viaDataAccessorBasedStore.updateContainerModifiedDate(). This read-modify-write on the parent runs outside any parent lock, creating a race condition.Two concurrent PUTs to different children in the same container can both read the parent's metadata before either writes, producing duplicate
dcterms:modifiedtriples in the SPARQL store. Any subsequentRepresentationMetadata.get(DC.terms.modified)then throws"Multiple results for http://purl.org/dc/terms/modified", returning a 500 to the client and effectively bricking the container until the duplicate is manually removed.Affected methods in
LockingResourceStore:addResource(POST)setRepresentation(PUT)deleteResource(DELETE)Root cause:
LockingResourceStore.setRepresentation()atdist/storage/LockingResourceStore.jscallsthis.source.setRepresentation(identifier, ...)insidethis.locks.withWriteLock(identifier), whereidentifieris the child resource. Inside the store,DataAccessorBasedStore.writeData()(called for new resources created via PUT) callsthis.updateContainerModifiedDate(parent)— this reads, modifies, and writes back the parent container'sdcterms:modified— all while holding only the child's lock.The same pattern exists in
deleteResource(), which also callsupdateContainerModifiedDate(parent)inside the child's lock.Contrast:
addResource()(POST) correctly locks the container (this.locks.withWriteLock(container)), so the parent metadata update is serialized.Steps to reproduce:
Promise.all([PUT /container/a, PUT /container/b]))Expected behavior:
Either:
setRepresentationanddeleteResourceshould acquire a write lock on the parent container before modifying its metadata, orupdateContainerModifiedDateshould use an atomicDELETE/INSERTSPARQL update or equivalent atomic metadata write instead of a read-modify-write cycleServer-side logs:
RepresentationMetadata {Primary} error: Multiple results for http://purl.org/dc/terms/modified
WrappedExpiringReadWriteLocker {Primary} error: Lock expired after 6000ms on
(The lock expiration is a downstream symptom — the long-running container metadata write is blocked by or racing with concurrent operations.)
Environment:
8.0.0-alpha.2memory.json,file.json,redis.json) — the locking scope bug is inLockingResourceStore, not the locker implementationWorkaround:
Serialize PUTs and DELETEs to children of the same container (no concurrency), or switch to POST (which correctly locks the parent) where possible.