diff --git a/build.gradle b/build.gradle index 2baf15d..3b356e4 100644 --- a/build.gradle +++ b/build.gradle @@ -23,4 +23,4 @@ java { test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/src/main/java/database/BufferPool.java b/src/main/java/database/BufferPool.java new file mode 100644 index 0000000..baa97db --- /dev/null +++ b/src/main/java/database/BufferPool.java @@ -0,0 +1,53 @@ +package database; + +import java.util.Map; + +public class BufferPool { + + private static final int BUFFER_SIZE = 40; + + private final Map bufferPool; + private final PageManager pageManager; + + public BufferPool(PageManager pageManager) { + this.bufferPool = new LRUCache<>(BUFFER_SIZE, 0.75f, true, pageManager); + this.pageManager = pageManager; + } + + public Page getPage(PageId pageId) { + Page page = bufferPool.computeIfAbsent(pageId, id -> pageManager.loadPage(pageId)); + page.pin(); + return page; + } + + public void modifyPage(PageId pageId) { + Page page = getPage(pageId); + /* + ..modify.. + */ + page.setDirty(true); + page.unPin(); + } + + /** + * 플러시 리스트 플러시 + */ + public void flush() { + for (Map.Entry entry : bufferPool.entrySet()) { + Page page = entry.getValue(); + + if (!page.isPinned() && page.isDirty()) { + pageManager.savePage(entry.getKey(), page); + page.setDirty(false); + } + } + } + + public Map getBufferPool() { + return bufferPool; + } + + public PageManager getPageManager() { + return pageManager; + } +} diff --git a/src/main/java/database/FileManager.java b/src/main/java/database/FileManager.java new file mode 100644 index 0000000..f2680fe --- /dev/null +++ b/src/main/java/database/FileManager.java @@ -0,0 +1,62 @@ +package database; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class FileManager { + + private static final String DIRECTORY_PATH = "data/files/"; + private static final String FILE_EXTENSION = ".ibd"; + + public FileManager() { + createDirectory(); + } + + private void createDirectory() { + Path directory = Paths.get(DIRECTORY_PATH); + if (Files.notExists(directory)) { + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + public Page readPage(PageId pageId) { + Path filePath = Paths.get(DIRECTORY_PATH, pageId.getFileName() + FILE_EXTENSION); + + try (RandomAccessFile file = new RandomAccessFile(filePath.toString(), "r")) { + long offset = (long) pageId.getPageNum() * Page.PAGE_SIZE; + file.seek(offset); + + try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file.getFD()))) { + return (Page) ois.readObject(); + } + } catch (IOException | ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + public void writePage(PageId pageId, Page page) { + Path filePath = Paths.get(DIRECTORY_PATH, pageId.getFileName() + FILE_EXTENSION); + + try (RandomAccessFile file = new RandomAccessFile(filePath.toString(), "rw")) { + long offset = (long) pageId.getPageNum() * Page.PAGE_SIZE; + file.seek(offset); + + try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file.getFD()))) { + oos.writeObject(page); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/database/LRUCache.java b/src/main/java/database/LRUCache.java new file mode 100644 index 0000000..6961016 --- /dev/null +++ b/src/main/java/database/LRUCache.java @@ -0,0 +1,34 @@ +package database; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class LRUCache extends LinkedHashMap { + + private final int bufferSize; + private final PageManager pageManager; + + public LRUCache(int initialCapacity, float loadFactor, boolean accessOrder, PageManager pageManager) { + super(initialCapacity, loadFactor, accessOrder); + this.pageManager = pageManager; + this.bufferSize = initialCapacity; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + boolean isBufferPoolOverCapacity = size() > bufferSize; + boolean isUnpinned = !((Page) eldest.getValue()).isPinned(); + + if (isBufferPoolOverCapacity && isUnpinned) { + flushIfDirty((PageId) eldest.getKey(), (Page) eldest.getValue()); + return true; + } + return false; + } + + private void flushIfDirty(PageId pageId, Page page) { + if (page.isDirty()) { + pageManager.savePage(pageId, page); + } + } +} diff --git a/src/main/java/database/Page.java b/src/main/java/database/Page.java new file mode 100644 index 0000000..c184bdc --- /dev/null +++ b/src/main/java/database/Page.java @@ -0,0 +1,92 @@ +package database; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class Page implements Serializable { + + public static final int PAGE_SIZE = 16 * 1024; + + private final PageHeader header; + private final List records; + private int freeSpace; + private int pinCount; + + public Page(int pageNum, PageType pageType) { + this.header = new PageHeader(pageNum, pageType); + this.records = new ArrayList<>(); + this.freeSpace = PAGE_SIZE - header.getHeaderSize(); + } + + public boolean addRecord(Record record) { + if (freeSpace >= record.getSize()) { + records.add(record); + freeSpace -= record.getSize(); + + header.incrementRecordCount(); + header.setDirty(true); + + return true; + } else { + return false; + } + } + + public boolean deleteRecord(Record record) { + if (records.remove(record)) { + freeSpace += record.getSize(); + + header.setDirty(true); + header.decrementRecordCount(); + + return true; + } else { + return false; + } + } + + public void pin() { + this.pinCount++; + } + + public void unPin() { + this.pinCount--; + } + + public boolean isPinned() { + return this.pinCount > 0; + } + + public PageHeader getHeader() { + return header; + } + + public List getRecords() { + return records; + } + + public int getFreeSpace() { + return freeSpace; + } + + public int getPinCount() { + return pinCount; + } + + public void setDirty(boolean isDirty) { + header.setDirty(isDirty); + } + + public boolean isDirty() { + return header.isDirty(); + } + + public PageType getPageType() { + return header.getPageType(); + } + + public int getPageNum() { + return header.getPageNum(); + } +} diff --git a/src/main/java/database/PageHeader.java b/src/main/java/database/PageHeader.java new file mode 100644 index 0000000..fa225d8 --- /dev/null +++ b/src/main/java/database/PageHeader.java @@ -0,0 +1,52 @@ +package database; + +import java.io.Serializable; + +public class PageHeader implements Serializable { + + public static final int HEADER_SIZE = 13; + + private final int pageNum; + private final PageType pageType; + private int recordCount; + private boolean isDirty; + + public PageHeader(int pageNum, PageType pageType) { + this.pageNum = pageNum; + this.pageType = pageType; + this.recordCount = 0; + this.isDirty = false; + } + + public void incrementRecordCount() { + this.recordCount++; + } + + public void decrementRecordCount() { + this.recordCount--; + } + + public void setDirty(boolean isDirty) { + this.isDirty = isDirty; + } + + public int getPageNum() { + return pageNum; + } + + public PageType getPageType() { + return pageType; + } + + public int getRecordCount() { + return recordCount; + } + + public boolean isDirty() { + return isDirty; + } + + public int getHeaderSize() { + return HEADER_SIZE; + } +} diff --git a/src/main/java/database/PageId.java b/src/main/java/database/PageId.java new file mode 100644 index 0000000..4c4cef7 --- /dev/null +++ b/src/main/java/database/PageId.java @@ -0,0 +1,39 @@ +package database; + +import java.util.Objects; + +public class PageId { + + private final String fileName; + private final int PageNum; + + public PageId(String fileName, int pageNum) { + this.fileName = fileName; + this.PageNum = pageNum; + } + + public String getFileName() { + return fileName; + } + + public int getPageNum() { + return PageNum; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PageId pageId = (PageId) o; + return PageNum == pageId.PageNum && Objects.equals(fileName, pageId.fileName); + } + + @Override + public int hashCode() { + return Objects.hash(fileName, PageNum); + } +} diff --git a/src/main/java/database/PageManager.java b/src/main/java/database/PageManager.java new file mode 100644 index 0000000..2e366ef --- /dev/null +++ b/src/main/java/database/PageManager.java @@ -0,0 +1,18 @@ +package database; + +public class PageManager { + + private final FileManager fileManager; + + public PageManager(FileManager fileManager) { + this.fileManager = fileManager; + } + + public Page loadPage(PageId pageId) { + return fileManager.readPage(pageId); + } + + public void savePage(PageId pageId, Page page) { + fileManager.writePage(pageId, page); + } +} diff --git a/src/main/java/database/PageType.java b/src/main/java/database/PageType.java new file mode 100644 index 0000000..3ae70bf --- /dev/null +++ b/src/main/java/database/PageType.java @@ -0,0 +1,8 @@ +package database; + +public enum PageType { + + CLUSTERED_INDEX, + SECONDARY_INDEX, + UNDO_LOG; +} diff --git a/src/main/java/database/Record.java b/src/main/java/database/Record.java new file mode 100644 index 0000000..83da838 --- /dev/null +++ b/src/main/java/database/Record.java @@ -0,0 +1,26 @@ +package database; + +import java.io.Serializable; + +public class Record implements Serializable { + + private final int recordId; + private final byte[] data; + + public Record(int recordId, byte[] data) { + this.recordId = recordId; + this.data = data; + } + + public int getRecordId() { + return recordId; + } + + public byte[] getData() { + return data; + } + + public int getSize() { + return data.length; + } +} diff --git a/src/main/java/database/page/Page.java b/src/main/java/database/page/Page.java deleted file mode 100644 index b421f9e..0000000 --- a/src/main/java/database/page/Page.java +++ /dev/null @@ -1,19 +0,0 @@ -package database.page; - -public class Page { - private final long pageNum; - private final byte[] data; - - public Page(long pageNum, byte[] data) { - this.pageNum = pageNum; - this.data = data; - } - - public long getPageNum() { - return pageNum; - } - - public byte[] getData() { - return data; - } -} diff --git a/src/test/java/database/BufferPoolTest.java b/src/test/java/database/BufferPoolTest.java new file mode 100644 index 0000000..ba52a5f --- /dev/null +++ b/src/test/java/database/BufferPoolTest.java @@ -0,0 +1,73 @@ +package database; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class BufferPoolTest { + + private PageManager pageManager; + private BufferPool bufferPool; + + private PageId pageId; + private Page page; + + @BeforeEach + void setUp() { + FileManager fileManager = new FileManager(); + pageManager = new PageManager(fileManager); + bufferPool = new BufferPool(pageManager); + + pageId = new PageId("jazz", 1); + page = new Page(1, PageType.SECONDARY_INDEX); + } + + @DisplayName("버퍼 풀에 없는 페이지를 불러오면 디스크에서 버퍼 풀로 페이지를 적재한다") + @Test + void loadPageFromDiskWhenNotInCache() { + pageManager.savePage(pageId, page); + + Page loadedPage = bufferPool.getPage(pageId); + + assertAll( + () -> assertThat(loadedPage.getPageType()).isEqualTo(page.getPageType()), + () -> assertThat(loadedPage.getPageNum()).isEqualTo(page.getPageNum()), + () -> assertThat(loadedPage.getFreeSpace()).isEqualTo(page.getFreeSpace()), + () -> assertThat(loadedPage.isPinned()).isTrue() + ); + } + + @DisplayName("플러시 호출 시 핀 상태인 페이지는 디스크에 저장되지 않는다.") + @Test + void notFlushPinnedPageToDisk() { + page.setDirty(true); + page.pin(); + bufferPool.getBufferPool().put(pageId, page); + + bufferPool.flush(); + + assertAll( + () -> assertThat(page.isPinned()).isTrue(), + () -> assertThat(page.isDirty()).isTrue() + ); + } + + @DisplayName("플러시 호출 시 핀 상태가 아닌 페이지는 디스크에 저장되고 더티 페이지가 아니다.") + @Test + void flushUnpinnedPageToDisk() { + page.setDirty(true); + page.pin(); + page.unPin(); + bufferPool.getBufferPool().put(pageId, page); + + bufferPool.flush(); + + assertAll( + () -> assertThat(page.isPinned()).isFalse(), + () -> assertThat(page.isDirty()).isFalse() + ); + } +} diff --git a/src/test/java/database/FileManagerTest.java b/src/test/java/database/FileManagerTest.java new file mode 100644 index 0000000..2334cfd --- /dev/null +++ b/src/test/java/database/FileManagerTest.java @@ -0,0 +1,36 @@ +package database; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class FileManagerTest { + + private FileManager fileManager; + private PageId pageId; + private Page page; + + @BeforeEach + void setUp() { + fileManager = new FileManager(); + pageId = new PageId("jazz", 1); + page = new Page(1, PageType.SECONDARY_INDEX); + } + + @DisplayName("파일에 페이지를 저장하고, 읽어올 수 있다.") + @Test + void writePageTest() { + fileManager.writePage(pageId, page); + + Page readPage = fileManager.readPage(pageId); + + assertAll( + () -> assertThat(readPage.getPageType()).isEqualTo(page.getPageType()), + () -> assertThat(readPage.getPageNum()).isEqualTo(page.getPageNum()), + () -> assertThat(readPage.getFreeSpace()).isEqualTo(page.getFreeSpace()) + ); + } +} diff --git a/src/test/java/database/LRUCacheTest.java b/src/test/java/database/LRUCacheTest.java new file mode 100644 index 0000000..0277ed0 --- /dev/null +++ b/src/test/java/database/LRUCacheTest.java @@ -0,0 +1,49 @@ +package database; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LRUCacheTest { + + private LRUCache lruCache; + + private PageId pageId1; + private Page page1; + private PageId pageId2; + private Page page2; + + @BeforeEach + void setUp() { + FileManager fileManager = new FileManager(); + PageManager pageManager = new PageManager(fileManager); + lruCache = new LRUCache<>(2, 0.75f, true, pageManager); + + pageId1 = new PageId("jazz", 1); + page1 = new Page(1, PageType.CLUSTERED_INDEX); + + pageId2 = new PageId("jazz", 2); + page2 = new Page(2, PageType.CLUSTERED_INDEX); + } + + @DisplayName("캐시가 가득차고 새 페이지가 추가되었을 때, 가장 오래된 페이지가 캐시에서 제거된다.") + @Test + void removeEldestEntryTest() { + lruCache.put(pageId1, page1); + lruCache.put(pageId2, page2); + + PageId newPageId = new PageId("jazz", 3); + Page newPage = new Page(3, PageType.CLUSTERED_INDEX); + + lruCache.get(pageId1); // cache hit + lruCache.put(newPageId, newPage); + + assertAll( + () -> assertThat(lruCache.containsKey(pageId2)).isFalse(), + () -> assertThat(lruCache.containsKey(newPageId)).isTrue() + ); + } +} diff --git a/src/test/java/database/PageIdTest.java b/src/test/java/database/PageIdTest.java new file mode 100644 index 0000000..a3f8230 --- /dev/null +++ b/src/test/java/database/PageIdTest.java @@ -0,0 +1,28 @@ +package database; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Objects; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PageIdTest { + + @DisplayName("내부 필드가 모두 같으면 동일한 객체이다.") + @Test + void equalsTest() { + PageId pageId1 = new PageId("jazz", 1); + PageId pageId2 = new PageId("jazz", 1); + + assertThat(Objects.equals(pageId1, pageId2)).isTrue(); + } + + @DisplayName("내부 필드가 모두 같으면 동일한 해시를 반환한다.") + @Test + void hashCodeTest() { + PageId pageId1 = new PageId("jazz", 1); + PageId pageId2 = new PageId("jazz", 1); + + assertThat(pageId1.hashCode()).isEqualTo(pageId2.hashCode()); + } +} diff --git a/src/test/java/database/PageTest.java b/src/test/java/database/PageTest.java new file mode 100644 index 0000000..c813e36 --- /dev/null +++ b/src/test/java/database/PageTest.java @@ -0,0 +1,65 @@ +package database; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PageTest { + + @DisplayName("페이지에 레코드를 추가하는데 성공하면 true를 반환한다.") + @Test + void addRecordSuccessTest() { + Page page = new Page(1, PageType.CLUSTERED_INDEX); + Record record = new Record(1, new byte[100]); + + int previousSize = page.getFreeSpace(); + boolean isAdded = page.addRecord(record); + + assertAll( + () -> assertThat(isAdded).isTrue(), + () -> assertThat(page.getFreeSpace()).isEqualTo(previousSize - record.getSize()), + () -> assertThat(page.isDirty()).isTrue() + ); + } + + @DisplayName("페이지에 레코드를 추가하는데 실패하면 false를 반환한다.") + @Test + void addRecordFailTest() { + Page page = new Page(1, PageType.CLUSTERED_INDEX); + Record record = new Record(1, new byte[Page.PAGE_SIZE]); + + boolean isAdded = page.addRecord(record); + + assertThat(isAdded).isFalse(); + } + + @DisplayName("페이지에 레코드를 삭제하는데 성공하면 true를 반환한다.") + @Test + void deleteRecordSuccessTest() { + Page page = new Page(1, PageType.CLUSTERED_INDEX); + Record record = new Record(1, new byte[100]); + page.addRecord(record); + + int previousSize = page.getFreeSpace(); + boolean isDeleted = page.deleteRecord(record); + + assertAll( + () -> assertThat(isDeleted).isTrue(), + () -> assertThat(page.getFreeSpace()).isEqualTo(previousSize + record.getSize()), + () -> assertThat(page.isDirty()).isTrue() + ); + } + + @DisplayName("페이지에 레코드를 삭제하는데 실패하면 false를 반환한다.") + @Test + void deleteRecordFailTest() { + Page page = new Page(1, PageType.CLUSTERED_INDEX); + Record record = new Record(1, new byte[Page.PAGE_SIZE]); + + boolean isDeleted = page.deleteRecord(record); + + assertThat(isDeleted).isFalse(); + } +}