diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/AbstractLevelHash.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/AbstractLevelHash.kt index d77f03b9b..2ea72455a 100644 --- a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/AbstractLevelHash.kt +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/AbstractLevelHash.kt @@ -195,15 +195,14 @@ internal abstract class AbstractLevelHash internal constructo } override fun remove(key: K): V? { - return findSlot(key).let { slot -> - if (slot != null) { - val oldValue = slot.value - slot.reset(null, null) - return@let oldValue - } - - return@let null + val slot = findSlot(key) + if (slot != null) { + val oldValue = slot.value + slot.reset(null, null) + return oldValue } + + return null } override fun findSlot(key: K): LevelSlot? { diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/BinaryFileUtils.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/BinaryFileUtils.kt new file mode 100644 index 000000000..4cefa311d --- /dev/null +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/BinaryFileUtils.kt @@ -0,0 +1,78 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.levelhash.internal + +import com.itsaky.androidide.levelhash.internal.PersistentKeymapIO.Companion.KEYMAP_HEADER_SIZE_BYTES +import com.itsaky.androidide.levelhash.internal.PersistentKeymapIO.Companion.KEYMAP_MAGIC_NUMBER +import org.slf4j.LoggerFactory +import java.io.DataInputStream +import java.io.File +import java.io.RandomAccessFile + +/** + * @author Akash Yadav + */ +internal object BinaryFileUtils { + + private val log = LoggerFactory.getLogger(BinaryFileUtils::class.java) + + fun initSparseFile(file: File, magicNumber: Long, maxLength: Long, + conf: (raf: RandomAccessFile) -> Unit = {} + ) { + val raf = RandomAccessFile(file, "rw") + if (!file.exists()) { + doInit(file, raf, magicNumber, maxLength, conf) + return + } + + if (file.isDirectory) { + file.deleteRecursively() + } else if (file.isFile && file.length() < KEYMAP_HEADER_SIZE_BYTES) { + check(file.delete()) { "Failed to delete existing index file" } + } else { + DataInputStream(file.inputStream()).use { dis -> + val magic = dis.readLong() + if (magic != magicNumber) { + // mismatched magic number + // delete file and create new one + log.warn( + "Magic number mismatch for index: {}. Deleting existing file...", + file.canonicalPath) + check(file.delete()) { "Failed to delete existing index file" } + } + } + } + + doInit(file, raf, magicNumber, maxLength, conf) + } + + private inline fun doInit(file: File, raf: RandomAccessFile, magic: Long, + maxLength: Long, + conf: (raf: RandomAccessFile) -> Unit = {} + ) { + check(file.createNewFile()) { + "Failed to create keymap file: ${file.canonicalPath}" + } + +// raf.setLength(maxLength) + + raf.seek(0) + raf.writeLong(magic) + raf.use(conf) + } +} \ No newline at end of file diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/InMemoryLevelSlot.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/InMemoryLevelSlot.kt index 4556ce731..340f5ed7e 100644 --- a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/InMemoryLevelSlot.kt +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/InMemoryLevelSlot.kt @@ -57,8 +57,8 @@ internal class InMemoryLevelSlot private constructor( */ override fun reset(key: K?, value: V?) { lock.writeLock().withLock { - _key = key ?: _key - _value = value ?: _value + _key = key + _value = value } } } \ No newline at end of file diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentKeymapIO.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentKeymapIO.kt index a70c04389..1c2cdd184 100644 --- a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentKeymapIO.kt +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentKeymapIO.kt @@ -37,7 +37,7 @@ internal class PersistentKeymapIO(indexFile: File */ private val keymapSegments = arrayOfNulls(KEYMAP_MAX_SEGMENTS) private val keymapFile = File(indexFile.parentFile, "${indexFile.name}._i") - private val raKeymapFile = RandomAccessFile(keymapFile, "rw") + private val raKeymapFile by lazy { RandomAccessFile(keymapFile, "rw") } private val segmentCount by lazy { if (!keymapFile.exists() || !keymapFile.isFile || keymapFile.length() <= KEYMAP_HEADER_SIZE_BYTES) { @@ -45,25 +45,25 @@ internal class PersistentKeymapIO(indexFile: File return@lazy 1 } - raKeymapFile.seek(KEYMAP_HEADER_SIZE_BYTES.toLong()) + raKeymapFile.seek(KEYMAP_HEADER_SIZE_BYTES) val count = raKeymapFile.readLong() raKeymapFile.seek(0) return@lazy count } - override fun close() { - keymapSegments.forEach { segment -> - try { - segment?.unmap() - } catch (err: Throwable) { - log.error("Failed to unmap segment", err) - } - } + init { + BinaryFileUtils.initSparseFile( + file = keymapFile, + magicNumber = KEYMAP_MAGIC_NUMBER, + maxLength = KEYMAP_HEADER_SIZE_BYTES + KEYMAP_SEGMENT_SIZE_BYTES * KEYMAP_MAX_SEGMENTS + ) { raf -> + var position = MAGIC_NUMBER_SIZE_BYTES + raf.seek(position) + raf.writeInt(0) + position += SIZE_INT - try { - raKeymapFile.close() - } catch (e: Throwable) { - log.error("Failed to close keymap file", e) + raf.seek(position) + raf.writeLong(KEYMAP_SEGMENT_SIZE_BYTES) } } @@ -96,19 +96,31 @@ internal class PersistentKeymapIO(indexFile: File return segment to positionInSegment } - fun deallocate( - offset: Long, - len: Long + fun deallocate(offset: Long, len: Long ) { FileUtils.deallocate(raKeymapFile, offset, len) } - fun deallocate( - segment: KeymapSegment, - positionInSegment: Long, - len: Long + fun deallocate(segment: KeymapSegment, positionInSegment: Long, len: Long ) { - FileUtils.deallocate(raKeymapFile, segment.segmentOffset + positionInSegment, len) + FileUtils.deallocate(raKeymapFile, + segment.segmentOffset + positionInSegment, len) + } + + override fun close() { + keymapSegments.forEach { segment -> + try { + segment?.unmap() + } catch (err: Throwable) { + log.error("Failed to unmap segment", err) + } + } + + try { + raKeymapFile.close() + } catch (e: Throwable) { + log.error("Failed to close keymap file", e) + } } companion object { diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHash.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHash.kt index 141030913..13f56cd6b 100644 --- a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHash.kt +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHash.kt @@ -57,8 +57,8 @@ internal class PersistentLevelHash( override val key: K get() = checkNotNull(io.readKey(levelIdx, bucketIdx, slotIdx)) - override val value: V - get() = checkNotNull(io.readValue(levelIdx, bucketIdx, slotIdx)) + override val value: V? + get() = io.readValue(levelIdx, bucketIdx, slotIdx) override fun isOccupied(): Boolean { return io.isOccupied(levelIdx, bucketIdx, slotIdx) diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHashIO.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHashIO.kt index 4871208b6..0a457d4aa 100644 --- a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHashIO.kt +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/internal/PersistentLevelHashIO.kt @@ -23,9 +23,11 @@ import com.itsaky.androidide.levelhash.internal.PersistentKeymapIO.Companion.KEY import com.itsaky.androidide.levelhash.internal.PersistentKeymapIO.Companion.KEYMAP_SEGMENT_SIZE_BYTES import com.itsaky.androidide.levelhash.seekBoolean import com.itsaky.androidide.levelhash.seekInt +import com.itsaky.androidide.levelhash.seekShort import com.itsaky.androidide.levelhash.util.DataExternalizers.SIZE_BOOLEAN import com.itsaky.androidide.levelhash.util.DataExternalizers.SIZE_INT import com.itsaky.androidide.levelhash.util.DataExternalizers.SIZE_LONG +import com.itsaky.androidide.levelhash.util.DataExternalizers.SIZE_SHORT import com.itsaky.androidide.levelhash.util.FileUtils import com.itsaky.androidide.levelhash.util.MappedRandomAccessIO import org.slf4j.LoggerFactory @@ -33,7 +35,6 @@ import java.io.File import java.io.RandomAccessFile import java.nio.channels.FileChannel - private val logger = LoggerFactory.getLogger(PersistentLevelHashIO::class.java) /** @@ -67,6 +68,7 @@ private val logger = LoggerFactory.getLogger(PersistentLevelHashIO::class.java) * * ``` * keymap { + * u64 magic_number; * u32 segment_count; * u64 segment_size; * segment segments[segment_count]; @@ -100,6 +102,7 @@ private val logger = LoggerFactory.getLogger(PersistentLevelHashIO::class.java) * * value { * u8 is_occupied; + * u16 entry_size; * u32 key_size; * u8 key[key_size]; * u32 value_size; @@ -109,8 +112,8 @@ private val logger = LoggerFactory.getLogger(PersistentLevelHashIO::class.java) * ``` * @author Akash Yadav */ -class PersistentLevelHashIO( - private val indexFile: File, +internal class PersistentLevelHashIO( + indexFile: File, private val keyExternalizer: DataExternalizer, private val valueExternalizer: DataExternalizer, ) : AutoCloseable { @@ -123,53 +126,51 @@ class PersistentLevelHashIO( private val keymapIo = PersistentKeymapIO(indexFile) - private val raIndexFile by lazy { - RandomAccessFile(indexFile, "rw") - } + private val metaFile = File(indexFile.parentFile, "${indexFile.name}._meta") + private val raMetaFile by lazy { RandomAccessFile(metaFile, "rw") } + private val raIndexFile by lazy { RandomAccessFile(indexFile, "rw") } - private val metaFile by lazy { - File(indexFile.parentFile, "${indexFile.name}._meta") - } - - private val raMetaFile by lazy { - RandomAccessFile(metaFile, "rw") - } - - private var valuesFirstEntryAt = 0L - private var valuesLastEntryAt = 0L - private var valuesWriteAt = 0L - private var valuesMapSize = 0L - private var valuesIo = MappedRandomAccessIO() + private var valFirstEntryPos = 0L + private var valNxtEntryPos = 0L + private var valIdxSize = 0L + private var valIo = MappedRandomAccessIO() init { - if (indexFile.exists()) { - if (indexFile.isDirectory) { - indexFile.deleteRecursively() - } else if (indexFile.isFile && indexFile.length() < VALUES_HEADER_SIZE_BYTES) { - indexFile.delete() - } - } else { - indexFile.createNewFile() + BinaryFileUtils.initSparseFile( + file = indexFile, + magicNumber = VALUES_MAGIC_NUMBER, + maxLength = VALUES_HEADER_SIZE_BYTES + valIdxSize + ) { raf -> + var position = MAGIC_NUMBER_SIZE_BYTES + raf.seek(position) + raf.writeLong(0) // first entry + position += SIZE_LONG + + raf.seek(position) + raf.writeLong(0) // next entry } - // TODO(itsaky): Load the below values from file when the file already exists + // TODO: Load the below values from file when the file already exists - valuesLastEntryAt = -1 - valuesWriteAt = 0 - valuesFirstEntryAt = VALUES_HEADER_SIZE_BYTES - valuesMapSize = VALUES_MAX_MAP_SIZE_BYTES + valNxtEntryPos = 0 + valFirstEntryPos = VALUES_HEADER_SIZE_BYTES + valIdxSize = VALUES_MAX_MAP_SIZE_BYTES // the header region is not memory mapped log.debug("mmap values file: offset={}, size={}", VALUES_HEADER_SIZE_BYTES, - valuesMapSize) + valIdxSize) val buffer = raIndexFile.channel.map(FileChannel.MapMode.READ_WRITE, - VALUES_HEADER_SIZE_BYTES, valuesMapSize) - valuesIo.reset(buffer, 0L, valuesMapSize) + VALUES_HEADER_SIZE_BYTES, valIdxSize) + valIo.reset(buffer, 0L, valIdxSize) } private fun getValuesRealOffset(offset: Long) = VALUES_HEADER_SIZE_BYTES + offset + private fun valuesDeallocate(offset: Long, len: Long) { + FileUtils.deallocate(raIndexFile, getValuesRealOffset(offset), len) + } + /** * Get the address of the value entry in the values for the given slot * position. @@ -192,23 +193,6 @@ class PersistentLevelHashIO( } } - fun isOccupied( - levelNum: Int, - bucketIdx: Int, - slotIdx: Int, - ): Boolean { - val valueAddr = valueAddressForPos(levelNum, bucketIdx, slotIdx) - if (valueAddr == 0L) { - // 0 == no value entry - return false - } - - valuesIo.position(valueAddr - 1) - - // token must be set and key size must be > 0 - return valuesIo.readBoolean() && valuesIo.readInt() > 0 - } - private fun moveToSlot( levelNum: Int, bucketIdx: Int, @@ -220,10 +204,23 @@ class PersistentLevelHashIO( return false } - valuesIo.position(valueAddr - 1) + valIo.position(valueAddr - 1) return true } + fun isOccupied( + levelNum: Int, + bucketIdx: Int, + slotIdx: Int, + ): Boolean { + if (!moveToSlot(levelNum, bucketIdx, slotIdx)) { + return false + } + + // token must be set and key size must be > 0 + return valIo.readBoolean() && valIo.readInt() > 0 + } + /** * Visits the key region for the given slot position. * @@ -238,24 +235,31 @@ class PersistentLevelHashIO( return null } - val isOccupied = valuesIo.readBoolean() + val isOccupied = valIo.readBoolean() if (!isOccupied) { return null } - val keySize = valuesIo.readInt() + if (valIo.readUnsignedShort() <= 0L) { + // entry size is 0, so the slot is empty + return null + } + + val keySize = valIo.readInt() if (keySize == 0) { if (readKey) { - logReadFailure("key", "key size is 0", levelNum, bucketIdx, slotIdx) + log.info( + "Cannot read 'key' at slot (key size is 0): level={}, bucket={}, slot={}", + levelNum, bucketIdx, slotIdx) } return null } if (readKey) { - val keyAddr = valuesIo.position() - val key = keyExternalizer.read(valuesIo) - val readBytes = (valuesIo.position() - keyAddr).toInt() + val keyAddr = valIo.position() + val key = keyExternalizer.read(valIo) + val readBytes = (valIo.position() - keyAddr).toInt() check(readBytes == keySize) { "Key size mismatch. Expected to read $keySize bytes, but $readBytes bytes were read." } @@ -264,7 +268,7 @@ class PersistentLevelHashIO( } if (keySize > 0) { - valuesIo.skipBytes(keySize) + valIo.skipBytes(keySize) } return null to keySize @@ -297,15 +301,14 @@ class PersistentLevelHashIO( return null } - val valueSize = valuesIo.readInt() + val valueSize = valIo.readInt() if (valueSize == 0) { - logReadFailure("value", "value size is 0", levelNum, bucketIdx, slotIdx) return null } - val valAddr = valuesIo.position() - val value = valueExternalizer.read(valuesIo) - val readBytes = (valuesIo.position() - valAddr).toInt() + val valAddr = valIo.position() + val value = valueExternalizer.read(valIo) + val readBytes = (valIo.position() - valAddr).toInt() check(readBytes == valueSize) { "Value size mismatch. Expected to read $valueSize bytes, but $readBytes bytes were read." } @@ -329,32 +332,59 @@ class PersistentLevelHashIO( return } - // TODO(itsaky): Punch holes in the region of the existing entry on update - // to ensure the disk space is released + segment.position(positionInSegment) + val existingValueAddr = segment.readLong() + val (isUpdate, existingEntrySize) = run { + if (existingValueAddr > 0L) { + valIo.position(existingValueAddr - 1) + valIo.readBoolean() to valIo.readUnsignedShort().toLong() + } else { + false to 0L + } + } - // leave space for the 'is_occupied' token - val tokenAddr = valuesWriteAt - valuesIo.position(tokenAddr) - valuesIo.seekBoolean() + val tokenAddr = valNxtEntryPos + valIo.position(tokenAddr) + + // leave space for isOccupied token and entry size + valIo.seekBoolean() + valIo.seekShort() + + val keyValPos = valIo.position() // write key and value writeSlotKeyOrVal(key, keyExternalizer) writeSlotKeyOrVal(value, valueExternalizer) - val finalAddr = valuesIo.position() + val finalAddr = valIo.position() + val entrySize = finalAddr - keyValPos + check(entrySize <= Short.MAX_VALUE) { + "Entry size is too large: $entrySize" + } // then set the token - valuesIo.position(tokenAddr) - valuesIo.writeBoolean(true) + valIo.position(tokenAddr) + valIo.writeBoolean(true) + valIo.writeShort((entrySize and 0xFFFF).toInt()) // reset to the final position - valuesIo.position(finalAddr) + valIo.position(finalAddr) // then update the address in the keymap segment.position(positionInSegment) segment.writeLong(tokenAddr + 1) - valuesWriteAt = finalAddr + valNxtEntryPos = finalAddr + + if (isUpdate) { + var size: Long = SIZE_BOOLEAN + SIZE_SHORT + if (existingEntrySize > 0L) { + size += existingEntrySize + } + + // deallocate the region which contains the existing entry + valuesDeallocate(existingValueAddr - 1, size) + } } private fun deleteEntry( @@ -369,47 +399,48 @@ class PersistentLevelHashIO( return null } + // reading this region again will return 0, which is considered + // a null pointer keymapIo.deallocate(segment, positionInSegment, SIZE_LONG) - valuesIo.position(valueAddr - 1) + valIo.position(valueAddr - 1) var entrySize = 0L - if (!valuesIo.readBoolean()) { + if (!valIo.readBoolean()) { // slot is not occupied return null } entrySize += SIZE_BOOLEAN // isOccupied + entrySize += SIZE_SHORT // entrySize - val keySize = valuesIo.readInt() + val keySize = valIo.readInt() entrySize += SIZE_INT entrySize += keySize if (keySize > 0) { - valuesIo.seekRelative(keySize) + valIo.seekRelative(keySize) } - val valueSize = valuesIo.readInt() + val valueSize = valIo.readInt() entrySize += SIZE_INT entrySize += valueSize var value: V? = null if (readValue && valueSize > 0) { - val valAddr = valuesIo.position() - value = valueExternalizer.read(valuesIo) + val valAddr = valIo.position() + value = valueExternalizer.read(valIo) // ensure that the externalizer read exactly the expected bytes - check((valuesIo.position() - valAddr).toInt() == valueSize) + check((valIo.position() - valAddr).toInt() == valueSize) } else if (valueSize > 0) { - valuesIo.seekRelative(valueSize) + valIo.seekRelative(valueSize) } // punch holes in the file - FileUtils.deallocate(raIndexFile, getValuesRealOffset(valueAddr - 1), - entrySize) + valuesDeallocate(valueAddr - 1, entrySize) - // TODO(itsaky): When deleting an entry, update the corresponding keymap entry with -1 // TODO(itsaky): When an entry is deleted, update valueFirstEntryAt and valueLastEntryAt accordingly return value } @@ -417,10 +448,10 @@ class PersistentLevelHashIO( fun clear() { keymapIo.deallocate(KEYMAP_HEADER_SIZE_BYTES, KEYMAP_SEGMENT_SIZE_BYTES * KEYMAP_MAX_SEGMENTS) - - FileUtils.deallocate(raIndexFile, VALUES_HEADER_SIZE_BYTES, valuesMapSize) + valuesDeallocate(VALUES_HEADER_SIZE_BYTES, valIdxSize) } + @Suppress("UNUSED", "UNUSED_PARAMETER") fun tryMoveToInterim( levelNum: Int, bucketIdx: Int, @@ -437,22 +468,22 @@ class PersistentLevelHashIO( // obj is not null // leave space for the obj size and write the key // then write the number of bytes written for obj to the sizeAddr - val sizeAddr = valuesIo.position() - valuesIo.seekInt() + val sizeAddr = valIo.position() + valIo.seekInt() - val valueAddr = valuesIo.position() - externalizer.write(valuesIo, obj) + val valueAddr = valIo.position() + externalizer.write(valIo, obj) - val finalAddr = valuesIo.position() - valuesIo.position(sizeAddr) - valuesIo.writeInt((finalAddr - valueAddr).toInt().also { + val finalAddr = valIo.position() + valIo.position(sizeAddr) + valIo.writeInt((finalAddr - valueAddr).toInt().also { check(it > 0) { "Key/Value must be at least 1 byte in size" } }) - valuesIo.position(finalAddr) + valIo.position(finalAddr) } else { // object is null, write the size of the obj as 0 // since obj size is 0, we don't need to write the obj - valuesIo.writeInt(0) + valIo.writeInt(0) } } @@ -477,18 +508,6 @@ class PersistentLevelHashIO( } } - private fun logReadFailure( - what: String, - reason: String, - levelNum: Int, - bucketIdx: Int, - slotIdx: Int, - ) { - log.info( - "Cannot read '$what' at slot ($reason): level={}, bucket={}, slot={}", - levelNum, bucketIdx, slotIdx) - } - companion object { diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/DataExternalizers.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/DataExternalizers.kt index 0491fd4d4..f8956272a 100644 --- a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/DataExternalizers.kt +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/DataExternalizers.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.levelhash.util import com.itsaky.androidide.levelhash.DataExternalizer +import com.itsaky.androidide.utils.uncheckedCast import java.io.DataInput import java.io.DataOutput @@ -179,4 +180,33 @@ object DataExternalizers { out.writeUTF(data) } } -} \ No newline at end of file +} + +/** + * Create a [DataExternalizer] for nullable type [T]. An extra byte before the + * actual data is used to represent the null state. + */ +fun DataExternalizer.nullable(): DataExternalizer { + if (this is NullableDataExternalizer<*>) { + return uncheckedCast(this) + } + return NullableDataExternalizer(this) +} + +private class NullableDataExternalizer( + private val self: DataExternalizer +) : DataExternalizer { + override fun read(input: DataInput): T? { + if (input.readBoolean()) { + return null + } + return self.read(input) + } + + override fun write(out: DataOutput, data: T?) { + out.writeBoolean(data == null) + if (data != null) { + self.write(out, data) + } + } +} diff --git a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/MappedRandomAccessIO.kt b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/MappedRandomAccessIO.kt index df30cc29a..988ad381b 100644 --- a/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/MappedRandomAccessIO.kt +++ b/utilities/level-hash/src/main/java/com/itsaky/androidide/levelhash/util/MappedRandomAccessIO.kt @@ -25,7 +25,6 @@ import com.itsaky.androidide.levelhash.seekFloat import com.itsaky.androidide.levelhash.seekInt import com.itsaky.androidide.levelhash.seekLong import com.itsaky.androidide.levelhash.seekShort -import com.itsaky.androidide.levelhash.util.DataExternalizers.SIZE_BYTE import java.io.DataInput import java.io.DataInputStream import java.io.DataOutput @@ -46,16 +45,16 @@ internal class MappedRandomAccessIO : DataInput, DataOutput, RandomAccessIO { private lateinit var buffer: MappedByteBuffer - fun reset(buffer: MappedByteBuffer, offset: Long, length: Long) { - this.buffer = buffer - this.position = offset - this.size = length + internal fun buf(): MappedByteBuffer = buf(this.position) + + internal fun buf(position: Long): MappedByteBuffer { + return this.buffer.apply { position(position.toInt()) } } - private fun buf(): MappedByteBuffer = buf(this.position) - - private fun buf(position: Long): MappedByteBuffer { - return this.buffer.apply { position(position.toInt()) } + fun reset(buffer: MappedByteBuffer, position: Long, length: Long) { + this.buffer = buffer + this.position = position + this.size = length } override fun position(position: Long) { @@ -101,9 +100,7 @@ internal class MappedRandomAccessIO : DataInput, DataOutput, RandomAccessIO { } override fun readByte(): Byte { - return buf(position).get().also { - position += SIZE_BYTE - } + return buf(position).get().also { seekByte() } } override fun readUnsignedByte(): Int { diff --git a/utilities/level-hash/src/test/java/com/itsaky/androidide/levelhash/PersistentLevelHashTest.kt b/utilities/level-hash/src/test/java/com/itsaky/androidide/levelhash/PersistentLevelHashTest.kt index 102c69e82..494ed42ef 100644 --- a/utilities/level-hash/src/test/java/com/itsaky/androidide/levelhash/PersistentLevelHashTest.kt +++ b/utilities/level-hash/src/test/java/com/itsaky/androidide/levelhash/PersistentLevelHashTest.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.levelhash import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.levelhash.LevelHash.OverflowException import com.itsaky.androidide.levelhash.util.DataExternalizers +import com.itsaky.androidide.levelhash.util.nullable import com.itsaky.androidide.testing.common.LinuxOnlyTestRule import org.junit.Rule import org.junit.Test @@ -38,20 +39,21 @@ class PersistentLevelHashTest { @Rule val linuxOnlyTestRule = LinuxOnlyTestRule() - private fun persistentStringHash( - fileName: String, - conf: PersistentHashBuilder.() -> Unit = {} - ): LevelHash { + private inline fun persistentStringHash(fileName: String, + valueExternalizer: DataExternalizer, + conf: PersistentHashBuilder.() -> Unit = {} + ): LevelHash { val indexFile = File("./build/sample-index/${fileName}.storage") if (indexFile.parentFile!!.exists()) { indexFile.parentFile!!.deleteRecursively() } indexFile.parentFile!!.mkdirs() + indexFile.createNewFile() - val hash = LevelHash.persistentHashBuilder() + val hash = LevelHash.persistentHashBuilder() .keyExternalizer(DataExternalizers.STRING) - .valueExternalizer(DataExternalizers.STRING) + .valueExternalizer(valueExternalizer) .indexFile(indexFile) .apply(conf) .levelSize(2) @@ -63,6 +65,12 @@ class PersistentLevelHashTest { return hash } + private inline fun persistentStringHash(fileName: String, + conf: PersistentHashBuilder.() -> Unit = {} + ): LevelHash { + return persistentStringHash(fileName, DataExternalizers.STRING, conf) + } + @Test fun testInsertion() { val hash = persistentStringHash("insert") @@ -109,4 +117,40 @@ class PersistentLevelHashTest { assertThat(hash.findSlot("key")).isNull() } + + @Test(expected = LevelHash.EntryNotFoundException::class) + fun `test value update for non-existing entry`() { + val hash = persistentStringHash("update-non-existing-entry") + + assertThat(hash.insert("k", "v")).isTrue() + + val slot = hash.findSlot("k") + assertThat(slot).isNotNull() + assertThat(slot!!.key).isEqualTo("k") + assertThat(slot.value).isEqualTo("v") + + assertThat(hash.set("kk", "newV")).isNull() + } + + @Test + fun `test value update for existing entry with null value`() { + val hash = persistentStringHash( + fileName = "update-existing-with-null-value", + valueExternalizer = DataExternalizers.STRING.nullable() + ) + + assertThat(hash.insert("k", null)).isTrue() + + var slot = hash.findSlot("k") + assertThat(slot).isNotNull() + assertThat(slot!!.key).isEqualTo("k") + assertThat(slot.value).isNull() + + assertThat(hash.set("k", "newV")).isNull() + + slot = hash.findSlot("k") + assertThat(slot).isNotNull() + assertThat(slot!!.key).isEqualTo("k") + assertThat(slot.value).isEqualTo("newV") + } }