-
Notifications
You must be signed in to change notification settings - Fork 25
Add prepared reward definition snapshots #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
2cf7d56
Add durable prepared reward definition snapshots
BenCodez d6835b5
Construct reward fixtures before Mockito stubbing
BenCodez 28cede3
Preserve optional and Unicode reward lookup semantics
BenCodez e3b9e64
Bound catalog capture and preserve reload lookup semantics
BenCodez 4c6e518
Bound decoded reward catalog record count
BenCodez 6e058d5
Coordinate prepared reward capture with registry rebuild
BenCodez 54708cd
Preserve empty named reward fallback in prepared catalogs
BenCodez 309f5e2
Bound prepared catalog capture and preserve empty reward lookup
BenCodez File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
308 changes: 308 additions & 0 deletions
308
AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/PreparedRewardCatalog.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,308 @@ | ||
| package com.bencodez.advancedcore.api.rewards; | ||
|
|
||
| import java.nio.charset.StandardCharsets; | ||
| import java.security.MessageDigest; | ||
| import java.security.NoSuchAlgorithmException; | ||
| import java.util.Base64; | ||
| import java.util.Collection; | ||
| import java.util.Collections; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.Map; | ||
| import java.util.TreeMap; | ||
|
|
||
| /** | ||
| * Immutable, persistable lookup data for the named rewards available while a | ||
| * caller prepares one root reward plan. | ||
| * | ||
| * <p>The catalog preserves {@link RewardRegistry} lookup precedence: directly | ||
| * defined entries, then sub-direct entries, then ordinary reward files. It | ||
| * freezes only the definitions supplied at capture time; callers must capture | ||
| * a new catalog when they intentionally want a later registry state. The | ||
| * legacy reward dispatcher does not consume this catalog automatically: a | ||
| * caller must resolve nested names through {@link #instantiate(String)} while | ||
| * preparing its own execution plan.</p> | ||
| */ | ||
| public final class PreparedRewardCatalog { | ||
|
|
||
| private static final String ENCODED_PREFIX = "AdvancedCorePreparedRewardCatalog/"; | ||
| private static final int FORMAT_VERSION = 2; | ||
| private static final int MAX_DEFINITIONS = 1024; | ||
| private static final int MAX_CATALOG_BYTES = 4 * 1024 * 1024; | ||
| private static final int MAX_ENCODED_BYTES = 8 * 1024 * 1024; | ||
|
|
||
| private final PreparedRewardDefinition root; | ||
| private final Map<String, PreparedRewardDefinition> directDefinitions; | ||
| private final Map<String, PreparedRewardDefinition> fileDefinitions; | ||
| private final String records; | ||
| private final String versionHash; | ||
|
|
||
| private PreparedRewardCatalog(PreparedRewardDefinition root, | ||
| Map<String, PreparedRewardDefinition> directDefinitions, | ||
| Map<String, PreparedRewardDefinition> fileDefinitions, | ||
| String records, String versionHash) { | ||
| this.root = root; | ||
| this.directDefinitions = Collections.unmodifiableMap(new TreeMap<>(directDefinitions)); | ||
| this.fileDefinitions = Collections.unmodifiableMap(new LinkedHashMap<>(fileDefinitions)); | ||
| this.records = records; | ||
| this.versionHash = versionHash; | ||
| } | ||
|
|
||
| /** Captures the root and every currently registered named definition. */ | ||
| public static PreparedRewardCatalog capture(Reward root, Collection<DirectlyDefinedReward> directlyDefined, | ||
| Collection<SubDirectlyDefinedReward> subDirectlyDefined, Collection<Reward> rewardFiles) { | ||
| PreparedRewardDefinition preparedRoot = PreparedRewardDefinition.capture(root); | ||
| TreeMap<String, PreparedRewardDefinition> direct = new TreeMap<>(); | ||
| LinkedHashMap<String, PreparedRewardDefinition> files = new LinkedHashMap<>(); | ||
| int count = 1; | ||
| int recordBytes = 0; | ||
|
|
||
| for (DirectlyDefinedReward entry : directlyDefined) { | ||
| if (entry == null) continue; | ||
| count = checkCount(count + 1); | ||
| String key = directKey(entry.getPath()); | ||
| if (!direct.containsKey(key)) { | ||
| Reward reward = entry.getReward(); | ||
| PreparedRewardDefinition definition = reward == null ? null : definitionFor(reward, key); | ||
| recordBytes = addRecordBytes(recordBytes, "D", key, definition); | ||
| direct.put(key, definition); | ||
| } | ||
| } | ||
| for (SubDirectlyDefinedReward entry : subDirectlyDefined) { | ||
| if (entry == null) continue; | ||
| count = checkCount(count + 1); | ||
| String key = directKey(entry.getFullPath()); | ||
| if (!direct.containsKey(key)) { | ||
| Reward reward = entry.getReward(); | ||
| PreparedRewardDefinition definition = reward == null ? null : definitionFor(reward, key); | ||
| recordBytes = addRecordBytes(recordBytes, "D", key, definition); | ||
| direct.put(key, definition); | ||
| } | ||
| } | ||
| for (Reward entry : rewardFiles) { | ||
| count = checkCount(count + 1); | ||
| String key = fileKey(entry == null ? null : entry.getRewardName()); | ||
| if (!files.containsKey(key)) { | ||
| PreparedRewardDefinition definition = definitionFor(entry, key); | ||
| recordBytes = addRecordBytes(recordBytes, "F", key, definition); | ||
| files.put(key, definition); | ||
| } | ||
| } | ||
|
|
||
| String records = encodeRecords(direct, files); | ||
| validateRecords(records); | ||
|
BenCodez marked this conversation as resolved.
|
||
| String rootEncoded = preparedRoot.encode(); | ||
| return new PreparedRewardCatalog(preparedRoot, direct, files, records, hash(rootEncoded, records)); | ||
| } | ||
|
|
||
| /** Restores a catalog written by {@link #encode()}, rejecting changed data. */ | ||
| public static PreparedRewardCatalog decode(String encoded) { | ||
| if (encoded == null || encoded.length() > MAX_ENCODED_BYTES || !encoded.startsWith(ENCODED_PREFIX)) { | ||
| throw new PreparedRewardDefinitionException("Malformed prepared reward catalog"); | ||
| } | ||
| String[] fields = encoded.split("/", -1); | ||
| if (fields.length != 5 || !"AdvancedCorePreparedRewardCatalog".equals(fields[0])) { | ||
| throw new PreparedRewardDefinitionException("Malformed prepared reward catalog"); | ||
| } | ||
| if (!String.valueOf(FORMAT_VERSION).equals(fields[1])) { | ||
| throw new PreparedRewardDefinitionException("Unsupported prepared reward catalog version: " + fields[1]); | ||
| } | ||
| String rootEncoded = decodeField(fields[2], "root"); | ||
| String records = decodeField(fields[3], "definitions"); | ||
| validateRecords(records); | ||
| String actualHash = hash(rootEncoded, records); | ||
| if (!MessageDigest.isEqual(fields[4].getBytes(StandardCharsets.US_ASCII), | ||
| actualHash.getBytes(StandardCharsets.US_ASCII))) { | ||
| throw new PreparedRewardDefinitionException("Prepared reward catalog hash does not match"); | ||
| } | ||
|
|
||
| PreparedRewardDefinition root = PreparedRewardDefinition.decode(rootEncoded); | ||
| TreeMap<String, PreparedRewardDefinition> direct = new TreeMap<>(); | ||
| LinkedHashMap<String, PreparedRewardDefinition> files = new LinkedHashMap<>(); | ||
| if (!records.isEmpty()) { | ||
| long recordCount = 1L + records.chars().filter(value -> value == '\n').count(); | ||
| if (recordCount > MAX_DEFINITIONS - 1L) { | ||
| throw new PreparedRewardDefinitionException( | ||
| "Prepared reward catalog exceeds " + MAX_DEFINITIONS + " definitions"); | ||
| } | ||
| for (String record : records.split("\n", -1)) { | ||
|
BenCodez marked this conversation as resolved.
|
||
| String[] parts = record.split("\\|", -1); | ||
| if (parts.length != 3) throw new PreparedRewardDefinitionException("Malformed prepared reward catalog record"); | ||
| String key = decodeField(parts[1], "lookup key"); | ||
| Map<String, PreparedRewardDefinition> target; | ||
| PreparedRewardDefinition definition; | ||
| if ("D".equals(parts[0])) { | ||
| if (!key.equals(directKey(key))) { | ||
| throw new PreparedRewardDefinitionException("Malformed direct prepared reward lookup key"); | ||
| } | ||
| target = direct; | ||
| definition = parts[2].isEmpty() ? null | ||
| : PreparedRewardDefinition.decode(decodeField(parts[2], "definition")); | ||
| } else if ("F".equals(parts[0])) { | ||
| if (!key.equals(fileKey(key))) { | ||
| throw new PreparedRewardDefinitionException("Malformed file prepared reward lookup key"); | ||
| } | ||
| target = files; | ||
| definition = PreparedRewardDefinition.decode(decodeField(parts[2], "definition")); | ||
| } else { | ||
| throw new PreparedRewardDefinitionException("Malformed prepared reward catalog record type"); | ||
| } | ||
| if (target.containsKey(key)) { | ||
| throw new PreparedRewardDefinitionException("Duplicate prepared reward catalog lookup key"); | ||
| } | ||
| target.put(key, definition); | ||
| checkCount(1 + direct.size() + files.size()); | ||
| } | ||
| } | ||
| return new PreparedRewardCatalog(root, direct, files, records, actualHash); | ||
| } | ||
|
|
||
| /** Encodes this bounded catalog for durable storage. */ | ||
| public String encode() { | ||
| String rootEncoded = root.encode(); | ||
| return ENCODED_PREFIX + FORMAT_VERSION + "/" + encodeField(rootEncoded) + "/" + encodeField(records) + "/" | ||
| + versionHash; | ||
| } | ||
|
|
||
| /** Creates a fresh detached root reward for plan preparation. */ | ||
| public Reward instantiateRoot() { | ||
| return root.instantiate(); | ||
| } | ||
|
|
||
| /** | ||
| * Resolves a named entry using the captured registry precedence and returns | ||
| * a fresh detached reward. Unknown names fail closed. | ||
| */ | ||
| public Reward instantiate(String rewardName) { | ||
| String lookupName = RewardRegistry.normalizeLookupName(rewardName); | ||
| if (lookupName.isEmpty()) lookupName = "EmptyName"; | ||
| String directKey = directKeyForLookup(lookupName); | ||
| if (directDefinitions.containsKey(directKey) && directDefinitions.get(directKey) == null) { | ||
| throw new PreparedRewardDefinitionException("Prepared reward catalog has no definition for: " + rewardName); | ||
| } | ||
| PreparedRewardDefinition definition = directDefinitions.get(directKey); | ||
| if (definition == null) { | ||
| String lookup = fileKeyForLookup(lookupName); | ||
| for (Map.Entry<String, PreparedRewardDefinition> entry : fileDefinitions.entrySet()) { | ||
| if (entry.getKey().equalsIgnoreCase(lookup)) { | ||
| definition = entry.getValue(); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (definition == null) { | ||
| throw new PreparedRewardDefinitionException("Prepared reward catalog has no definition for: " + rewardName); | ||
| } | ||
| return definition.instantiate(); | ||
| } | ||
|
|
||
| public String getVersionHash() { | ||
| return versionHash; | ||
| } | ||
|
|
||
| public int getDefinitionCount() { | ||
| return 1 + (int) directDefinitions.values().stream().filter(java.util.Objects::nonNull).count() | ||
| + fileDefinitions.size(); | ||
| } | ||
|
|
||
| private static PreparedRewardDefinition definitionFor(Reward reward, String lookupKey) { | ||
| if (reward == null) { | ||
| throw new PreparedRewardDefinitionException("Could not resolve prepared reward for " + lookupKey); | ||
| } | ||
| return PreparedRewardDefinition.capture(reward); | ||
| } | ||
|
|
||
| private static int checkCount(int count) { | ||
| if (count > MAX_DEFINITIONS) { | ||
| throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_DEFINITIONS + " definitions"); | ||
| } | ||
| return count; | ||
| } | ||
|
|
||
| private static int addRecordBytes(int used, String type, String key, PreparedRewardDefinition definition) { | ||
| // Records are ASCII: Base64 URL fields separated by two pipes and newlines. | ||
| String record = type + "|" + encodeField(key) + "|" | ||
| + (definition == null ? "" : encodeField(definition.encode())); | ||
| long next = (long) used + (used == 0 ? 0 : 1) + record.length(); | ||
| if (next > MAX_CATALOG_BYTES) { | ||
| throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_CATALOG_BYTES + " bytes"); | ||
| } | ||
| return (int) next; | ||
| } | ||
|
|
||
| private static String encodeRecords(Map<String, PreparedRewardDefinition> direct, | ||
| Map<String, PreparedRewardDefinition> files) { | ||
| StringBuilder records = new StringBuilder(); | ||
| for (Map.Entry<String, PreparedRewardDefinition> entry : direct.entrySet()) { | ||
| appendRecord(records, "D|" + encodeField(entry.getKey()) + "|" | ||
| + (entry.getValue() == null ? "" : encodeField(entry.getValue().encode()))); | ||
| } | ||
| for (Map.Entry<String, PreparedRewardDefinition> entry : files.entrySet()) { | ||
| appendRecord(records, "F|" + encodeField(entry.getKey()) + "|" + encodeField(entry.getValue().encode())); | ||
| } | ||
| return records.toString(); | ||
| } | ||
|
|
||
| private static void appendRecord(StringBuilder records, String record) { | ||
| // All record characters are ASCII (the fields use Base64 URL encoding). | ||
| int nextLength = records.length() + (records.length() == 0 ? 0 : 1) + record.length(); | ||
| if (nextLength > MAX_CATALOG_BYTES) { | ||
| throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_CATALOG_BYTES + " bytes"); | ||
| } | ||
| if (records.length() != 0) records.append('\n'); | ||
| records.append(record); | ||
| } | ||
|
|
||
| private static String directKeyForLookup(String name) { | ||
| return directKey(RewardRegistry.normalizeLookupName(name)); | ||
| } | ||
|
|
||
| private static String fileKeyForLookup(String name) { | ||
| return fileKey(RewardRegistry.normalizeLookupName(name)); | ||
| } | ||
|
|
||
| private static String directKey(String name) { | ||
| validateKey(name); | ||
| return RewardRegistry.normalizeDirectPath(name); | ||
| } | ||
|
|
||
| private static String fileKey(String name) { | ||
| validateKey(name); | ||
| return name; | ||
| } | ||
|
BenCodez marked this conversation as resolved.
|
||
|
|
||
| private static void validateKey(String key) { | ||
| if (key == null || key.isEmpty() || key.getBytes(StandardCharsets.UTF_8).length > 256) { | ||
| throw new PreparedRewardDefinitionException("Invalid prepared reward catalog lookup key"); | ||
| } | ||
| } | ||
|
|
||
| private static void validateRecords(String records) { | ||
| if (records == null || records.getBytes(StandardCharsets.UTF_8).length > MAX_CATALOG_BYTES) { | ||
| throw new PreparedRewardDefinitionException("Prepared reward catalog exceeds " + MAX_CATALOG_BYTES + " bytes"); | ||
| } | ||
| } | ||
|
|
||
| private static String encodeField(String value) { | ||
| return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); | ||
| } | ||
|
|
||
| private static String decodeField(String value, String field) { | ||
| try { | ||
| return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8); | ||
| } catch (IllegalArgumentException failure) { | ||
| throw new PreparedRewardDefinitionException("Malformed prepared reward catalog " + field, failure); | ||
| } | ||
| } | ||
|
|
||
| private static String hash(String rootEncoded, String records) { | ||
| try { | ||
| byte[] digest = MessageDigest.getInstance("SHA-256") | ||
| .digest((FORMAT_VERSION + "\n" + rootEncoded + "\n" + records).getBytes(StandardCharsets.UTF_8)); | ||
| StringBuilder result = new StringBuilder(digest.length * 2); | ||
| for (byte value : digest) result.append(String.format("%02x", value)); | ||
| return result.toString(); | ||
| } catch (NoSuchAlgorithmException failure) { | ||
| throw new IllegalStateException("SHA-256 is unavailable", failure); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.