Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/main/java/com/fastsync/data/PlayerData.java
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ public class PlayerData {
// Runtime-only marker: this instance contains only a dirty component
// subset and must never be serialized as the authoritative full Blob.
private boolean componentSubset;
// Runtime-only mask of components actually collected into a subset. This
// freezes the entity-thread config decision across the async persistence
// boundary, where a config reload may otherwise enable an uncollected field.
private long collectedComponentMask;

public PlayerData() {
this(true);
Expand Down Expand Up @@ -209,6 +213,10 @@ public static PlayerData forComponentSubset() {

public boolean isComponentSubset() { return componentSubset; }
public void setComponentSubset(boolean componentSubset) { this.componentSubset = componentSubset; }
public void markComponentCollected(long storageMask) { collectedComponentMask |= storageMask; }
public boolean isComponentCollected(long storageMask) {
return (collectedComponentMask & storageMask) != 0L;
}

// ==================== Inner Data Classes ====================

Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/fastsync/database/DatabaseManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -1471,7 +1471,8 @@ public ComponentBatchResult upsertComponentsIfLockHeld(
conn.setAutoCommit(oldAutoCommit);
return ComponentBatchResult.rejected("stale collected version (expected: "
+ expectedVersion + ", actual: " + currentVersion + ")",
ComponentRejectReason.STALE_VERSION);
ComponentRejectReason.STALE_VERSION, currentVersion,
currentBitmap, generation);
}

// 3. Upsert component rows with current generation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,12 +384,15 @@ private static ItemStack[] fromItemStackList(ListTag list) {
ItemStack[] items = new ItemStack[list.size()];
for (int i = 0; i < list.size(); i++) {
Tag element = list.get(i);
if (element instanceof net.momirealms.sparrow.nbt.ByteArrayTag baTag) {
// ItemStackCompat.deserialize returns null for an empty payload
// (a real air slot) and throws ItemSerializationException on
// genuine corruption. Both behaviors are correct here.
items[i] = ItemStackCompat.deserialize(baTag.getAsByteArray());
}
if (!(element instanceof net.momirealms.sparrow.nbt.ByteArrayTag baTag)) {
throw new ItemSerializationException("Invalid item tag at slot " + i
+ ": expected ByteArrayTag, got "
+ (element == null ? "null" : element.getClass().getSimpleName()));
}
// ItemStackCompat.deserialize returns null for an empty payload
// (a real air slot) and throws ItemSerializationException on
// genuine corruption. Both behaviors are correct here.
items[i] = ItemStackCompat.deserialize(baTag.getAsByteArray());
}
return items;
}
Expand Down
87 changes: 79 additions & 8 deletions src/main/java/com/fastsync/sync/SyncManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@
private volatile List<org.bukkit.entity.EntityType> aliveEntities; // isAlive() == true

// Parsed snapshot trigger set (computed once at init/reload, O(1) lookup per save).
private volatile java.util.Set<String> snapshotTriggerSet;
private volatile java.util.Set<String> snapshotTriggerSet = java.util.Set.of();

// Sync strategies (initialized in initialize())
private com.fastsync.sync.strategy.PdcSyncStrategy pdcStrategy;
Expand Down Expand Up @@ -651,6 +651,8 @@
databaseManager.loadPlayerDataRow(uuid);
componentCursors.put(uuid,
new ComponentCursor(loaded.componentGeneration(), loaded.componentBitmap()));
verifyComponentBaselineConsistency(uuid, loaded.hasData(),
loaded.componentBitmap(), loaded.componentGeneration());
long loadElapsedMs = (System.nanoTime() - startTime) / 1_000_000;
latencyMonitor.recordLoad(loadElapsedMs);

Expand Down Expand Up @@ -1661,12 +1663,16 @@
data.setArmor(snapshotItemContents(inventory.getArmorContents()));
org.bukkit.inventory.ItemStack offhand = inventory.getItemInOffHand();
data.setOffhand(offhand != null && !offhand.getType().isAir() ? offhand.clone() : null);
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.INVENTORY);
}

// Ender chest
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.ENDER_CHEST)
&& config.isSyncEnderChest()) {
data.setEnderChest(snapshotItemContents(player.getEnderChest().getStorageContents()));
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.ENDER_CHEST);
}

// Vitals
Expand Down Expand Up @@ -1698,6 +1704,8 @@
boolean dead = deathState != null || player.isDead() || currentHealth <= 0;
data.setHealth(healthForSave(dead, currentHealth, effectiveMaxHealth));
data.setMaxHealth(baseMaxHealth);
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.VITALS);
}
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.FOOD)
&& config.isSyncFood()) {
Expand All @@ -1711,6 +1719,8 @@
data.setSaturation(player.getSaturation());
data.setExhaustion(player.getExhaustion());
}
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.FOOD);
}

// Experience
Expand All @@ -1729,6 +1739,8 @@
data.setExpProgress(player.getExp());
data.setTotalExperience(player.getTotalExperience());
}
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.EXPERIENCE);
}

// Potion effects
Expand All @@ -1741,23 +1753,31 @@
}
}
data.setPotionEffects(effects);
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.POTION_EFFECTS);
}

