From f49ca2c4a7f7729c8a3738b64ef24d5938e890f8 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Fri, 25 Sep 2026 16:33:48 +0100 Subject: [PATCH 1/5] feat(SpecsIo): add OS-created temp directory and OS cache folder helpers - SpecsIo.createTempDirectory(prefix): unique directory allocated by the OS under the user temp folder, registered for deletion on exit as a backstop, so callers no longer hand-roll UUID/timestamp names - SpecsIo.getOsCacheFolder(): user-level cache folder following OS conventions (XDG_CACHE_HOME, ~/Library/Caches, %LOCALAPPDATA%) - SpecsSystem.isMac() --- .../src/pt/up/fe/specs/util/SpecsIo.java | 53 +++++++++++++++++++ .../src/pt/up/fe/specs/util/SpecsSystem.java | 9 ++++ .../test/pt/up/fe/specs/util/SpecsIoTest.java | 23 ++++++++ 3 files changed, 85 insertions(+) diff --git a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java index 7bb9db3e..cc1b3903 100644 --- a/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java +++ b/SpecsUtils/src/pt/up/fe/specs/util/SpecsIo.java @@ -30,6 +30,7 @@ import java.io.ObjectOutputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; +import java.io.UncheckedIOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; @@ -1910,6 +1911,58 @@ public static File getTempFolder(String folderName) { return mkdir(systemTemp, folderName); } + /** + * An empty, uniquely-named directory in the OS temporary folder, allocated by + * the operating system. + * + *

+ * The directory is registered for deletion when the virtual machine exits, as a + * backstop. Owners should still delete it as soon as its lifecycle ends. + * + * @param prefix the directory name prefix, must be at least three characters + * long + * @return the newly created directory + */ + public static File createTempDirectory(String prefix) { + try { + File tempFolder = Files.createTempDirectory(getTempFolder().toPath(), prefix).toFile(); + + // Backstop deletion, in case the owner does not get the chance to delete it + deleteOnExit(tempFolder); + + return tempFolder; + } catch (IOException e) { + throw new UncheckedIOException("Could not create temporary directory with prefix '" + prefix + "'", e); + } + } + + /** + * The user-level cache folder, following the operating system's conventions. + * + *

+ * Linux and other Unix-likes use '$XDG_CACHE_HOME', falling back to '~/.cache'; + * macOS uses '~/Library/Caches'; Windows uses '%LOCALAPPDATA%', falling back to + * '~/AppData/Local'. + * + * @return the existing or newly created OS cache folder + */ + public static File getOsCacheFolder() { + String cacheDir; + if (SpecsSystem.isWindows()) { + String localAppData = System.getenv("LOCALAPPDATA"); + cacheDir = localAppData != null && !localAppData.isBlank() ? localAppData + : new File(System.getProperty("user.home"), "AppData/Local").getAbsolutePath(); + } else if (SpecsSystem.isMac()) { + cacheDir = new File(System.getProperty("user.home"), "Library/Caches").getAbsolutePath(); + } else { + String xdgCacheHome = System.getenv("XDG_CACHE_HOME"); + cacheDir = xdgCacheHome != null && !xdgCacheHome.isBlank() ? xdgCacheHome + : new File(System.getProperty("user.home"), ".cache").getAbsolutePath(); + } + + return SpecsIo.mkdir(new File(cacheDir)); + } + /** * List directory contents for a resource folder. Not recursive. This is * basically a brute-force implementation. diff --git a/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java b/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java index cd4400b3..43485977 100644 --- a/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java +++ b/SpecsUtils/src/pt/up/fe/specs/util/SpecsSystem.java @@ -61,6 +61,7 @@ public class SpecsSystem { private static final boolean IS_LINUX = System.getProperty("os.name").toLowerCase().startsWith("linux"); private static final boolean IS_WINDOWS = System.getProperty("os.name").startsWith("Windows"); + private static final boolean IS_MAC = System.getProperty("os.name").toLowerCase().contains("mac"); private static final Map CACHED_METHODS = new HashMap<>(); private static final Map> CACHED_FIELDS = new HashMap<>(); @@ -874,6 +875,14 @@ public static boolean isWindows() { return IS_WINDOWS; } + /** + * @return true if the JVM is currently executing in a macOS system, false + * otherwise + */ + public static boolean isMac() { + return IS_MAC; + } + /** * Equivalent to class.isInstance. * diff --git a/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java b/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java index 90c7f35b..b15ac236 100644 --- a/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java +++ b/SpecsUtils/test/pt/up/fe/specs/util/SpecsIoTest.java @@ -1041,6 +1041,29 @@ void testTempFileOperations() { assertThat(randomFolder).isNotNull(); } + @Test + @DisplayName("Test OS-created temp directory") + void testCreateTempDirectory() { + File tempFolder = SpecsIo.createTempDirectory("specs-test-"); + assertThat(tempFolder).isNotNull(); + assertThat(tempFolder.isDirectory()).isTrue(); + assertThat(tempFolder.getName()).startsWith("specs-test-"); + assertThat(tempFolder.getParentFile()).isEqualTo(SpecsIo.getTempFolder()); + assertThat(SpecsIo.createTempDirectory("specs-test-")) + .isNotEqualTo(SpecsIo.createTempDirectory("specs-test-")); + } + + @Test + @DisplayName("Test OS cache folder") + void testGetOsCacheFolder() { + File cacheFolder = SpecsIo.getOsCacheFolder(); + assertThat(cacheFolder).isNotNull(); + assertThat(cacheFolder.isDirectory()).isTrue(); + + // Same call returns the same location + assertThat(SpecsIo.getOsCacheFolder()).isEqualTo(cacheFolder); + } + @Test @DisplayName("Test ZIP operations") void testZipOperations(@TempDir Path tempDir) throws IOException { From 5893091716372fca9d01ce9583e8ad60d836a961 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Thu, 10 Sep 2026 20:31:21 +0100 Subject: [PATCH 2/5] feat(jOptions): prototype memoized values in data store slots Add a backed store that decodes individual fields on first access, preserves deferred copies, and lets typed writes replace pending values. Verify storage semantics and run the full jOptions suite and coverage gate. --- .../jOptions/DataStore/ListDataStore.java | 16 +- .../jOptions/DataStore/MemoizedDataStore.java | 129 +++++++++++++++ .../DataStore/MemoizedDataStoreTest.java | 151 ++++++++++++++++++ 3 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java create mode 100644 jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java diff --git a/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java b/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java index 730a313f..5717c6ea 100644 --- a/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java +++ b/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java @@ -215,7 +215,8 @@ public String getName() { */ @Override public T get(DataKey key) { - Object valueRaw = get(toIndex(key)); + int index = toIndex(key); + Object valueRaw = resolveValue(index, get(index)); if (strict && valueRaw == null) { throw new RuntimeException( "No value present in DataStore '" + getName() + "' " + " for key '" + key.getName() + "'"); @@ -350,7 +351,18 @@ private StoreDefinitionIndexes getIndexes() { */ @Override public Object get(String id) { - return get(toIndex(id)); + int index = toIndex(id); + return resolveValue(index, get(index)); + } + + /** Allows backed stores to materialize a value only when it is read. Presence checks stay raw. */ + protected Object resolveValue(int index, Object value) { + return value; + } + + /** Raw slot access for copies that must preserve deferred values without reading them. */ + protected final Object getRawValue(int index) { + return get(index); } /** diff --git a/jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java b/jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java new file mode 100644 index 00000000..296c1134 --- /dev/null +++ b/jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java @@ -0,0 +1,129 @@ +/** + * Copyright 2026 SPeCS. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package org.suikasoft.jOptions.DataStore; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import org.suikasoft.jOptions.Datakey.DataKey; +import org.suikasoft.jOptions.Interfaces.DataStore; +import org.suikasoft.jOptions.storedefinition.StoreDefinition; + +/** + * Experimental field memoization in ordinary ListDataStore slots. + * An unloaded slot contains a shared marker, replaced by the value on first read. + * Like ListDataStore, concurrent mutation of a store is not supported. + */ +public class MemoizedDataStore extends ListDataStore { + private static final Object UNLOADED = new Object(); + private static final java.util.concurrent.ConcurrentHashMap>> KEY_LAYOUTS + = new java.util.concurrent.ConcurrentHashMap<>(); + private final List> allKeys; + private final List> deferredKeys; + private final Function, Object> decoder; + private long materializedCount; + private boolean strict; + + /** Decoder must not retain parser indexes and must produce independently mutable values per call. */ + public MemoizedDataStore(StoreDefinition definition, Collection> deferredKeys, + Function, Object> decoder) { + super(definition); + this.allKeys = KEY_LAYOUTS.computeIfAbsent(definition, d -> List.copyOf(d.getKeys())); + this.deferredKeys = List.copyOf(deferredKeys); + this.decoder = Objects.requireNonNull(decoder); + for (DataKey key : this.deferredKeys) { + if (!definition.hasKey(key.getName())) throw new IllegalArgumentException("Unknown deferred key " + key); + super.setRaw(key.getName(), UNLOADED); + } + } + + public long getMaterializedCount() { return materializedCount; } + public int getDeferredCount() { return deferredKeys.size(); } + + @Override + protected Object resolveValue(int index, Object value) { + if (value != UNLOADED) return value; + DataKey key = allKeys.get(index); + Object decoded = decoder.apply(key); + if (decoded != null && key.verifyValueClass() && !key.getValueClass().isInstance(decoded)) + throw new IllegalArgumentException("Decoded value has wrong type for " + key.getName()); + super.setRaw(key.getName(), decoded); + materializedCount++; + return decoded; + } + + /** Typed writes do not request the old value and therefore do not decode it. */ + @Override + public DataStore set(DataKey key, E value) { + Objects.requireNonNull(value, "Use remove instead of setting a null typed value"); + if (key.verifyValueClass() && !key.getValueClass().isInstance(value)) + throw new IllegalArgumentException("Wrong value type for " + key.getName()); + super.setRaw(key.getName(), value); + return this; + } + + /** Raw writes return the previous raw value, which requires decoding it if still pending. */ + @Override + public Optional setRaw(String name, Object value) { + if (!getStoreDefinition().hasKey(name)) return Optional.empty(); + Object previous = get(name); + super.setRaw(name, value); + return Optional.ofNullable(previous); + } + + @Override + public void setStrict(boolean strict) { + this.strict = strict; + super.setStrict(strict); + } + + /** Pending payloads stay deferred; realized values use the normal DataKey copy policy. */ + @Override + public DataStore copy() { + var copy = new MemoizedDataStore(getStoreDefinition(), deferredKeys, decoder); + copy.setStrict(strict); + var keys = allKeys; + for (int i = 0; i < keys.size(); i++) { + Object value = getRawValue(i); + if (value != UNLOADED) copy.setRealized(keys.get(i), value); + } + return copy; + } + + private void setRealized(DataKey key, Object value) { + super.setRaw(key.getName(), value == null ? null : key.copyRaw(value)); + } + + private void materializeAll() { + for (DataKey key : deferredKeys) get(key.getName()); + } + + @Override + public int hashCode() { + materializeAll(); + return super.hashCode(); + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + materializeAll(); + ((MemoizedDataStore) other).materializeAll(); + return super.equals(other); + } +} diff --git a/jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java b/jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java new file mode 100644 index 00000000..7667908c --- /dev/null +++ b/jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java @@ -0,0 +1,151 @@ +/** + * Copyright 2026 SPeCS. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package org.suikasoft.jOptions.DataStore; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.suikasoft.jOptions.Datakey.DataKey; +import org.suikasoft.jOptions.Datakey.KeyFactory; +import org.suikasoft.jOptions.Interfaces.DataStore; +import org.suikasoft.jOptions.storedefinition.StoreDefinition; + +class MemoizedDataStoreTest { + + @Test + void deferredValuesArePresenceVisibleAndDecodedOnce() { + DataKey text = KeyFactory.string("text"); + DataKey number = KeyFactory.integer("number"); + StoreDefinition definition = definition(text, number); + AtomicInteger decodes = new AtomicInteger(); + + MemoizedDataStore store = new MemoizedDataStore(definition, List.of(text), key -> { + decodes.incrementAndGet(); + return "from-dump"; + }); + + assertThat(store).isInstanceOf(ListDataStore.class); + assertThat(store.getDeferredCount()).isEqualTo(1); + assertThat(store.getMaterializedCount()).isZero(); + assertThat(store.hasValue(text)).isTrue(); + assertThat(store.getKeysWithValues()).containsExactly("text"); + assertThat(decodes).hasValue(0); + + assertThat(store.get(text)).isEqualTo("from-dump"); + assertThat(store.get(text)).isEqualTo("from-dump"); + assertThat(store.get("text")).isEqualTo("from-dump"); + assertThat(decodes).hasValue(1); + assertThat(store.getMaterializedCount()).isEqualTo(1); + } + + @Test + void typedWriteOverridesDeferredValueWithoutDecoding() { + DataKey text = KeyFactory.string("text"); + StoreDefinition definition = definition(text); + AtomicInteger decodes = new AtomicInteger(); + MemoizedDataStore store = new MemoizedDataStore(definition, List.of(text), key -> { + decodes.incrementAndGet(); + return "from-dump"; + }); + + store.set(text, "written"); + + assertThat(store.get(text)).isEqualTo("written"); + assertThat(store.get("text")).isEqualTo("written"); + assertThat(decodes).hasValue(0); + assertThat(store.getMaterializedCount()).isZero(); + } + + @Test + void rawWriteReturnsPreviousDeferredValueAndNullTombstonesIt() { + DataKey text = KeyFactory.string("text"); + MemoizedDataStore store = new MemoizedDataStore(definition(text), List.of(text), key -> "from-dump"); + + assertThat(store.setRaw("text", "written")).contains("from-dump"); + assertThat(store.get(text)).isEqualTo("written"); + + assertThat(store.setRaw("text", null)).contains("written"); + assertThat(store.hasValue(text)).isFalse(); + assertThat(store.get(text)).isEmpty(); + } + + @Test + void copyKeepsPendingValuesLazyAndCopiesMaterializedMutableValues() { + DataKey> values = KeyFactory.list("values", String.class); + AtomicInteger decodes = new AtomicInteger(); + MemoizedDataStore store = new MemoizedDataStore(definition(values), List.of(values), key -> { + decodes.incrementAndGet(); + return new ArrayList<>(List.of("dump")); + }); + + MemoizedDataStore pendingCopy = (MemoizedDataStore) store.copy(); + assertThat(decodes).hasValue(0); + assertThat(pendingCopy.get(values)).containsExactly("dump"); + assertThat(decodes).hasValue(1); + + List originalValues = store.get(values); + MemoizedDataStore materializedCopy = (MemoizedDataStore) store.copy(); + materializedCopy.get(values).add("copy"); + + assertThat(originalValues).containsExactly("dump"); + assertThat(materializedCopy.get(values)).containsExactly("dump", "copy"); + assertThat(decodes).hasValue(2); + } + + @Test + void removeUsesDefaultAfterTombstoningDeferredValue() { + DataKey text = KeyFactory.string("text"); + MemoizedDataStore store = new MemoizedDataStore(definition(text), List.of(text), key -> "from-dump"); + + assertThat(store.remove(text)).contains("from-dump"); + assertThat(store.hasValue(text)).isFalse(); + assertThat(store.get(text)).isEmpty(); + assertThat(store.hasValue(text)).isTrue(); + } + + @Test + void equalityAndHashingObserveDeferredValues() { + DataKey text = KeyFactory.string("text"); + StoreDefinition definition = definition(text); + var first = new MemoizedDataStore(definition, List.of(text), key -> "same"); + var second = new MemoizedDataStore(definition, List.of(text), key -> "same"); + var different = new MemoizedDataStore(definition, List.of(text), key -> "different"); + assertThat(first).isEqualTo(second).isNotEqualTo(different); + assertThat(first.hashCode()).isEqualTo(second.hashCode()); + } + + @Test + void failedDecodeCanBeRetriedAndStrictReadsStillWork() { + DataKey text = KeyFactory.string("text"); + AtomicInteger attempts = new AtomicInteger(); + var store = new MemoizedDataStore(definition(text), List.of(text), key -> { + if (attempts.getAndIncrement() == 0) throw new IllegalStateException("temporary failure"); + return "loaded"; + }); + store.setStrict(true); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> store.get(text)) + .isInstanceOf(IllegalStateException.class); + assertThat(store.getMaterializedCount()).isZero(); + assertThat(store.get(text)).isEqualTo("loaded"); + assertThat(store.getMaterializedCount()).isEqualTo(1); + } + + private static StoreDefinition definition(DataKey... keys) { + return StoreDefinition.newInstance("Memoized", keys); + } +} From ead48b7154b846058ef6cdd8c553849b987d9884 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Sat, 12 Sep 2026 10:16:10 +0100 Subject: [PATCH 3/5] fix: publish shared datastore metadata safely Build definition key maps before publication and use a concurrent cache for slot indexes. Parallel first readers previously observed incomplete maps, causing lazy AST imports to reject valid fields. Add a concurrent first-use test that reproduces the old failure. --- .../jOptions/DataStore/ListDataStore.java | 10 +-- .../storedefinition/AStoreDefinition.java | 6 +- .../AStoreDefinitionConcurrencyTest.java | 85 +++++++++++++++++++ 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java diff --git a/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java b/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java index 5717c6ea..23ad1d4b 100644 --- a/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java +++ b/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java @@ -20,6 +20,7 @@ import org.suikasoft.jOptions.storedefinition.StoreDefinitionIndexes; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; /** * Implementation of DataStore that uses a List to store the data. @@ -32,7 +33,7 @@ */ public class ListDataStore implements DataStore { - private static final Map KEY_TO_INDEXES = new HashMap<>(); + private static final Map KEY_TO_INDEXES = new ConcurrentHashMap<>(); private final StoreDefinition keys; private final List values; @@ -335,12 +336,7 @@ private int toIndex(String key) { * @return the StoreDefinitionIndexes */ private StoreDefinitionIndexes getIndexes() { - StoreDefinitionIndexes indexes = KEY_TO_INDEXES.get(keys); - if (indexes == null) { - indexes = new StoreDefinitionIndexes(keys); - KEY_TO_INDEXES.put(keys, indexes); - } - return indexes; + return KEY_TO_INDEXES.computeIfAbsent(keys, StoreDefinitionIndexes::new); } /** diff --git a/jOptions/src/org/suikasoft/jOptions/storedefinition/AStoreDefinition.java b/jOptions/src/org/suikasoft/jOptions/storedefinition/AStoreDefinition.java index 6cf0cf8e..79404115 100644 --- a/jOptions/src/org/suikasoft/jOptions/storedefinition/AStoreDefinition.java +++ b/jOptions/src/org/suikasoft/jOptions/storedefinition/AStoreDefinition.java @@ -31,7 +31,7 @@ public abstract class AStoreDefinition implements StoreDefinition { private final String appName; private final List sections; private final DataStore defaultData; - private final Map> keyMap = new HashMap<>(); + private final Map> keyMap; /** * Creates a new store definition with the given name and options. @@ -56,13 +56,11 @@ protected AStoreDefinition(String appName, List sections, DataStor this.appName = appName; this.sections = new ArrayList<>(sections); this.defaultData = defaultData; + this.keyMap = new HashMap<>(StoreDefinition.super.getKeyMap()); } @Override public Map> getKeyMap() { - if (keyMap.isEmpty()) { - keyMap.putAll(StoreDefinition.super.getKeyMap()); - } return keyMap; } diff --git a/jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java b/jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java new file mode 100644 index 00000000..be9990d8 --- /dev/null +++ b/jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java @@ -0,0 +1,85 @@ +package org.suikasoft.jOptions.storedefinition; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; +import org.suikasoft.jOptions.DataStore.ListDataStore; +import org.suikasoft.jOptions.DataStore.MemoizedDataStore; +import org.suikasoft.jOptions.Datakey.DataKey; +import org.suikasoft.jOptions.Datakey.KeyFactory; + +class AStoreDefinitionConcurrencyTest { + + private static final int THREADS = 32; + private static final int SHARED_KEYS = 2_048; + private static final int STORE_KEYS = 8; + private static final int ROUNDS = 8; + + @Test + void concurrentFirstReadersAndStoreCreationSeeCompleteDefinitions() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(THREADS); + + try { + for (int round = 0; round < ROUNDS; round++) { + final int currentRound = round; + List> sharedKeys = keys("shared-" + currentRound + "-", SHARED_KEYS); + TestStoreDefinition shared = new TestStoreDefinition("shared-" + currentRound, sharedKeys); + List independent = IntStream.range(0, THREADS) + .mapToObj(thread -> new TestStoreDefinition("independent-" + currentRound + "-" + thread, + keys("independent-" + currentRound + "-" + thread + "-", STORE_KEYS))) + .toList(); + CyclicBarrier gate = new CyclicBarrier(THREADS + 1); + List> futures = new ArrayList<>(THREADS); + + for (int thread = 0; thread < THREADS; thread++) { + final int threadIndex = thread; + futures.add(executor.submit(() -> { + gate.await(); + + Map> map = shared.getKeyMap(); + TestStoreDefinition definition = independent.get(threadIndex); + DataKey firstKey = definition.getKeys().get(0); + DataKey secondKey = definition.getKeys().get(1); + ListDataStore eager = new ListDataStore(definition); + eager.setRaw(firstKey.getName(), "eager"); + eager.get(firstKey.getName()); + MemoizedDataStore lazy = new MemoizedDataStore(definition, List.of(secondKey), key -> "lazy"); + lazy.get(secondKey.getName()); + + return map.size(); + })); + } + + gate.await(); + for (Future future : futures) { + assertThat(future.get()).isEqualTo(SHARED_KEYS); + } + } + } finally { + executor.shutdownNow(); + } + } + + private static List> keys(String prefix, int count) { + List> keys = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + keys.add(KeyFactory.string(prefix + index)); + } + return keys; + } + + private static final class TestStoreDefinition extends AStoreDefinition { + private TestStoreDefinition(String name, List> keys) { + super(name, keys); + } + } +} From ff2b543328c585376b64aded0d15778b6c67d59b Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Fri, 2 Oct 2026 22:30:44 +0100 Subject: [PATCH 4/5] refactor(jOptions): move deferred stores to lazy experiment branch Remove the FlatBuffers lazy datastore and slot resolution hooks from the eager production branch. Preserve the concurrency regression using ordinary stores; the complete lazy implementation remains on lazy-flatbuffers-experiment. Validation: jOptions tests and JaCoCo coverage gate pass. --- .../jOptions/DataStore/ListDataStore.java | 16 +- .../jOptions/DataStore/MemoizedDataStore.java | 129 --------------- .../DataStore/MemoizedDataStoreTest.java | 151 ------------------ .../AStoreDefinitionConcurrencyTest.java | 5 +- 4 files changed, 4 insertions(+), 297 deletions(-) delete mode 100644 jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java delete mode 100644 jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java diff --git a/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java b/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java index 23ad1d4b..72e2c677 100644 --- a/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java +++ b/jOptions/src/org/suikasoft/jOptions/DataStore/ListDataStore.java @@ -216,8 +216,7 @@ public String getName() { */ @Override public T get(DataKey key) { - int index = toIndex(key); - Object valueRaw = resolveValue(index, get(index)); + Object valueRaw = get(toIndex(key)); if (strict && valueRaw == null) { throw new RuntimeException( "No value present in DataStore '" + getName() + "' " + " for key '" + key.getName() + "'"); @@ -347,18 +346,7 @@ private StoreDefinitionIndexes getIndexes() { */ @Override public Object get(String id) { - int index = toIndex(id); - return resolveValue(index, get(index)); - } - - /** Allows backed stores to materialize a value only when it is read. Presence checks stay raw. */ - protected Object resolveValue(int index, Object value) { - return value; - } - - /** Raw slot access for copies that must preserve deferred values without reading them. */ - protected final Object getRawValue(int index) { - return get(index); + return get(toIndex(id)); } /** diff --git a/jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java b/jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java deleted file mode 100644 index 296c1134..00000000 --- a/jOptions/src/org/suikasoft/jOptions/DataStore/MemoizedDataStore.java +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Copyright 2026 SPeCS. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ - -package org.suikasoft.jOptions.DataStore; - -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.function.Function; - -import org.suikasoft.jOptions.Datakey.DataKey; -import org.suikasoft.jOptions.Interfaces.DataStore; -import org.suikasoft.jOptions.storedefinition.StoreDefinition; - -/** - * Experimental field memoization in ordinary ListDataStore slots. - * An unloaded slot contains a shared marker, replaced by the value on first read. - * Like ListDataStore, concurrent mutation of a store is not supported. - */ -public class MemoizedDataStore extends ListDataStore { - private static final Object UNLOADED = new Object(); - private static final java.util.concurrent.ConcurrentHashMap>> KEY_LAYOUTS - = new java.util.concurrent.ConcurrentHashMap<>(); - private final List> allKeys; - private final List> deferredKeys; - private final Function, Object> decoder; - private long materializedCount; - private boolean strict; - - /** Decoder must not retain parser indexes and must produce independently mutable values per call. */ - public MemoizedDataStore(StoreDefinition definition, Collection> deferredKeys, - Function, Object> decoder) { - super(definition); - this.allKeys = KEY_LAYOUTS.computeIfAbsent(definition, d -> List.copyOf(d.getKeys())); - this.deferredKeys = List.copyOf(deferredKeys); - this.decoder = Objects.requireNonNull(decoder); - for (DataKey key : this.deferredKeys) { - if (!definition.hasKey(key.getName())) throw new IllegalArgumentException("Unknown deferred key " + key); - super.setRaw(key.getName(), UNLOADED); - } - } - - public long getMaterializedCount() { return materializedCount; } - public int getDeferredCount() { return deferredKeys.size(); } - - @Override - protected Object resolveValue(int index, Object value) { - if (value != UNLOADED) return value; - DataKey key = allKeys.get(index); - Object decoded = decoder.apply(key); - if (decoded != null && key.verifyValueClass() && !key.getValueClass().isInstance(decoded)) - throw new IllegalArgumentException("Decoded value has wrong type for " + key.getName()); - super.setRaw(key.getName(), decoded); - materializedCount++; - return decoded; - } - - /** Typed writes do not request the old value and therefore do not decode it. */ - @Override - public DataStore set(DataKey key, E value) { - Objects.requireNonNull(value, "Use remove instead of setting a null typed value"); - if (key.verifyValueClass() && !key.getValueClass().isInstance(value)) - throw new IllegalArgumentException("Wrong value type for " + key.getName()); - super.setRaw(key.getName(), value); - return this; - } - - /** Raw writes return the previous raw value, which requires decoding it if still pending. */ - @Override - public Optional setRaw(String name, Object value) { - if (!getStoreDefinition().hasKey(name)) return Optional.empty(); - Object previous = get(name); - super.setRaw(name, value); - return Optional.ofNullable(previous); - } - - @Override - public void setStrict(boolean strict) { - this.strict = strict; - super.setStrict(strict); - } - - /** Pending payloads stay deferred; realized values use the normal DataKey copy policy. */ - @Override - public DataStore copy() { - var copy = new MemoizedDataStore(getStoreDefinition(), deferredKeys, decoder); - copy.setStrict(strict); - var keys = allKeys; - for (int i = 0; i < keys.size(); i++) { - Object value = getRawValue(i); - if (value != UNLOADED) copy.setRealized(keys.get(i), value); - } - return copy; - } - - private void setRealized(DataKey key, Object value) { - super.setRaw(key.getName(), value == null ? null : key.copyRaw(value)); - } - - private void materializeAll() { - for (DataKey key : deferredKeys) get(key.getName()); - } - - @Override - public int hashCode() { - materializeAll(); - return super.hashCode(); - } - - @Override - public boolean equals(Object other) { - if (this == other) return true; - if (other == null || getClass() != other.getClass()) return false; - materializeAll(); - ((MemoizedDataStore) other).materializeAll(); - return super.equals(other); - } -} diff --git a/jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java b/jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java deleted file mode 100644 index 7667908c..00000000 --- a/jOptions/test/org/suikasoft/jOptions/DataStore/MemoizedDataStoreTest.java +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Copyright 2026 SPeCS. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ - -package org.suikasoft.jOptions.DataStore; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import org.junit.jupiter.api.Test; -import org.suikasoft.jOptions.Datakey.DataKey; -import org.suikasoft.jOptions.Datakey.KeyFactory; -import org.suikasoft.jOptions.Interfaces.DataStore; -import org.suikasoft.jOptions.storedefinition.StoreDefinition; - -class MemoizedDataStoreTest { - - @Test - void deferredValuesArePresenceVisibleAndDecodedOnce() { - DataKey text = KeyFactory.string("text"); - DataKey number = KeyFactory.integer("number"); - StoreDefinition definition = definition(text, number); - AtomicInteger decodes = new AtomicInteger(); - - MemoizedDataStore store = new MemoizedDataStore(definition, List.of(text), key -> { - decodes.incrementAndGet(); - return "from-dump"; - }); - - assertThat(store).isInstanceOf(ListDataStore.class); - assertThat(store.getDeferredCount()).isEqualTo(1); - assertThat(store.getMaterializedCount()).isZero(); - assertThat(store.hasValue(text)).isTrue(); - assertThat(store.getKeysWithValues()).containsExactly("text"); - assertThat(decodes).hasValue(0); - - assertThat(store.get(text)).isEqualTo("from-dump"); - assertThat(store.get(text)).isEqualTo("from-dump"); - assertThat(store.get("text")).isEqualTo("from-dump"); - assertThat(decodes).hasValue(1); - assertThat(store.getMaterializedCount()).isEqualTo(1); - } - - @Test - void typedWriteOverridesDeferredValueWithoutDecoding() { - DataKey text = KeyFactory.string("text"); - StoreDefinition definition = definition(text); - AtomicInteger decodes = new AtomicInteger(); - MemoizedDataStore store = new MemoizedDataStore(definition, List.of(text), key -> { - decodes.incrementAndGet(); - return "from-dump"; - }); - - store.set(text, "written"); - - assertThat(store.get(text)).isEqualTo("written"); - assertThat(store.get("text")).isEqualTo("written"); - assertThat(decodes).hasValue(0); - assertThat(store.getMaterializedCount()).isZero(); - } - - @Test - void rawWriteReturnsPreviousDeferredValueAndNullTombstonesIt() { - DataKey text = KeyFactory.string("text"); - MemoizedDataStore store = new MemoizedDataStore(definition(text), List.of(text), key -> "from-dump"); - - assertThat(store.setRaw("text", "written")).contains("from-dump"); - assertThat(store.get(text)).isEqualTo("written"); - - assertThat(store.setRaw("text", null)).contains("written"); - assertThat(store.hasValue(text)).isFalse(); - assertThat(store.get(text)).isEmpty(); - } - - @Test - void copyKeepsPendingValuesLazyAndCopiesMaterializedMutableValues() { - DataKey> values = KeyFactory.list("values", String.class); - AtomicInteger decodes = new AtomicInteger(); - MemoizedDataStore store = new MemoizedDataStore(definition(values), List.of(values), key -> { - decodes.incrementAndGet(); - return new ArrayList<>(List.of("dump")); - }); - - MemoizedDataStore pendingCopy = (MemoizedDataStore) store.copy(); - assertThat(decodes).hasValue(0); - assertThat(pendingCopy.get(values)).containsExactly("dump"); - assertThat(decodes).hasValue(1); - - List originalValues = store.get(values); - MemoizedDataStore materializedCopy = (MemoizedDataStore) store.copy(); - materializedCopy.get(values).add("copy"); - - assertThat(originalValues).containsExactly("dump"); - assertThat(materializedCopy.get(values)).containsExactly("dump", "copy"); - assertThat(decodes).hasValue(2); - } - - @Test - void removeUsesDefaultAfterTombstoningDeferredValue() { - DataKey text = KeyFactory.string("text"); - MemoizedDataStore store = new MemoizedDataStore(definition(text), List.of(text), key -> "from-dump"); - - assertThat(store.remove(text)).contains("from-dump"); - assertThat(store.hasValue(text)).isFalse(); - assertThat(store.get(text)).isEmpty(); - assertThat(store.hasValue(text)).isTrue(); - } - - @Test - void equalityAndHashingObserveDeferredValues() { - DataKey text = KeyFactory.string("text"); - StoreDefinition definition = definition(text); - var first = new MemoizedDataStore(definition, List.of(text), key -> "same"); - var second = new MemoizedDataStore(definition, List.of(text), key -> "same"); - var different = new MemoizedDataStore(definition, List.of(text), key -> "different"); - assertThat(first).isEqualTo(second).isNotEqualTo(different); - assertThat(first.hashCode()).isEqualTo(second.hashCode()); - } - - @Test - void failedDecodeCanBeRetriedAndStrictReadsStillWork() { - DataKey text = KeyFactory.string("text"); - AtomicInteger attempts = new AtomicInteger(); - var store = new MemoizedDataStore(definition(text), List.of(text), key -> { - if (attempts.getAndIncrement() == 0) throw new IllegalStateException("temporary failure"); - return "loaded"; - }); - store.setStrict(true); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> store.get(text)) - .isInstanceOf(IllegalStateException.class); - assertThat(store.getMaterializedCount()).isZero(); - assertThat(store.get(text)).isEqualTo("loaded"); - assertThat(store.getMaterializedCount()).isEqualTo(1); - } - - private static StoreDefinition definition(DataKey... keys) { - return StoreDefinition.newInstance("Memoized", keys); - } -} diff --git a/jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java b/jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java index be9990d8..5ed2a5de 100644 --- a/jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java +++ b/jOptions/test/org/suikasoft/jOptions/storedefinition/AStoreDefinitionConcurrencyTest.java @@ -13,7 +13,6 @@ import org.junit.jupiter.api.Test; import org.suikasoft.jOptions.DataStore.ListDataStore; -import org.suikasoft.jOptions.DataStore.MemoizedDataStore; import org.suikasoft.jOptions.Datakey.DataKey; import org.suikasoft.jOptions.Datakey.KeyFactory; @@ -52,8 +51,8 @@ void concurrentFirstReadersAndStoreCreationSeeCompleteDefinitions() throws Excep ListDataStore eager = new ListDataStore(definition); eager.setRaw(firstKey.getName(), "eager"); eager.get(firstKey.getName()); - MemoizedDataStore lazy = new MemoizedDataStore(definition, List.of(secondKey), key -> "lazy"); - lazy.get(secondKey.getName()); + eager.setRaw(secondKey.getName(), "second"); + eager.get(secondKey.getName()); return map.size(); })); From fd41aceb8336d2b5e026abb2deee1a514a778be5 Mon Sep 17 00:00:00 2001 From: "L. Sousa" Date: Sun, 4 Oct 2026 05:17:53 +0100 Subject: [PATCH 5/5] Fix generic key default supplier preservation Return the immutable key copy configured with the supplier so generic and typed-list keys retain their defaults. Add regressions for supplier invocation, independent mutable list defaults in simple and closed stores, and typed-list setter validation. --- .../jOptions/Datakey/KeyFactory.java | 3 +- .../jOptions/Datakey/KeyFactoryTest.java | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/jOptions/src/org/suikasoft/jOptions/Datakey/KeyFactory.java b/jOptions/src/org/suikasoft/jOptions/Datakey/KeyFactory.java index 85029980..553ae987 100644 --- a/jOptions/src/org/suikasoft/jOptions/Datakey/KeyFactory.java +++ b/jOptions/src/org/suikasoft/jOptions/Datakey/KeyFactory.java @@ -590,8 +590,7 @@ public static DataKey generic(String id, E exampleInstance) */ public static DataKey generic(String id, Supplier defaultSupplier) { DataKey datakey = new GenericKey<>(id, defaultSupplier.get()); - datakey.setDefault(defaultSupplier); - return datakey; + return datakey.setDefault(defaultSupplier); } /** diff --git a/jOptions/test/org/suikasoft/jOptions/Datakey/KeyFactoryTest.java b/jOptions/test/org/suikasoft/jOptions/Datakey/KeyFactoryTest.java index 5d1d2af5..9527b90a 100644 --- a/jOptions/test/org/suikasoft/jOptions/Datakey/KeyFactoryTest.java +++ b/jOptions/test/org/suikasoft/jOptions/Datakey/KeyFactoryTest.java @@ -5,11 +5,14 @@ import java.io.File; import java.math.BigInteger; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.DisplayName; import pt.up.fe.specs.util.utilities.StringList; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.suikasoft.jOptions.Interfaces.DataStore; +import org.suikasoft.jOptions.storedefinition.StoreDefinition; /** * Comprehensive test suite for KeyFactory static factory methods. @@ -218,6 +221,64 @@ void testStringListFactoryWithDefault_CreatesStringListDataKey() { assertThat(key.getName()).isEqualTo("default.stringlist"); assertThat(key.getValueClass()).isEqualTo(StringList.class); } + + @Test + @DisplayName("list defaults are present, mutable, and independent across stores") + @SuppressWarnings({ "rawtypes", "unchecked" }) + void testListFactory_DefaultsAreIndependentAcrossStores_AndSetterValidatesElements() { + DataKey> key = KeyFactory.list("typed.list", String.class); + StoreDefinition definition = StoreDefinition.newInstance("Typed Lists", key); + DataStore simpleFirst = DataStore.newInstance(definition); + DataStore simpleSecond = DataStore.newInstance(definition); + DataStore closedFirst = DataStore.newInstance(definition, true); + DataStore closedSecond = DataStore.newInstance(definition, true); + + List simpleFirstValue = simpleFirst.get(key); + List simpleSecondValue = simpleSecond.get(key); + List closedFirstValue = closedFirst.get(key); + List closedSecondValue = closedSecond.get(key); + List> defaults = List.of(simpleFirstValue, simpleSecondValue, + closedFirstValue, closedSecondValue); + + assertThat(key.hasDefaultValue()).isTrue(); + assertThat(key.getDefault()).hasValueSatisfying(value -> assertThat(value).isEmpty()); + assertThat(defaults).allSatisfy(value -> assertThat(value).isEmpty()); + + for (int i = 0; i < defaults.size(); i++) { + for (int j = i + 1; j < defaults.size(); j++) { + assertThat(defaults.get(i)).isNotSameAs(defaults.get(j)); + } + } + + simpleFirstValue.add("mutable"); + assertThat(simpleSecondValue).isEmpty(); + assertThat(closedFirstValue).isEmpty(); + assertThat(closedSecondValue).isEmpty(); + + assertThatThrownBy(() -> simpleFirst.set(key, (List) List.of(1))) + .isInstanceOf(ClassCastException.class); + } + } + + @Nested + @DisplayName("Generic Key Factory") + class GenericFactoryTests { + + @Test + @DisplayName("generic factory retains and invokes its default supplier") + void testGenericFactory_RetainsDefaultSupplier() { + AtomicInteger supplierCalls = new AtomicInteger(); + + DataKey key = KeyFactory.generic("supplier.default", () -> { + supplierCalls.incrementAndGet(); + return "default"; + }); + + assertThat(supplierCalls).hasValue(1); + assertThat(key.hasDefaultValue()).isTrue(); + assertThat(key.getDefault()).hasValue("default"); + assertThat(supplierCalls).hasValue(2); + } } @Nested