Merge remote-tracking branch 'github/main' into main

This commit is contained in:
Axel Uhl
2026-04-17 21:26:21 +02:00
12 changed files with 287 additions and 571 deletions
-400
View File
@@ -1,400 +0,0 @@
# LeaderboardPanel Selection State Refactoring Analysis
## Current Architecture
### Three Selection Models in Play
1. **`leaderboardSelectionModel`** (LeaderboardPanel:239)
- Type: `MultiSelectionModel<LeaderboardRowDTO>`
- Created at line 527
- Attached to the CellTable at line 547
- Has a `selectionChangeHandler` that syncs TO CompetitorSelectionProvider (lines 530-544)
2. **`SelectionCheckboxColumn.selectionModel`** (SelectionCheckboxColumn:51)
- Type: `RefreshableMultiSelectionModel<T>`
- Created by SelectionCheckboxColumn constructor
- **Currently NOT used by the LeaderboardPanel's CellTable**
- Has display.flush() callbacks for UI updates
3. **`CompetitorSelectionProvider`**
- Application-level selection state
- Both LeaderboardPanel and SelectionCheckboxColumn listen to it
- Notifies listeners via `addedToSelection()` / `removedFromSelection()`
### Current Selection Flow Problems
#### Flow 1: User clicks checkbox in UI
```
User clicks checkbox
SelectionCheckboxColumn handles click event
leaderboardSelectionModel.setSelected() called ← Uses WRONG selection model!
selectionChangeHandler fires (lines 530-544)
competitorSelectionProvider.setSelection() called
CompetitorSelectionProvider fires addedToSelection/removedFromSelection
BOTH LeaderboardPanel AND SelectionCheckboxColumn receive callback:
- LeaderboardPanel.addedToSelection() (line 3393)
→ calls leaderboardSelectionModel.setSelected()
- SelectionCheckboxColumn.addedToSelection() (line 2160)
→ calls getSelectionModel().setSelected() ← Different model!
```
**Problem**: Two selection models exist but only one (leaderboardSelectionModel) is attached to the table. The SelectionCheckboxColumn's RefreshableMultiSelectionModel is created but orphaned.
#### Flow 2: CompetitorSelectionProvider changes externally
```
External change to CompetitorSelectionProvider
Fires addedToSelection/removedFromSelection
BOTH listeners receive callback:
- LeaderboardPanel.addedToSelection() (line 3393)
→ leaderboardSelectionModel.setSelected(row, true)
→ Could trigger selectionChangeHandler → infinite loop potential!
- SelectionCheckboxColumn.addedToSelection() (line 2160)
→ getSelectionModel().setSelected(row, true) ← Orphaned model!
```
**Problem**: LeaderboardPanel guards against recursion by temporarily removing the handler (line 2712), but this is complex. SelectionCheckboxColumn updates an unused selection model.
### Inconsistencies Found
1. **Line 527**: LeaderboardPanel creates its own `MultiSelectionModel`
2. **Line 547**: Attaches that model to the table, ignoring SelectionCheckboxColumn's model
3. **Line 2163/2174**: SelectionCheckboxColumn callbacks update its own orphaned model
4. **Line 3396/3404**: LeaderboardPanel callbacks update the table's model
5. **SelectionCheckboxColumn.getValue()** (line 2140): Checks CompetitorSelectionProvider directly, NOT its own selection model!
## Proposed Refactoring
### Goal
Use **ONLY** the `RefreshableMultiSelectionModel` from SelectionCheckboxColumn as the single source of truth, synchronized bidirectionally with CompetitorSelectionProvider.
### Step-by-Step Plan
#### Step 1: Use SelectionCheckboxColumn's Model as Table's Model
**File**: `LeaderboardPanel.java`
**Current code** (lines 525-547):
```java
selectionCheckboxColumn = new LeaderboardSelectionCheckboxColumn(competitorSelectionProvider);
leaderboardTable.setWidth("100%");
leaderboardSelectionModel = new MultiSelectionModel<LeaderboardRowDTO>(); // ← REMOVE THIS
selectionChangeHandler = new Handler() {
@Override
public void onSelectionChange(SelectionChangeEvent event) {
List<CompetitorDTO> selection = new ArrayList<>();
for (LeaderboardRowDTO row : getSelectedRows()) {
selection.add(row.competitor);
}
LeaderboardPanel.this.competitorSelectionProvider.setSelection(selection,
/* listenersNotToNotify */LeaderboardPanel.this);
if (blurInOnSelectionChanged > 0) {
blurInOnSelectionChanged--;
blurFocusedElementAfterSelectionChange();
}
}
};
leaderboardAsTableSelectionModelRegistration = leaderboardSelectionModel
.addSelectionChangeHandler(selectionChangeHandler);
leaderboardTable.setSelectionModel(leaderboardSelectionModel, selectionCheckboxColumn.getSelectionManager());
```
**Refactored**:
```java
selectionCheckboxColumn = new LeaderboardSelectionCheckboxColumn(competitorSelectionProvider);
leaderboardTable.setWidth("100%");
// Use the SelectionCheckboxColumn's model as THE selection model
leaderboardSelectionModel = selectionCheckboxColumn.getSelectionModel();
selectionChangeHandler = new Handler() {
@Override
public void onSelectionChange(SelectionChangeEvent event) {
if (updatingSelectionFromProvider) {
// Guard: don't sync back to provider if we're currently syncing FROM provider
return;
}
List<CompetitorDTO> selection = new ArrayList<>();
for (LeaderboardRowDTO row : getSelectedRows()) {
selection.add(row.competitor);
}
LeaderboardPanel.this.competitorSelectionProvider.setSelection(selection,
/* listenersNotToNotify */LeaderboardPanel.this);
if (blurInOnSelectionChanged > 0) {
blurInOnSelectionChanged--;
blurFocusedElementAfterSelectionChange();
}
}
};
leaderboardAsTableSelectionModelRegistration = leaderboardSelectionModel
.addSelectionChangeHandler(selectionChangeHandler);
leaderboardTable.setSelectionModel(leaderboardSelectionModel, selectionCheckboxColumn.getSelectionManager());
```
**Changes**:
- Line 527: Change from `new MultiSelectionModel<>()` to `selectionCheckboxColumn.getSelectionModel()`
- Add guard flag `updatingSelectionFromProvider` to prevent recursion
- Declare field: `private boolean updatingSelectionFromProvider = false;`
#### Step 2: Update Field Declaration
**Current** (line 239):
```java
private final MultiSelectionModel<LeaderboardRowDTO> leaderboardSelectionModel;
```
**Refactored**:
```java
private final RefreshableMultiSelectionModel<LeaderboardRowDTO> leaderboardSelectionModel;
```
Add import:
```java
import com.sap.sse.gwt.client.celltable.RefreshableMultiSelectionModel;
```
#### Step 3: Fix LeaderboardPanel's Selection Callbacks
**Current** (lines 3393-3406):
```java
@Override
public void addedToSelection(CompetitorDTO competitor) {
LeaderboardRowDTO row = getRow(competitor.getIdAsString());
if (row != null) {
leaderboardSelectionModel.setSelected(row, true);
}
}
@Override
public void removedFromSelection(CompetitorDTO competitor) {
LeaderboardRowDTO row = getRow(competitor.getIdAsString());
if (row != null) {
leaderboardSelectionModel.setSelected(row, false);
}
}
```
**Refactored**:
```java
@Override
public void addedToSelection(CompetitorDTO competitor) {
LeaderboardRowDTO row = getRow(competitor.getIdAsString());
if (row != null) {
updatingSelectionFromProvider = true;
try {
leaderboardSelectionModel.setSelected(row, true);
} finally {
updatingSelectionFromProvider = false;
}
}
}
@Override
public void removedFromSelection(CompetitorDTO competitor) {
LeaderboardRowDTO row = getRow(competitor.getIdAsString());
if (row != null) {
updatingSelectionFromProvider = true;
try {
leaderboardSelectionModel.setSelected(row, false);
} finally {
updatingSelectionFromProvider = false;
}
}
}
```
**Changes**:
- Set guard flag before updating selection model
- Ensures selectionChangeHandler won't sync back to CompetitorSelectionProvider
#### Step 4: Remove SelectionCheckboxColumn's Redundant Listener
**File**: `LeaderboardPanel.java`, inner class `LeaderboardSelectionCheckboxColumn`
**Current** (lines 2120-2136):
```java
protected LeaderboardSelectionCheckboxColumn(final CompetitorSelectionProvider competitorSelectionProvider) {
super(style.getTableresources().cellTableStyle().cellTableCheckboxSelected(),
style.getTableresources().cellTableStyle().cellTableCheckboxDeselected(),
style.getTableresources().cellTableStyle().cellTableCheckboxColumnCell(),
new EntityIdentityComparator<LeaderboardRowDTO>() {
@Override
public boolean representSameEntity(LeaderboardRowDTO dto1, LeaderboardRowDTO dto2) {
return dto1.competitor.getIdAsString().equals(dto2.competitor.getIdAsString());
}
@Override
public int hashCode(LeaderboardRowDTO t) {
return t.competitor.getIdAsString().hashCode();
}
}, getData(), leaderboardTable);
competitorSelectionProvider.addCompetitorSelectionChangeListener(this); // ← REMOVE THIS
}
```
**Refactored**:
```java
protected LeaderboardSelectionCheckboxColumn(final CompetitorSelectionProvider competitorSelectionProvider) {
super(style.getTableresources().cellTableStyle().cellTableCheckboxSelected(),
style.getTableresources().cellTableStyle().cellTableCheckboxDeselected(),
style.getTableresources().cellTableStyle().cellTableCheckboxColumnCell(),
new EntityIdentityComparator<LeaderboardRowDTO>() {
@Override
public boolean representSameEntity(LeaderboardRowDTO dto1, LeaderboardRowDTO dto2) {
return dto1.competitor.getIdAsString().equals(dto2.competitor.getIdAsString());
}
@Override
public int hashCode(LeaderboardRowDTO t) {
return t.competitor.getIdAsString().hashCode();
}
}, getData(), leaderboardTable);
// REMOVED: competitorSelectionProvider.addCompetitorSelectionChangeListener(this);
// LeaderboardPanel is now the ONLY listener that syncs selection state
}
```
**Current** (lines 2160-2176, now unnecessary):
```java
@Override
public void addedToSelection(CompetitorDTO competitor) {
final LeaderboardRowDTO row = getRow(competitor.getIdAsString());
if (row != null) {
getSelectionModel().setSelected(row, true);
}
}
@Override
public void removedFromSelection(CompetitorDTO competitor) {
final LeaderboardRowDTO row = getRow(competitor.getIdAsString());
if (row != null) {
getSelectionModel().setSelected(row, false);
}
}
```
**Refactored**: Remove these methods entirely. The SelectionCheckboxColumn no longer needs to implement `CompetitorSelectionChangeListener`.
**Also remove** (line 2119):
```java
// FROM:
private class LeaderboardSelectionCheckboxColumn
extends com.sap.sse.gwt.client.celltable.SelectionCheckboxColumn<LeaderboardRowDTO>
implements CompetitorSelectionChangeListener {
// TO:
private class LeaderboardSelectionCheckboxColumn
extends com.sap.sse.gwt.client.celltable.SelectionCheckboxColumn<LeaderboardRowDTO> {
```
Remove the empty listener method implementations at lines 2144-2154.
#### Step 5: Simplify updateSelection()
**Current** (lines 2707-2721):
```java
private void updateSelection(LeaderboardRowDTO row) {
final boolean shallBeSelected = competitorSelectionProvider.isSelected(row.competitor);
if (leaderboardAsTableSelectionModelRegistration != null) {
// suspend selection events while actively adjusting the leaderboardSelectionModel to match the
// competitorSelectionProvider
leaderboardAsTableSelectionModelRegistration.removeHandler();
leaderboardAsTableSelectionModelRegistration = null;
}
if (leaderboardSelectionModel.isSelected(row) != shallBeSelected) {
leaderboardSelectionModel.setSelected(row, shallBeSelected);
}
// register the selection change handler again
leaderboardAsTableSelectionModelRegistration = leaderboardTable.getSelectionModel()
.addSelectionChangeHandler(selectionChangeHandler);
}
```
**Refactored**:
```java
private void updateSelection(LeaderboardRowDTO row) {
final boolean shallBeSelected = competitorSelectionProvider.isSelected(row.competitor);
updatingSelectionFromProvider = true;
try {
if (leaderboardSelectionModel.isSelected(row) != shallBeSelected) {
leaderboardSelectionModel.setSelected(row, shallBeSelected);
}
} finally {
updatingSelectionFromProvider = false;
}
}
```
**Changes**:
- Removed handler removal/re-registration complexity
- Use guard flag instead
- Much simpler and clearer
#### Step 6: Fix getValue() in LeaderboardSelectionCheckboxColumn
**Current** (lines 2139-2141):
```java
@Override
public Boolean getValue(LeaderboardRowDTO row) {
return competitorSelectionProvider.isSelected(row.competitor);
}
```
**Refactored**:
```java
@Override
public Boolean getValue(LeaderboardRowDTO row) {
// Use the selection model as the source of truth, not CompetitorSelectionProvider
return getSelectionModel().isSelected(row);
}
```
**Rationale**: The selection model should be the single source of truth for rendering. It stays synchronized with CompetitorSelectionProvider via the LeaderboardPanel's listeners.
### Summary of Changes
1. **LeaderboardPanel.java line 527**: Change to use `selectionCheckboxColumn.getSelectionModel()`
2. **LeaderboardPanel.java line 239**: Change type to `RefreshableMultiSelectionModel`
3. **LeaderboardPanel.java**: Add field `private boolean updatingSelectionFromProvider = false;`
4. **LeaderboardPanel.java lines 530-544**: Add guard check in selectionChangeHandler
5. **LeaderboardPanel.java lines 3393-3406**: Add guard flag around setSelected calls
6. **LeaderboardPanel.java lines 2707-2721**: Simplify updateSelection() using guard flag
7. **LeaderboardPanel.LeaderboardSelectionCheckboxColumn line 2135**: Remove listener registration
8. **LeaderboardPanel.LeaderboardSelectionCheckboxColumn line 2119**: Remove `implements CompetitorSelectionChangeListener`
9. **LeaderboardPanel.LeaderboardSelectionCheckboxColumn lines 2144-2176**: Remove listener methods
10. **LeaderboardPanel.LeaderboardSelectionCheckboxColumn line 2140**: Use selection model instead of provider
## Benefits
1. **Single Selection Model**: RefreshableMultiSelectionModel is the only selection model
2. **No Orphaned State**: Eliminates the unused selection model in SelectionCheckboxColumn
3. **Simpler Synchronization**: Single bidirectional sync point between model and provider
4. **No Handler Removal**: Guard flag is simpler than removing/re-adding handlers
5. **Clearer Ownership**: LeaderboardPanel owns synchronization, SelectionCheckboxColumn just renders
## Testing Strategy
1. Test user clicking checkboxes - selection should sync to CompetitorSelectionProvider
2. Test external CompetitorSelectionProvider changes - should update checkboxes
3. Test rapid clicking - no infinite loops or race conditions
4. Test with filtering - filtered rows should maintain correct selection state
5. Test leaderboard updates - selection should persist across data refreshes
## Potential Risks
1. **GWT Selection Model Flush Timing**: GWT selection models flush changes at end of event loop. The guard flag approach should handle this correctly since the flag is set synchronously.
2. **Multiple LeaderboardPanels**: If multiple LeaderboardPanels share the same CompetitorSelectionProvider, each will receive callbacks. This should work fine as each has its own guard flag.
3. **RefreshableMultiSelectionModel.refreshSelectionModel()**: This method has its own `dontCheckSelectionState` guard (RefreshableMultiSelectionModel.java line 131). Verify this doesn't interfere with our guard flag.
## Future Improvements
Consider enhancing RefreshableMultiSelectionModel to accept a callback for selection changes, eliminating the need for the external SelectionChangeHandler and making the synchronization more encapsulated within the model itself.
@@ -2107,7 +2107,7 @@ public class MongoObjectFactoryImpl implements MongoObjectFactory {
final Document fingerprintDoc = Document.parse(fingerprintjson.toString());
result.put(FieldNames.MANEUVER_FINGERPRINT.name(), fingerprintDoc);
storeRaceIdentifier(result, raceIdentifier);
final List<Document> maneuverDoc = storeManeuvers( maneuvers , raceIdentifier, course);
final List<Document> maneuverDoc = storeManeuvers(maneuvers , raceIdentifier, course);
result.put(FieldNames.MANEUVERS.name(), maneuverDoc);
maneuverCollection.replaceOne(query, result, new ReplaceOptions().upsert(true));
}
@@ -39,10 +39,10 @@ public class GPSFixStoreListenerTest extends AbstractGPSFixStoreTest {
Thread thread = new Thread() {
public void run() {
try {
barrier.await(100, TimeUnit.MILLISECONDS);
barrier.await(1000, TimeUnit.MILLISECONDS);
// During iteration in the main thread this causes a modification that makes the iterator throw a
// ConcurrentModificationException on next()
store.addListener((DeviceIdentifier device, GPSFixMoving fix, boolean returnManeuverChanges, boolean returnLiveDelay) -> {
store.addListener((DeviceIdentifier device, Iterable<GPSFixMoving> fixes, boolean returnManeuverChanges, boolean returnLiveDelay) -> {
return null;
}, device);
barrier.await(100, TimeUnit.MILLISECONDS);
@@ -71,9 +71,9 @@ public class GPSFixStoreListenerTest extends AbstractGPSFixStoreTest {
}
@Override
public Iterable<Triple<RegattaAndRaceIdentifier, Boolean, Duration>> fixReceived(DeviceIdentifier device, GPSFixMoving fix, boolean returnManeuverChanges, boolean returnLiveDelay) {
public Iterable<Triple<RegattaAndRaceIdentifier, Boolean, Duration>> fixesReceived(DeviceIdentifier device, Iterable<GPSFixMoving> fixes, boolean returnManeuverChanges, boolean returnLiveDelay) {
try {
barrier.await(100, TimeUnit.MILLISECONDS);
barrier.await(1000, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
throw new TimoutRuntimeException(e);
} catch (Exception e) {
@@ -139,7 +139,6 @@ public class SensorFixStoreTest {
for (int i = 0; i < fixes; i++) {
addBravoFix(device, FIX_TIMESTAMP + 1, FIX_RIDE_HEIGHT);
}
List<Double> progressData = new ArrayList<>();
store.loadFixes((fix) -> {
}, device, new MillisecondsTimePoint(FIX_TIMESTAMP - 1), new MillisecondsTimePoint(FIX_TIMESTAMP + fixes + 1),
@@ -206,161 +206,163 @@ public class FixLoaderAndTracker implements TrackingDataLoader {
private final FixReceivedListener<Timed> listener = new FixReceivedListener<Timed>() {
@Override
public Iterable<Triple<RegattaAndRaceIdentifier, Boolean, Duration>> fixReceived(DeviceIdentifier device,
Timed fix, boolean returnManeuverChanges, boolean returnLiveDelay) {
public Iterable<Triple<RegattaAndRaceIdentifier, Boolean, Duration>> fixesReceived(DeviceIdentifier device,
Iterable<Timed> fixes, boolean returnManeuverChanges, boolean returnLiveDelay) {
final Set<RegattaAndRaceIdentifier> maneuverChanged = new HashSet<>();
final Map<RegattaAndRaceIdentifier, Duration> delayToLive = new HashMap<>();
if (!preemptiveStopRequested.get() && trackedRace.getStartOfTracking() != null) {
final TimePoint timePoint = fix.getTimePoint();
deviceMappings.forEachMappingOfDeviceIncludingTimePoint(device, fix.getTimePoint(),
new Consumer<DeviceMappingWithRegattaLogEvent<WithID>>() {
@Override
public void accept(DeviceMappingWithRegattaLogEvent<WithID> mapping) {
mapping.getRegattaLogEvent().accept(new MappingEventVisitor() {
@Override
public void visit(RegattaLogDeviceCompetitorSensorDataMappingEvent event) {
recordSensorFixForCompetitor(event.getMappedTo(), event);
}
@Override
public void visit(RegattaLogDeviceBoatSensorDataMappingEvent event) {
final Boat boat = event.getMappedTo();
final Competitor competitor = trackedRace.getCompetitorOfBoat(boat);
if (competitor != null) {
recordSensorFixForCompetitor(competitor, event);
} else {
logger.log(Level.FINE, ()->"Could not record fix for boat because no competitor could be determined. Boat: " + boat);
for (final Timed fix : fixes) {
final TimePoint timePoint = fix.getTimePoint();
deviceMappings.forEachMappingOfDeviceIncludingTimePoint(device, fix.getTimePoint(),
new Consumer<DeviceMappingWithRegattaLogEvent<WithID>>() {
@Override
public void accept(DeviceMappingWithRegattaLogEvent<WithID> mapping) {
mapping.getRegattaLogEvent().accept(new MappingEventVisitor() {
@Override
public void visit(RegattaLogDeviceCompetitorSensorDataMappingEvent event) {
recordSensorFixForCompetitor(event.getMappedTo(), event);
}
}
private void recordSensorFixForCompetitor(Competitor competitor, RegattaLogDeviceMappingEvent<?> event) {
if (!preemptiveStopRequested.get()) {
@SuppressWarnings("unchecked")
SensorFixMapper<SensorFix, DynamicSensorFixTrack<Competitor, SensorFix>, Competitor> mapper = sensorFixMapperFactory
.createCompetitorMapper((Class<? extends RegattaLogDeviceMappingEvent<?>>) event.getClass());
DynamicSensorFixTrack<Competitor, SensorFix> track = mapper.getTrack(trackedRace, competitor);
if (track != null && trackedRace.isWithinStartAndEndOfTracking(fix.getTimePoint())) {
mapper.addFix(track, (DoubleVectorFix) fix);
@Override
public void visit(RegattaLogDeviceBoatSensorDataMappingEvent event) {
final Boat boat = event.getMappedTo();
final Competitor competitor = trackedRace.getCompetitorOfBoat(boat);
if (competitor != null) {
recordSensorFixForCompetitor(competitor, event);
} else {
logger.log(Level.FINE, ()->"Could not record fix for boat because no competitor could be determined. Boat: " + boat);
}
}
private void recordSensorFixForCompetitor(Competitor competitor, RegattaLogDeviceMappingEvent<?> event) {
if (!preemptiveStopRequested.get()) {
@SuppressWarnings("unchecked")
SensorFixMapper<SensorFix, DynamicSensorFixTrack<Competitor, SensorFix>, Competitor> mapper = sensorFixMapperFactory
.createCompetitorMapper((Class<? extends RegattaLogDeviceMappingEvent<?>>) event.getClass());
DynamicSensorFixTrack<Competitor, SensorFix> track = mapper.getTrack(trackedRace, competitor);
if (track != null && trackedRace.isWithinStartAndEndOfTracking(fix.getTimePoint())) {
mapper.addFix(track, (DoubleVectorFix) fix);
if (returnLiveDelay) {
delayToLive.put(trackedRace.getRaceIdentifier(), new MillisecondsDurationImpl(trackedRace.getDelayToLiveInMillis()));
}
}
}
}
@Override
public void visit(RegattaLogDeviceCompetitorMappingEvent event) {
recordForCompetitor(event.getMappedTo());
}
@Override
public void visit(RegattaLogDeviceBoatMappingEvent event) {
final Boat boat = event.getMappedTo();
final Competitor comp = trackedRace.getCompetitorOfBoat(boat);
if (comp != null) {
recordForCompetitor(comp);
} else {
// this is not necessarily something to warn of; while a boat tracker may continuously track
logger.log(Level.FINE,
()->"Could not record fix for boat because no competitor could be determined. Boat: " + boat);
}
}
private void recordForCompetitor(Competitor comp) {
if (!preemptiveStopRequested.get()) {
if (fix instanceof GPSFixMoving) {
// try to record the fix, and only if it was really to the track,
// check for maneuvers; otherwise, the fix may not have been accepted
// by the race or the track, e.g., because the race's end-of-tracking
// comes before the fix's time point
if (trackedRace.recordFix(comp, (GPSFixMoving) fix)) { // TOOD bug6229: this checks the TrackedRace's tracking interval, but for an MDI we'd also want to intersect with Event/Regatta end date if set
if (returnManeuverChanges) {
RegattaAndRaceIdentifier maneuverChangedAnswer = detectIfManeuverChanged(comp);
if (maneuverChangedAnswer != null) {
maneuverChanged.add(maneuverChangedAnswer);
}
}
if (returnLiveDelay) {
delayToLive.put(trackedRace.getRaceIdentifier(), new MillisecondsDurationImpl(trackedRace.getDelayToLiveInMillis()));
}
}
} else {
logger.log(Level.WARNING,
String.format(
"Could not add fix for competitor (%s) in race (%s), as it"
+ " is no GPSFixMoving, meaning it is missing COG/SOG values",
comp, trackedRace.getRace().getName()));
}
}
}
@Override
public void visit(RegattaLogDeviceMarkMappingEvent event) {
if (!preemptiveStopRequested.get()) {
Mark mark = event.getMappedTo();
final DynamicGPSFixTrack<Mark, GPSFix> markTrack = trackedRace.getOrCreateTrack(mark);
final GPSFix firstFixAtOrAfter;
final boolean forceFix;
if (trackedRace.isWithinStartAndEndOfTracking(fix.getTimePoint())) {
forceFix = false;
} else {
markTrack.lockForRead();
try {
if (Util.isEmpty(markTrack.getRawFixes())
|| (firstFixAtOrAfter = markTrack.getFirstFixAtOrAfter(timePoint)) != null
&& firstFixAtOrAfter.getTimePoint().equals(timePoint)) {
// either the first fix or overwriting an existing one
forceFix = true;
} else {
// checking if the given fix is "better" than an existing one
TimePoint startOfTracking = trackedRace.getStartOfTracking();
TimePoint endOfTracking = trackedRace.getEndOfTracking();
if (startOfTracking != null) {
GPSFix fixAfterStartOfTracking = markTrack
.getFirstFixAtOrAfter(startOfTracking);
if (fixAfterStartOfTracking == null
|| !trackedRace.isWithinStartAndEndOfTracking(
fixAfterStartOfTracking.getTimePoint())) {
// There is no fix in the tracking interval, so this fix could be "better"
// than ones already available in the track
// Better means closer before/after the beginning/end of the tracking
// interval
if (timePoint.before(startOfTracking)) {
// check if it is closer to the beginning of the tracking interval
GPSFix fixBeforeStartOfTracking = markTrack
.getLastFixAtOrBefore(startOfTracking);
forceFix = (fixBeforeStartOfTracking == null
|| fixBeforeStartOfTracking.getTimePoint().before(timePoint));
} else if (endOfTracking != null && timePoint.after(endOfTracking)) {
// check if it is closer to the end of the tracking interval
GPSFix fixAfterEndOfTracking = markTrack
.getFirstFixAtOrAfter(endOfTracking);
forceFix = (fixAfterEndOfTracking == null
|| fixAfterEndOfTracking.getTimePoint().after(timePoint));
} else {
forceFix = false;
}
} else {
// there is already a fix in the tracking interval
forceFix = false;
}
} else {
forceFix = false;
}
}
} finally {
markTrack.unlockAfterRead();
}
}
trackedRace.recordFix(mark, (GPSFix) fix, /* only when in tracking interval */ !forceFix);
if (returnLiveDelay) {
delayToLive.put(trackedRace.getRaceIdentifier(), new MillisecondsDurationImpl(trackedRace.getDelayToLiveInMillis()));
}
}
}
}
@Override
public void visit(RegattaLogDeviceCompetitorMappingEvent event) {
recordForCompetitor(event.getMappedTo());
}
@Override
public void visit(RegattaLogDeviceBoatMappingEvent event) {
final Boat boat = event.getMappedTo();
final Competitor comp = trackedRace.getCompetitorOfBoat(boat);
if (comp != null) {
recordForCompetitor(comp);
} else {
// this is not necessarily something to warn of; while a boat tracker may continuously track
logger.log(Level.FINE,
()->"Could not record fix for boat because no competitor could be determined. Boat: " + boat);
}
}
private void recordForCompetitor(Competitor comp) {
if (!preemptiveStopRequested.get()) {
if (fix instanceof GPSFixMoving) {
// try to record the fix, and only if it was really to the track,
// check for maneuvers; otherwise, the fix may not have been accepted
// by the race or the track, e.g., because the race's end-of-tracking
// comes before the fix's time point
if (trackedRace.recordFix(comp, (GPSFixMoving) fix)) { // TOOD bug6229: this checks the TrackedRace's tracking interval, but for an MDI we'd also want to intersect with Event/Regatta end date if set
if (returnManeuverChanges) {
RegattaAndRaceIdentifier maneuverChangedAnswer = detectIfManeuverChanged(comp);
if (maneuverChangedAnswer != null) {
maneuverChanged.add(maneuverChangedAnswer);
}
}
if (returnLiveDelay) {
delayToLive.put(trackedRace.getRaceIdentifier(), new MillisecondsDurationImpl(trackedRace.getDelayToLiveInMillis()));
}
}
} else {
logger.log(Level.WARNING,
String.format(
"Could not add fix for competitor (%s) in race (%s), as it"
+ " is no GPSFixMoving, meaning it is missing COG/SOG values",
comp, trackedRace.getRace().getName()));
}
}
}
@Override
public void visit(RegattaLogDeviceMarkMappingEvent event) {
if (!preemptiveStopRequested.get()) {
Mark mark = event.getMappedTo();
final DynamicGPSFixTrack<Mark, GPSFix> markTrack = trackedRace.getOrCreateTrack(mark);
final GPSFix firstFixAtOrAfter;
final boolean forceFix;
if (trackedRace.isWithinStartAndEndOfTracking(fix.getTimePoint())) {
forceFix = false;
} else {
markTrack.lockForRead();
try {
if (Util.isEmpty(markTrack.getRawFixes())
|| (firstFixAtOrAfter = markTrack.getFirstFixAtOrAfter(timePoint)) != null
&& firstFixAtOrAfter.getTimePoint().equals(timePoint)) {
// either the first fix or overwriting an existing one
forceFix = true;
} else {
// checking if the given fix is "better" than an existing one
TimePoint startOfTracking = trackedRace.getStartOfTracking();
TimePoint endOfTracking = trackedRace.getEndOfTracking();
if (startOfTracking != null) {
GPSFix fixAfterStartOfTracking = markTrack
.getFirstFixAtOrAfter(startOfTracking);
if (fixAfterStartOfTracking == null
|| !trackedRace.isWithinStartAndEndOfTracking(
fixAfterStartOfTracking.getTimePoint())) {
// There is no fix in the tracking interval, so this fix could be "better"
// than ones already available in the track
// Better means closer before/after the beginning/end of the tracking
// interval
if (timePoint.before(startOfTracking)) {
// check if it is closer to the beginning of the tracking interval
GPSFix fixBeforeStartOfTracking = markTrack
.getLastFixAtOrBefore(startOfTracking);
forceFix = (fixBeforeStartOfTracking == null
|| fixBeforeStartOfTracking.getTimePoint().before(timePoint));
} else if (endOfTracking != null && timePoint.after(endOfTracking)) {
// check if it is closer to the end of the tracking interval
GPSFix fixAfterEndOfTracking = markTrack
.getFirstFixAtOrAfter(endOfTracking);
forceFix = (fixAfterEndOfTracking == null
|| fixAfterEndOfTracking.getTimePoint().after(timePoint));
} else {
forceFix = false;
}
} else {
// there is already a fix in the tracking interval
forceFix = false;
}
} else {
forceFix = false;
}
}
} finally {
markTrack.unlockAfterRead();
}
}
trackedRace.recordFix(mark, (GPSFix) fix, /* only when in tracking interval */ !forceFix);
if (returnLiveDelay) {
delayToLive.put(trackedRace.getRaceIdentifier(), new MillisecondsDurationImpl(trackedRace.getDelayToLiveInMillis()));
}
}
}
});
}
});
});
}
});
}
}
return mergeManeuverChangedAndLiveDelayResult(maneuverChanged, delayToLive);
}
@@ -878,6 +880,11 @@ public class FixLoaderAndTracker implements TrackingDataLoader {
addLoadingJob(new LoadFixesForNewlyCoveredTimeRangesJob(item, newlyCoveredTimeRanges));
}
}
@Override
public String toString() {
return "FixLoaderDeviceMappings for race "+trackedRace.getRaceIdentifier();
}
}
/**
@@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -69,6 +70,39 @@ public abstract class RegattaLogDeviceMappings<ItemT extends WithID> {
private final Map<ItemT, List<DeviceMappingWithRegattaLogEvent<ItemT>>> mappings = new HashMap<>();
private final Map<DeviceIdentifier, List<DeviceMappingWithRegattaLogEvent<ItemT>>> mappingsByDevice = new HashMap<>();
/**
* A cache that holds the device mappings as the {@link Pair#getB() second} component of the values in this map,
* such that exactly these device mappings apply for any time point {@link TimeRange#includes(TimePoint) included}
* by the {@link TimeRange} that is the {@link Pair#getA() first} component of a value in this map. This map's keys
* match with the {@link DeviceMapping#getDevice() device identifiers} of the {@link Pair#getB() second} components
* of their corresponding values.
* <p>
*
* This cache is designed to work well for cases where mappings change at a frequency orders of magnitude less than
* the frequency with which fixes arrive and are to be mapped to items. Furthermore, the cache hit rates benefit
* from mappings covering large time ranges.
* <p>
*
* Any change to the mappings for a device will remove the mapping for the device's {@link DeviceIdentifier
* identifier} from this map.
* <p>
*
* Access to this map has to undergo the same locking drill as any access to {@link #mappings}, using the
* {@link #mappingsLock}.
*/
private final Map<DeviceIdentifier, Pair<TimeRange, List<DeviceMappingWithRegattaLogEvent<ItemT>>>> cachedMappings = new HashMap<>();
/**
* When the {@link #cachedMappings} are to be updated, the corresponding update job is stored in this field. It must be executed
* under the {@link #mappingsLock}'s write lock. However, when other updates to {@link #mappings} or {@link #mappingsByDevice}
* are performed (usually by the {@link #updateMappingsInternal()} method), this field will be cleared because the cache update
* will most likely be obsolete.
*/
private Runnable cacheUpdateJob;
private int cacheHits;
private int cacheMisses;
private final RegattaLogEventVisitor regattaLogEventVisitor = new BaseRegattaLogEventVisitor() {
@Override
public void visit(RegattaLogDeviceCompetitorSensorDataMappingEvent event) {
@@ -156,7 +190,13 @@ public abstract class RegattaLogDeviceMappings<ItemT extends WithID> {
/**
* Calls the given callback for every DeviceMapping that is known for the given {@link DeviceIdentifier} that
* includes the given {@link TimePoint}.
* includes the given {@link TimePoint}.<p>
*
* Searches the {@link #cachedMappings} for a match for the {@code device}; if found, checks whether {@code timePoint}
* is within the time range for which device mappings were cached, and if so, uses those device mappings. Otherwise,
* the device mappings are calculated and a cache update is carried out after releasing the read-lock of
* {@link #mappingsLock} and after obtaining its write-lock. Should an update have been squeezed in between releasing
* the read-lock and obtaining the write-lock, the cache update is not carried out.
*
* @param device
* the device to get the mappings for
@@ -169,15 +209,51 @@ public abstract class RegattaLogDeviceMappings<ItemT extends WithID> {
public void forEachMappingOfDeviceIncludingTimePoint(DeviceIdentifier device, TimePoint timePoint,
Consumer<DeviceMappingWithRegattaLogEvent<ItemT>> callback) {
LockUtil.executeWithReadLock(mappingsLock, () -> {
List<DeviceMappingWithRegattaLogEvent<ItemT>> mappingsForDevice = mappingsByDevice.get(device);
if (mappingsForDevice != null) {
for (DeviceMappingWithRegattaLogEvent<ItemT> mapping : mappingsForDevice) {
if (mapping.getTimeRange().includes(timePoint)) {
callback.accept(mapping);
final Pair<TimeRange, List<DeviceMappingWithRegattaLogEvent<ItemT>>> cachedTimeRangeForDevice = cachedMappings.get(device);
if (cachedTimeRangeForDevice != null && cachedTimeRangeForDevice.getA().includes(timePoint)) {
cacheHits++;
logger.fine(() -> "Device mapping cache hit for mapper " + this + " for device " + device
+ " and time point " + timePoint + ", included in cached range "
+ cachedTimeRangeForDevice.getA() + "; " + cacheHits + " hits, " + cacheMisses + " misses");
cachedTimeRangeForDevice.getB().forEach(mapping->callback.accept(mapping));
} else {
final List<DeviceMappingWithRegattaLogEvent<ItemT>> mappingsForDevice = mappingsByDevice.get(device);
TimeRange timeRangeForCache = null;
final List<DeviceMappingWithRegattaLogEvent<ItemT>> deviceMappingsForCache = new LinkedList<>();
cacheMisses++;
if (mappingsForDevice != null) {
for (final DeviceMappingWithRegattaLogEvent<ItemT> mapping : mappingsForDevice) {
if (mapping.getTimeRange().includes(timePoint)) {
if (timeRangeForCache == null) {
timeRangeForCache = mapping.getTimeRange();
} else {
timeRangeForCache = timeRangeForCache.intersection(mapping.getTimeRange());
}
deviceMappingsForCache.add(mapping);
callback.accept(mapping);
}
}
}
final TimeRange finalTimeRangeForCache = timeRangeForCache;
logger.fine(() -> "Device mapping cache miss for mapper " + this + " for device " + device
+ " and time point " + timePoint + ", determined cachable range "
+ finalTimeRangeForCache + "; " + cacheHits + " hits, " + cacheMisses + " misses");
if (timeRangeForCache != null) {
cacheUpdateJob = ()->cachedMappings.put(device, new Pair<>(finalTimeRangeForCache, deviceMappingsForCache));
}
}
});
if (cacheUpdateJob != null) {
LockUtil.executeWithWriteLock(mappingsLock, ()->{
if (cacheUpdateJob != null) {
logger.fine(()-> "Device mapping cache miss for mapper " + this + " performs cache update.");
cacheUpdateJob.run();
} else {
logger.fine(() -> "Device mapping cache miss for mapper " + this
+ " does not update the cache because the mappings were updated in between");
}
});
}
}
public void forEachItemAndCoveredTimeRanges(final BiConsumer<ItemT, Map<RegattaLogDeviceMappingEvent<ItemT>, MultiTimeRange>> consumer) {
@@ -245,8 +321,10 @@ public abstract class RegattaLogDeviceMappings<ItemT extends WithID> {
oldMappings.putAll(mappings);
oldDeviceIds.addAll(mappingsByDevice.keySet());
mappings.clear();
cachedMappings.clear();
mappings.putAll(newMappings);
mappingsByDevice.clear();
cacheUpdateJob = null;
for (ItemT item : newMappings.keySet()) {
for (DeviceMappingWithRegattaLogEvent<ItemT> mapping : newMappings.get(item)) {
List<DeviceMappingWithRegattaLogEvent<ItemT>> list = mappingsByDevice.get(mapping.getDevice());
@@ -1,5 +1,7 @@
package com.sap.sailing.domain.racelog.tracking;
import java.util.Collections;
import com.sap.sailing.domain.common.DeviceIdentifier;
import com.sap.sailing.domain.common.RegattaAndRaceIdentifier;
import com.sap.sse.common.Duration;
@@ -12,6 +14,7 @@ import com.sap.sse.common.Util.Triple;
* @param <FixT>
* the type of fixes this listener can consume.
*/
@FunctionalInterface
public interface FixReceivedListener<FixT extends Timed> {
/**
*
@@ -34,6 +37,32 @@ public interface FixReceivedListener<FixT extends Timed> {
* returned can be empty but is never {@code null}. It can also contain multiple identifiers if the device
* mapping is currently ambiguous.
*/
Iterable<Triple<RegattaAndRaceIdentifier, Boolean, Duration>> fixReceived(DeviceIdentifier device, FixT fix,
default Iterable<Triple<RegattaAndRaceIdentifier, Boolean, Duration>> fixReceived(DeviceIdentifier device, FixT fix,
boolean returnManeuverChanges, boolean returnLiveDelay) {
return fixesReceived(device, Collections.singleton(fix), returnManeuverChanges, returnLiveDelay);
}
/**
*
* @param device
* the device that recorded the fix. Cannot be <code>null</code>.
* @param fixes
* The fixes that were stored. Must not be <code>null</code> but may be empty
* @param returnLiveDelay
* if {@code true} then all listeners to which the fixes are forwarded shall check to which races the fix
* maps and report the live delay for the newest of those {@code fixes} for all those races as the third
* component of the resulting {@link Triple}s.
* @param returnManeuverUpdate
* if {@code true}, all listeners to which this fixes are forwarded shall check whether the fixes feed
* into a competitor's track in the scope of a race where for that competitor the maneuver list has
* changed since the last call of this type; if so, the race identifier will be part of the result, with
* the {@link Boolean} component being {@code true} for that race. Otherwise, the {@link Boolean}
* component is {@code false} or the race is not listed in the result.
* @return An {@link Iterable} with {@link RegattaAndRaceIdentifier}s is returned that will contain races with new
* maneuvers which were not available at the last time the given device stored a fix. The {@link Iterable}
* returned can be empty but is never {@code null}. It can also contain multiple identifiers if the device
* mapping is currently ambiguous.
*/
Iterable<Triple<RegattaAndRaceIdentifier, Boolean, Duration>> fixesReceived(DeviceIdentifier device, Iterable<FixT> fixes,
boolean returnManeuverChanges, boolean returnLiveDelay);
}
@@ -580,7 +580,7 @@ factor=Factor
errorUpdatingIsMedalRace=Error updating the medal race setting: {0}
maneuverLoss=Maneuver loss
averageManeuverLossInMeters=\u2205 Maneuver Loss
averageManeuverLossInMetersTooltip=The average distance loss during any mneuver.
averageManeuverLossInMetersTooltip=The average distance loss during any maneuver.
averageTackLossInMeters=\u2205 Tack Loss
averageTackLossInMetersTooltip=The average distance loss during tacks.
averageJibeLossInMeters=\u2205 Jibe Loss
@@ -637,7 +637,7 @@ polarSheetRemoveOutliersTooltip=Outliers are removed by a distance based approac
polarSheetOutlierDetectionRadius=Outlier detection neighborhood radius
polarSheetOutlierDetectionRadiusTooltip=The radius used in the distance based outlier\ndetection algorithm.
polarSheetOutlierDetectionMinimumPerc=Minimum outlier detection neighborhood percentage
polarSheetOutlierDetectionMinimumPercTooltip=The percentage of neightborhood datapoints / overall datapoints\nneeds to be at less than the given value for the point to be considered an outlier.
polarSheetOutlierDetectionMinimumPercTooltip=The percentage of neighborhood datapoints / overall datapoints\nneeds to be at less than the given value for the point to be considered an outlier.
polarSheetNumberOfHistogramColumns=Number of histogram columns per wind range
polarSheetWindSteppingInKnots=Wind stepping in knots
polarSheetWindSteppingMaxDistance=Max. distance to wind level
@@ -1021,7 +1021,7 @@ addIgtimiUser=Add Igtimi User
errorTryingToRemoveIgtimiDevice=Error trying to remove Igtimi device {0}: {1}
successfullyRemoveIgtimiDevice=Igtimi device {0} removed successfully
errorCreatingDataAccessWindow=Error creating data access window for Igtimi device {0}: {1}
successfullyCreatedIgtimiDataAccessWindow=Data access window for Igtimi devic {0} created successfully
successfullyCreatedIgtimiDataAccessWindow=Data access window for Igtimi device {0} created successfully
errorFetchingIgtimiDataAccessWindows=Error fetching Igtimi data access windows: {0}
doYouReallyWantToRemoveTheSelectedIgtimiDataAccessWindows=Do you really want to remove the selected Igtimi data access windows?
igtimiDataAccessWindows=Igtimi Data Access Windows
@@ -1525,7 +1525,7 @@ showOnlyCompetitorsOfLog=Show only competitors registered in Log
showOnlyBoatsOfLog=Show only boats registered in Log
confirmLosingCompetitorEditsWhenTogglingLogBasedView=This will reset your current, unsaved changes to the competitors.\nDo you really want to continue?
confirmLosingBoatEditsWhenTogglingLogBasedView=This will reset your current, unsaved changes to the boats.\nDo you really want to continue?
removalOfMarkDisabledMayBeUsedInRaces=Removal deavticated, marks are used in races {0}
removalOfMarkDisabledMayBeUsedInRaces=Removal deactivated, marks are used in races {0}
createDefaultLeaderboardGroup=Would you like to create a default LeaderboardGroup for your event?
pleaseCreateAtLeastOneMappingBy=Please create at least one mapping by first selecting a track on the left hand side and then selecting a mark, boat or competitor on the right hand side
matcherType=Matcher type
@@ -2464,8 +2464,8 @@ errorLoadingPolarDataForBoatClass=Error loading polar data for boat class {0}: {
videoGuide=Video guide
orcExplanation=ORC explanation
confirmNonRenewingSubscriptionTitle=Stop auto-renewal
confirmNonRenewingSubscriptionText=Do you really want to stop the auto-renewal of your subscription?\n You will loose your aquired premium privileges at the end of the current term.
subscriptionOneTimePlanLockedText=This plan may only be aquired once.
confirmNonRenewingSubscriptionText=Do you really want to stop the auto-renewal of your subscription?\n You will loose your acquired premium privileges at the end of the current term.
subscriptionOneTimePlanLockedText=This plan may only be acquired once.
errorSettingRaceToDefineItsOwnCompetitors=Error setting race for fleet {2} in column {1} of leaderboard {0} to define its own competitors: {3}
twdInDegrees=TWD (from) in degrees
legDirectionInDegrees=Leg direction in degrees
@@ -2578,7 +2578,7 @@ selectRaceColumnsWhosePairingsToCopy=Race columns whose pairings to copy
errorCopyingPairings=Error copying pairings: {0}
successfullyCopiedPairings=Successfully copied pairings
selectFromRaceColumn=Select race column from where to start copying pairings
selectToRaceColumn=Select race colunm to where to copy pairings
selectToRaceColumn=Select race column to where to copy pairings
exportTWAHistogramToCsv=Export True Wind Angle histogram to CSV
exportWindSpeedHistogramToCsv=Export Wind Speed histogram to CSV
optionalBearerTokenForWindImport=Optional bearer token for wind import
@@ -2,7 +2,7 @@ dataMiningSettings=Data Mining Settings
runPredefinedQuery=Run Predefined Query
viewQueryDefinition=View Query Definition
selectPredefinedQuery=Select Predefined Query
errorRunningDataMiningQuery=An error occured running the query
errorRunningDataMiningQuery=An error occurred running the query
predefinedQueryRunner=Predefined Query Runner
useClassGetNameTooltip=More robust against changes in the code base, but the code snippet can only be used in the scope where the classes are available.
useClassGetName=Use Class.getName() for type names
@@ -2,6 +2,6 @@ s3Desc=Store files on Amazon S3. The resulting file name is a random UUID plus t
s3AccessIdDesc=Access ID (leave blank to use ~/.aws/credentials instead)
s3AccessKeyDesc=Secret Access Key (leave blank to use ~/.aws/credentials instead)
s3BucketNameDesc=Name of Bucket to use (has to already exist, and user needs sufficient permissions)
localDesc=Service for storing files in the local file system. Files get stored in localPath+fileName and can be accessed at baseUrl+fileName Note that the content lying in baseUrl must therefore be accessible remotely. This can for examplebe achieved by mounting a remote file system for example from static.sapsailing.com on the replicas to localPath.
localDesc=Service for storing files in the local file system. Files get stored in localPath+fileName and can be accessed at baseUrl+fileName Note that the content lying in baseUrl must therefore be accessible remotely. This can for example be achieved by mounting a remote file system for example from static.sapsailing.com on the replicas to localPath.
localBaseUrlDesc=Base URL + fileName = URL where uploaded file will be accessible
localLocalPathDesc=Local Path to use for file storage
@@ -44,9 +44,9 @@ explainReplicationServletPort=Servlet port number that is used to connect to reg
setUpStorageService=Please set up a file storage service first
usernameAndPasswordMustBothBeSet=Username and password must both be filled or empty
username=User name
explainUserName=Username to use for authentification on the master
explainUserName=Username to use for authentication on the master
password=Password
explainPassword=Password to use for authentification on the master
explainPassword=Password to use for authentication on the master
additionalInformation=Info
serverInformation=Server Information
serverName=Server Name: {0}
@@ -68,4 +68,7 @@ com.sap.sse.landscape.impl.GithubReleasesRepository.level = FINE
# Produce wind-from-maneuver estimation graph:
#com.sap.sailing.windestimation.integration.IncrementalMstHmmWindEstimationForTrackedRaceTest.level = FINE
#com.sap.sailing.windestimation.integration.IncrementalMstHmmWindEstimationForTrackedRace.level = FINE
#com.sap.sailing.windestimation.integration.IncrementalMstHmmWindEstimationForTrackedRace.level = FINE
# Log device mapping caching
com.sap.sailing.domain.racelogtracking.impl.fixtracker.RegattaLogDeviceMappings.level = FINE