// Extra
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.GAME_MODE)
&& config.isSyncGameMode()) {
data.setGameMode(player.getGameMode());
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.GAME_MODE);
}
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.FIRE_TICKS)
&& config.isSyncFireTicks()) {
data.setFireTicks(deathState != null || player.isDead() ? 0 : player.getFireTicks());
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.FIRE_TICKS);
}
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.AIR)
&& config.isSyncAir()) {
int maximumAir = player.getMaximumAir();
data.setMaximumAir(maximumAir);
data.setRemainingAir(
deathState != null || player.isDead() ? maximumAir : player.getRemainingAir());
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.AIR);
}

// Flight status
Expand All @@ -1783,24 +1803,32 @@
}
data.setFlying(flying);
data.setAllowFlight(allowFlight);
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.FLIGHT);
}

// Advancements (using Bukkit API - iterates all advancement criteria)
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.ADVANCEMENTS)
&& config.isSyncAdvancements()) {
collectAdvancements(player, data);
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.ADVANCEMENTS);
}

// Statistics (basic UNTYPED stats always synced; typed stats via strategy)
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.STATISTICS)
&& config.isSyncStatistics()) {
collectStatistics(player, data);
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.STATISTICS);
}

// Attributes
if (collects(requested, com.fastsync.sync.dirty.ComponentDirtyMask.Component.ATTRIBUTES)
&& config.isSyncAttributes()) {
collectAttributes(player, data);
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.ATTRIBUTES);
}

// Persistent Data Container (via strategy)
Expand All @@ -1816,6 +1844,8 @@
} else {
data.setPersistentDataContainer(new HashMap<>());
}
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.PDC);
}

// Location (optional)
Expand All @@ -1829,6 +1859,8 @@
data.setZ(loc.getZ());
data.setYaw(loc.getYaw());
data.setPitch(loc.getPitch());
markComponentCollected(data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component.LOCATION);
}

data.setTimestamp(System.currentTimeMillis());
Expand All @@ -1842,6 +1874,13 @@
return requested == null || requested.contains(component);
}

private static void markComponentCollected(PlayerData data,
com.fastsync.sync.dirty.ComponentDirtyMask.Component component) {
if (data.isComponentSubset()) {
data.markComponentCollected(component.storageMask());
}
}

