diff --git a/src/pocketmine/level/format/io/region/RegionGarbageMap.php b/src/pocketmine/level/format/io/region/RegionGarbageMap.php new file mode 100644 index 000000000..6202007c8 --- /dev/null +++ b/src/pocketmine/level/format/io/region/RegionGarbageMap.php @@ -0,0 +1,154 @@ +entries[$entry->getFirstSector()] = $entry; + } + } + + /** + * @param RegionLocationTableEntry[]|null[] $locationTable + */ + public static function buildFromLocationTable(array $locationTable) : self{ + /** @var RegionLocationTableEntry[] $usedMap */ + $usedMap = []; + foreach($locationTable as $entry){ + if($entry === null){ + continue; + } + if(isset($usedMap[$entry->getFirstSector()])){ + throw new AssumptionFailedError("Overlapping entries detected"); + } + $usedMap[$entry->getFirstSector()] = $entry; + } + + ksort($usedMap, SORT_NUMERIC); + + /** @var RegionLocationTableEntry[] $garbageMap */ + $garbageMap = []; + + /** @var RegionLocationTableEntry|null $prevEntry */ + $prevEntry = null; + foreach($usedMap as $firstSector => $entry){ + $expectedStart = ($prevEntry !== null ? $prevEntry->getLastSector() + 1 : RegionLoader::FIRST_SECTOR); + $actualStart = $entry->getFirstSector(); + if($expectedStart < $actualStart){ + //found a gap in the table + $garbageMap[$expectedStart] = new RegionLocationTableEntry($expectedStart, $actualStart - $expectedStart, 0); + } + $prevEntry = $entry; + } + + return new self($garbageMap); + } + + /** + * @return RegionLocationTableEntry[] + * @phpstan-return array + */ + public function getArray() : array{ + if(!$this->clean){ + ksort($this->entries, SORT_NUMERIC); + + /** @var int|null $prevIndex */ + $prevIndex = null; + foreach($this->entries as $k => $entry){ + if($prevIndex !== null and $this->entries[$prevIndex]->getLastSector() + 1 === $entry->getFirstSector()){ + //this SHOULD overwrite the previous index and not appear at the end + $this->entries[$prevIndex] = new RegionLocationTableEntry( + $this->entries[$prevIndex]->getFirstSector(), + $this->entries[$prevIndex]->getSectorCount() + $entry->getSectorCount(), + 0 + ); + unset($this->entries[$k]); + }else{ + $prevIndex = $k; + } + } + $this->clean = true; + } + return $this->entries; + } + + public function add(RegionLocationTableEntry $entry) : void{ + if(isset($this->entries[$k = $entry->getFirstSector()])){ + throw new \InvalidArgumentException("Overlapping entry starting at " . $k); + } + $this->entries[$k] = $entry; + $this->clean = false; + } + + public function remove(RegionLocationTableEntry $entry) : void{ + if(isset($this->entries[$k = $entry->getFirstSector()])){ + //removal doesn't affect ordering and shouldn't affect fragmentation + unset($this->entries[$k]); + } + } + + public function end() : ?RegionLocationTableEntry{ + $array = $this->getArray(); + $end = end($array); + return $end !== false ? $end : null; + } + + public function allocate(int $newSize) : ?RegionLocationTableEntry{ + foreach($this->getArray() as $start => $candidate){ + $candidateSize = $candidate->getSectorCount(); + if($candidateSize < $newSize){ + continue; + } + + $newLocation = new RegionLocationTableEntry($candidate->getFirstSector(), $newSize, time()); + $this->remove($candidate); + + if($candidateSize > $newSize){ //we're not using the whole area, just take part of it + $newGarbageStart = $candidate->getFirstSector() + $newSize; + $newGarbageSize = $candidateSize - $newSize; + $this->add(new RegionLocationTableEntry($newGarbageStart, $newGarbageSize, 0)); + } + return $newLocation; + + } + + return null; + } +} diff --git a/src/pocketmine/level/format/io/region/RegionLoader.php b/src/pocketmine/level/format/io/region/RegionLoader.php index 05eb3eb5d..7e614f609 100644 --- a/src/pocketmine/level/format/io/region/RegionLoader.php +++ b/src/pocketmine/level/format/io/region/RegionLoader.php @@ -65,7 +65,7 @@ class RegionLoader{ public const MAX_SECTOR_LENGTH = 255 << 12; //255 sectors (~0.996 MiB) public const REGION_HEADER_LENGTH = 8192; //4096 location table + 4096 timestamps - private const FIRST_SECTOR = 2; //location table occupies 0 and 1 + public const FIRST_SECTOR = 2; //location table occupies 0 and 1 /** @var int */ public static $COMPRESSION_LEVEL = 7; @@ -82,6 +82,8 @@ class RegionLoader{ protected $nextSector = self::FIRST_SECTOR; /** @var RegionLocationTableEntry[]|null[] */ protected $locationTable = []; + /** @var RegionGarbageMap */ + protected $garbageTable; /** @var int */ public $lastUsed = 0; @@ -89,6 +91,7 @@ class RegionLoader{ $this->x = $regionX; $this->z = $regionZ; $this->filePath = $filePath; + $this->garbageTable = new RegionGarbageMap([]); } /** @@ -199,18 +202,49 @@ class RegionLoader{ $newSize = (int) ceil(($length + 4) / 4096); $index = self::getChunkOffset($x, $z); - if($this->locationTable[$index] === null or $this->locationTable[$index]->getSectorCount() < $newSize){ - $offset = $this->nextSector; - }else{ - $offset = $this->locationTable[$index]->getFirstSector(); //reuse old location - TODO: risk of corruption during power failure + /* + * look for an unused area big enough to hold this data + * this is corruption-resistant (it leaves the old data intact if a failure occurs when writing new data), and + * also allows the file to become more compact across consecutive writes without introducing a dedicated garbage + * collection mechanism. + */ + $newLocation = $this->garbageTable->allocate($newSize); + + /* if no gaps big enough were found, append to the end of the file instead */ + if($newLocation === null){ + $newLocation = new RegionLocationTableEntry($this->nextSector, $newSize, time()); + $this->bumpNextFreeSector($newLocation); } - $this->bumpNextFreeSector($this->locationTable[$index] = new RegionLocationTableEntry($offset, $newSize, time())); - - fseek($this->filePointer, $offset << 12); + /* write the chunk data into the chosen location */ + fseek($this->filePointer, $newLocation->getFirstSector() << 12); fwrite($this->filePointer, str_pad(Binary::writeInt($length) . chr(self::COMPRESSION_ZLIB) . $chunkData, $newSize << 12, "\x00", STR_PAD_RIGHT)); + /* + * update the file header - we do this after writing the main data, so that if a failure occurs while writing, + * the header will still point to the old (intact) copy of the chunk, instead of a potentially broken new + * version of the file (e.g. partially written). + */ + $oldLocation = $this->locationTable[$index]; + $this->locationTable[$index] = $newLocation; $this->writeLocationIndex($index); + + if($oldLocation !== null){ + /* release the area containing the old copy to the garbage pool */ + $this->garbageTable->add($oldLocation); + + $endGarbage = $this->garbageTable->end(); + $nextSector = $this->nextSector; + for(; $endGarbage !== null and $endGarbage->getLastSector() + 1 === $nextSector; $endGarbage = $this->garbageTable->end()){ + $nextSector = $endGarbage->getFirstSector(); + $this->garbageTable->remove($endGarbage); + } + + if($nextSector !== $this->nextSector){ + $this->nextSector = $nextSector; + ftruncate($this->filePointer, $this->nextSector << 12); + } + } } /** @@ -289,6 +323,8 @@ class RegionLoader{ $this->checkLocationTableValidity(); + $this->garbageTable = RegionGarbageMap::buildFromLocationTable($this->locationTable); + fseek($this->filePointer, 0); } diff --git a/src/pocketmine/level/format/io/region/RegionLocationTableEntry.php b/src/pocketmine/level/format/io/region/RegionLocationTableEntry.php index 05ebe7028..764c0eb9b 100644 --- a/src/pocketmine/level/format/io/region/RegionLocationTableEntry.php +++ b/src/pocketmine/level/format/io/region/RegionLocationTableEntry.php @@ -42,8 +42,8 @@ class RegionLocationTableEntry{ throw new \InvalidArgumentException("Start sector must be positive, got $firstSector"); } $this->firstSector = $firstSector; - if($sectorCount < 1 or $sectorCount > 255){ - throw new \InvalidArgumentException("Sector count must be in range 1...255, got $sectorCount"); + if($sectorCount < 1){ + throw new \InvalidArgumentException("Sector count must be positive, got $sectorCount"); } $this->sectorCount = $sectorCount; $this->timestamp = $timestamp;