Environment
SonarQube Server: 26.4.0.121862
SonarJava plugin: 8.27.0.43088
Java: 21.0.8
Build: Maven
Rule: java:S3077
Reproducer
package com.***.supremenico.module.crawl.internal.service;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import com.***.supremenico.module.crawl.api.CrawlObservationEventResponse;
import com.***.supremenico.module.crawl.api.CrawlObservationSnapshotResponse;
import com.***.supremenico.module.crawl.api.CrawlObservationStateResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Service
public class CrawlObservationService {
private static final int MAX_RECENT_EVENTS = 200;
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
private final AtomicLong sequence = new AtomicLong();
private final Object eventMonitor = new Object();
private final ArrayDeque<CrawlObservationEventResponse> recentEvents = new ArrayDeque<>();
private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private volatile CrawlObservationStateResponse state = new CrawlObservationStateResponse(
"",
false,
0,
"STARTING",
"Crawl worker has not reported state.",
null,
0,
Instant.now()
);
public CrawlObservationSnapshotResponse snapshot() {
return new CrawlObservationSnapshotResponse(Instant.now(), state, recentEvents());
}
public SseEmitter subscribe() {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
emitters.add(emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
emitter.onError(error -> emitters.remove(emitter));
send(emitter, "snapshot", snapshot());
return emitter;
}
public void workerState(
String workerId,
boolean workerEnabled,
int concurrency,
String phase,
String message,
UUID currentJobId,
int runningJobs
) {
state = new CrawlObservationStateResponse(
workerId,
workerEnabled,
concurrency,
phase,
message,
currentJobId,
runningJobs,
Instant.now()
);
broadcast("snapshot", snapshot());
}
public void event(String type, String workerId, UUID jobId, String message) {
event(type, workerId, jobId, message, Map.of());
}
public void event(String type, String workerId, UUID jobId, String message, Map<String, Object> details) {
CrawlObservationEventResponse event = new CrawlObservationEventResponse(
sequence.incrementAndGet(),
Instant.now(),
type,
workerId,
jobId,
message,
details == null ? Map.of() : new LinkedHashMap<>(details)
);
synchronized (eventMonitor) {
recentEvents.addLast(event);
while (recentEvents.size() > MAX_RECENT_EVENTS) {
recentEvents.removeFirst();
}
}
broadcast("event", event);
broadcast("snapshot", snapshot());
}
private List<CrawlObservationEventResponse> recentEvents() {
synchronized (eventMonitor) {
return List.copyOf(new ArrayList<>(recentEvents));
}
}
private void broadcast(String name, Object data) {
for (SseEmitter emitter : emitters) {
send(emitter, name, data);
}
}
private void send(SseEmitter emitter, String name, Object data) {
try {
emitter.send(SseEmitter.event().name(name).data(data));
} catch (Exception exception) {
emitters.remove(emitter);
}
}
}
package com.***.supremenico.module.crawl.api;
import java.time.Instant;
import java.util.UUID;
public record CrawlObservationStateResponse(
String workerId,
boolean workerEnabled,
int concurrency,
String phase,
String message,
UUID currentJobId,
int runningJobs,
Instant updatedAt
) {
}
S3077 reports:
Use a thread-safe type; adding
volatileis not enough to make this field thread-safe.
Why this appears to be a false positive
CrawlObservationStateResponse is immutable after construction:
- record component fields are final;
- all components are either primitives or immutable types:
boolean,int,String,UUID, andInstant; - the referenced object is never mutated;
- updates replace the complete
statereference with a newly constructed record.
volatile is used to make replacement of the immutable state snapshot visible
across threads. There is no concurrent mutation of the referenced object.
This does not imply that every record is immutable. Records containing mutable
components such as List, Map, or arrays may still require a warning.
A limited improvement would be to treat a record as immutable when every
component is a primitive, enum, or a type already recognized as immutable by
the rule.
Previous report
A nearly identical record-specific case was reported in 2023:
https://community.sonarsource.com/t/non-primitive-fields-should-not-be-volatile-spurious-bug/89068
A SonarSource representative confirmed that the usage was valid and mentioned an existing ticket. The issue is still reproducible with SonarJava 8.27.0.43088.
SONARJAVA-3804 is related but different. It fixed recognition of types explicitly annotated with @Immutable or @ThreadSafe; this case relies on the structure of the record and uses no such annotation:
https://jira.sonarsource.com/browse/SONARJAVA-3804
Could you confirm whether the ticket mentioned in the 2023 discussion is still active and whether immutable records are covered?
I may be overlooking an intended limitation of S3077, so confirmation on whether this is expected behavior or a false positive would be appreciated.