/**
* Detach Paper's CraftItemStack mirrors while still on the entity thread.
* CraftInventory#getStorageContents returns a new array, but Paper 1.21.11
Expand All @@ -1866,6 +1905,7 @@
private boolean canCollectComponentsOnly(UUID uuid, SaveKind kind,
com.fastsync.sync.dirty.ComponentDirtyMask.DirtySnapshot snapshot) {
if (!config.isComponentStorageEnabled() || dirtyMask == null || kind.releaseLock
|| (snapshotManager != null && shouldCreateSnapshot(kind.causeName))
|| snapshot == null || snapshot.isEmpty()
|| !playersWithBaseline.contains(uuid) || !componentCursors.containsKey(uuid)) {
return false;
Expand All @@ -1874,7 +1914,7 @@
// One baseline row is also cheaper than a transaction containing every
// component row.
if (snapshot.size() >= com.fastsync.sync.dirty.ComponentDirtyMask.Component.values().length
|| snapshot.size() > config.getComponentBatchSize()) {

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / DB Stress (Testcontainers)

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / DB Stress (Testcontainers)

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 26.2 E2E

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 1.21.11 E2E

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 1.21.11 API

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 1.21.11 API

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Build & Test

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Build & Test

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Full CI

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Full CI

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 26.2.build.40-alpha API

cannot find symbol

Check failure on line 1917 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 26.2.build.40-alpha API

cannot find symbol
return false;
}
for (com.fastsync.sync.dirty.ComponentDirtyMask.Component component :
Expand Down Expand Up @@ -3424,8 +3464,15 @@

for (com.fastsync.sync.dirty.ComponentDirtyMask.Component c : dirty) {
String name = c.name();
// Skip components that are disabled in config
if (!isComponentSyncEnabled(c)) continue;
// A selective carrier freezes the entity-thread collection
// decision. Re-reading config here is unsafe: a reload between
// collect and async persistence could enable an uncollected
// primitive field and write its Java default as authoritative.
if (data.isComponentSubset()) {
if (!data.isComponentCollected(c.storageMask())) continue;
} else if (!isComponentSyncEnabled(c)) {
continue;
}

byte[] serialized = PlayerDataSerializer.serializeComponent(name, data);
if (serialized == null) continue; // component has no data
Expand Down Expand Up @@ -3517,8 +3564,16 @@
// Our collected snapshot is behind the DB version.
// Skip THIS online save — do NOT fall back to a full
// Blob save with the stale snapshot (it would clobber
// the newer state). The next periodic cycle re-collects
// against the current version.
// the newer state). The rejection carries metadata read
// only after lock/fencing/session validation, so advance
// the session-local version/cursor before the next cycle.
// Without this refresh every later component CAS repeats
// the same stale expectedVersion forever.
if (batchResult.newVersion() > data.getVersion()) {
playerVersions.put(uuid, batchResult.newVersion());
componentCursors.put(uuid, new ComponentCursor(
batchResult.generation(), batchResult.componentBitmap()));
}
logger.warning("[Component] Save rejected for " + uuid
+ " (STALE_VERSION): " + batchResult.errorMessage()
+ " — skipping this online save; next cycle re-collects.");
Expand Down Expand Up @@ -3574,7 +3629,7 @@
// concurrent markDirty bumped an epoch during the DB write, that
// component stays dirty and the next periodic save will re-serialize
// and re-write it with the latest state.
dirtyMask.clearDirty(uuid, dirtySnapshot);
dirtyMask.clearDirty(uuid, dirtySnapshot, dirtyBits);

if (config.isDebug()) {
logger.info("Component save " + kind + " for " + uuid + ": "
Expand Down Expand Up @@ -3759,6 +3814,7 @@
if (config.isComponentStorageEnabled()
&& dirtyMask != null
&& !kind.releaseLock
&& (snapshotManager == null || !shouldCreateSnapshot(data.getSaveCause()))
&& dirtyMask.isAnyDirty(uuid)) {
// Pass the caller-provided snapshot (taken before collectPlayerData)
// so persistComponentsOnly's clearDirty after the DB write protects
Expand All @@ -3773,7 +3829,7 @@
return outcome.result();
}
case FALLBACK_FULL_BLOB -> {
// A selectively collected PlayerData intentionally omits

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / DB Stress (Testcontainers)

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / DB Stress (Testcontainers)

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 26.2 E2E

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 1.21.11 E2E

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 1.21.11 API

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 1.21.11 API

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Build & Test

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Build & Test

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Full CI

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Full CI

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 26.2.build.40-alpha API

cannot find symbol

Check failure on line 3832 in src/main/java/com/fastsync/sync/SyncManager.java

View workflow job for this annotation

GitHub Actions / Paper 26.2.build.40-alpha API

cannot find symbol
// unrelated components. It is never safe to turn that into
// the authoritative full Blob if an invariant unexpectedly
// invalidates the component path after collection.
Expand Down Expand Up @@ -4020,8 +4076,13 @@
// From this point on, component-only saves are safe for this player
// for the remainder of the session.
playersWithBaseline.add(uuid);
componentCursors.compute(uuid, (ignored, current) ->
new ComponentCursor(current == null ? 1L : current.generation() + 1L, 0L));
// Advance only a cursor that was loaded authoritatively. Inventing
// generation=1 when the cursor is absent is wrong after a hot reload
// if the DB row is already at generation N. Leaving it absent makes
// the next component save use the compatibility transaction, which
// locks the row, reads the real generation, and seeds a correct cursor.
componentCursors.computeIfPresent(uuid, (ignored, current) ->
new ComponentCursor(current.generation() + 1L, 0L));

// Clear dirty flags using the pre-save snapshot's epoch.
//
Expand Down Expand Up @@ -4461,6 +4522,16 @@
}
}

static void verifyComponentBaselineConsistency(
UUID uuid, boolean hasBaselineData, long componentBitmap,
long generation) throws IOException {
if (!hasBaselineData && componentBitmap != 0L) {
throw new IOException("component_bitmap is non-zero without a full-Blob baseline for "
+ uuid + " (gen=" + generation + ", bitmap=0x"
+ Long.toHexString(componentBitmap) + ")");
}
}

static java.util.Set<String> componentNamesForBitmap(long bitmap) throws IOException {
java.util.Set<String> names = new java.util.HashSet<>();
long knownMask = 0L;
Expand Down
12 changes: 9 additions & 3 deletions src/main/java/com/fastsync/sync/dirty/ComponentDirtyMask.java
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,13 @@ public DirtySnapshot snapshotDirty(UUID uuid) {
*/
public void clearDirty(UUID uuid, DirtySnapshot snapshot) {
PlayerDirtyState state = masks.get(uuid);
if (state != null) state.clearIfEpochMatches(snapshot);
if (state != null) state.clearIfEpochMatches(snapshot, ~0L);
}

/** Clear only successfully persisted snapshot components. */
public void clearDirty(UUID uuid, DirtySnapshot snapshot, long persistedBits) {
PlayerDirtyState state = masks.get(uuid);
if (state != null) state.clearIfEpochMatches(snapshot, persistedBits);
}

/**
Expand Down Expand Up @@ -286,10 +292,10 @@ DirtySnapshot snapshotWithEpochs() {
return bits == 0 ? DirtySnapshot.EMPTY : new DirtySnapshot(bits, snapshotStates);
}

void clearIfEpochMatches(DirtySnapshot snapshot) {
void clearIfEpochMatches(DirtySnapshot snapshot, long persistedBits) {
if (snapshot == null || snapshot.isEmpty()) return;
for (Component component : Component.values()) {
if (!snapshot.contains(component)) continue;
if (!snapshot.contains(component) || (persistedBits & bit(component)) == 0L) continue;
int index = component.ordinal();
long expected = snapshot.states[index];
states.compareAndSet(index, expected, expected & ~DIRTY_FLAG);
Expand Down
Loading
Loading