3535import java .util .LinkedHashMap ;
3636import java .util .Map ;
3737import java .util .Map .Entry ;
38+ import java .util .Objects ;
3839import java .util .Properties ;
3940import java .util .TreeMap ;
4041import java .util .concurrent .CountDownLatch ;
@@ -137,8 +138,9 @@ public class PageFile {
137138 private final AtomicLong nextFreePageId = new AtomicLong ();
138139 private SequenceSet freeList = new SequenceSet ();
139140
140- private AtomicReference <SequenceSet > recoveredFreeList = new AtomicReference <SequenceSet >();
141- private AtomicReference <SequenceSet > trackingFreeDuringRecovery = new AtomicReference <SequenceSet >();
141+ // package visibility for testing
142+ final AtomicReference <SequenceSet > recoveredFreeList = new AtomicReference <>();
143+ private final AtomicReference <SequenceSet > trackingFreeDuringRecovery = new AtomicReference <>();
142144
143145 private final AtomicLong nextTxid = new AtomicLong ();
144146
@@ -150,6 +152,33 @@ public class PageFile {
150152 private boolean useLFRUEviction = false ;
151153 private float LFUEvictionFactor = 0.2f ;
152154
155+ // Compaction config
156+ private PageFileCompactionStrategy compactionStrategy = PageFileCompactionStrategy .NEVER ;
157+
158+ // The ratio of the minimum amount of free pages to keep
159+ // The default will keep a minimum of 10% of free pages, relative to the
160+ // current size of the file when compaction is started.
161+ // For example, if the file has 1000 pages and 500 are free at the end of the file,
162+ // 400 would be removed and 100 would be kept (100 is 10% of 1000). This would leave
163+ // a file with 500 pages used + 100 free
164+ private float minFreePageCompactionRatio = .1F ;
165+
166+ // The ratio of the maximum amount of free pages to allow before triggering compaction.
167+ // The default will trigger a compaction attempt if the percentage of free pages
168+ // hits 30% of the total file size.
169+ // For example, if the file has 1000 pages and 350 are free at the end of the file, this
170+ // is greater than 30% so we'd truncate the file. If minFreePageCompactionRatio is kept
171+ // as the default of 10%, 250 pages would be removed leaving 100.
172+ private float maxFreePageCompactionRatio = .3F ;
173+
174+ public enum PageFileCompactionStrategy {
175+ TRUNCATION , NEVER ;
176+
177+ public boolean isNever () {
178+ return this == NEVER ;
179+ }
180+ }
181+
153182 /**
154183 * Use to keep track of updated pages which have not yet been committed.
155184 */
@@ -372,6 +401,9 @@ private void archive(File file, String suffix) throws IOException {
372401 */
373402 public void load () throws IOException , IllegalStateException {
374403 if (loaded .compareAndSet (false , true )) {
404+ if (maxFreePageCompactionRatio < minFreePageCompactionRatio ) {
405+ throw new IllegalStateException ("minFreePageCompactionRatio is greater than maxFreePageCompactionRatio" );
406+ }
375407
376408 if (enablePageCaching ) {
377409 if (isUseLFRUEviction ()) {
@@ -613,6 +645,150 @@ public void flush() throws IOException {
613645 }
614646 }
615647
648+ /**
649+ * Compact the PageFile if needed. The only supported strategy is truncation,
650+ * but future strategies may be added.
651+ * <p>
652+ * PageFile is a contiguous block of pages and new pages get allocated
653+ * as space is needed. If pages are no longer needed anymore, they are
654+ * marked as free so they can be re-used. This normally works well
655+ * for workflows that are consistent.
656+ * <p>
657+ * However, sometimes it's possible to end up with a large number of free
658+ * pages and wasted space. An example is an unusual backlog of data on
659+ * a destination. This can cause a huge increase in the PageFile size to
660+ * track the messages. Once the backlog is gone, the PageFile has a lot
661+ * of free pages and is huge and will never shrink.
662+ * <p>
663+ * Truncation helps in this scenario by simply truncating the file and
664+ * removing the excess free pages. This works well because all we need
665+ * to do is remove the free pages from the tracking and then simply
666+ * set the length of the file to the new size which is nearly instant time,
667+ * so there is no noticeable performance impact.
668+ * <p>
669+ * One major limitation to note is that this strategy only works if the free pages are
670+ * at the end of the file. If the free pages are in the middle of the file
671+ * his won't work so we can't shrink the file. This could happen, for example, if a new
672+ * destination was created while thre was a big backlog and it was written
673+ * to the index. This situation will hopefully be addressed in a future
674+ * compaction update as it is a more complex to handle and will likely require
675+ * a different compaction strategy such as defragging first or rewriting the file.
676+ *
677+ * @throws IOException
678+ */
679+ public void compact () throws IOException {
680+ if (compactionStrategy .isNever ()) {
681+ LOG .debug ("Skipping PageFile compaction check, compaction is disabled." );
682+ return ;
683+ }
684+
685+ LOG .debug ("Beginning PageFile compaction check." );
686+
687+ // Don't compact if we have not finished free page recovery
688+ if (trackingFreeDuringRecovery .get () != null ) {
689+ LOG .debug ("Skipping compaction, async recovery not finished" );
690+ return ;
691+ }
692+
693+ // diskSize computed by checking nextFreePageId
694+ long diskSize = getDiskSize ();
695+
696+ // Disk size of the free pages in the page file
697+ long freePageCount = getFreePageCount ();
698+ long totalPageCount = getPageCount ();
699+
700+ // Percentage of pages that are free vs in use
701+ double freePageRatio = Math .round ((double )freePageCount / totalPageCount * 100d ) / 100d ;
702+
703+ // Only attempt to compact if we have reached the maximum ratio of free pages
704+ // that is configured.
705+ var tolerance = .001 ;
706+ if (freePageRatio < maxFreePageCompactionRatio - tolerance ) {
707+ var formatted = Math .round ((double )freePageCount / totalPageCount * 100d ) / 100d ;
708+ LOG .debug ("Skipping compaction, page file freePageRatio {} is less than "
709+ + "configured maxFreePageCompactionRatio {}" , String .format ("%,.2f" , formatted ), maxFreePageCompactionRatio );
710+ return ;
711+ }
712+
713+ // Get the last sequence of contiguous set of free pages in the file
714+ // This block could be either in the middle of the file somewhere or
715+ // at the end of the file
716+ final Sequence lastFreeSeq = freeList .getTail ();
717+
718+ // Sanity check, should not happen if we have a free page ratio
719+ if (lastFreeSeq == null ) {
720+ LOG .warn ("Skipping compaction, no free pages" );
721+ return ;
722+ }
723+
724+ // If we have a block of free pages then check if it is
725+ // at the end of the file.
726+ //
727+ // If the offset of the last free page + the size of a page
728+ // (to account for the nextFreePage that was allocated already)
729+ // equals the disk size then there are no in use pages after this block.
730+ if (toOffset (lastFreeSeq .getLast ()) + pageSize != diskSize ) {
731+ LOG .info ("Unable to compact, last free page block is not at the end of the file" );
732+ return ;
733+ }
734+
735+ long minFreePages = Math .round (totalPageCount * minFreePageCompactionRatio );
736+
737+ // we must keep a minimum number of free pages so find the point
738+ // where we can truncate without removing too many pages
739+ long maxPagesToTruncate = freePageCount - minFreePages ;
740+
741+ final Sequence freePagesToDelete ;
742+ // If the block of free pages is larger than the max we need to truncate
743+ // then we can split the sequence to only delete the max
744+ if (lastFreeSeq .range () > maxPagesToTruncate ) {
745+ var last = lastFreeSeq .getLast ();
746+ var updatedFirst = (last - maxPagesToTruncate ) + 1 ;
747+ freePagesToDelete = new Sequence (updatedFirst , last );
748+ } else {
749+ freePagesToDelete = lastFreeSeq ;
750+ }
751+
752+ // sync on writes to block the async thread from trying to write while
753+ // we are updating the file and truncating
754+ synchronized (writes ) {
755+ // Generally this should be empty but need to verify
756+ if (!writes .isEmpty ()) {
757+ LOG .warn ("Skipping compaction, writes are in flight." );
758+ return ;
759+ }
760+
761+ LOG .debug ("Number of free pages to be deleted: {}" , freePagesToDelete .range ());
762+ LOG .debug ("Disk size of end of file free pages: {}" , pageSize * freePagesToDelete .range ());
763+
764+ if (enablePageCaching ) {
765+ pageCache .keySet ().removeIf (freePagesToDelete ::contains );
766+ }
767+
768+ long newDiskSize = toOffset (freePagesToDelete .getFirst ());
769+ LOG .debug ("Truncating page file to length: {}" , newDiskSize );
770+
771+ // Remove the free pages from the freeList tracking
772+ if (freePagesToDelete == lastFreeSeq ) {
773+ freeList .removeLastSequence ();
774+ } else {
775+ freeList .remove (freePagesToDelete );
776+ }
777+ nextFreePageId .getAndAdd (-freePagesToDelete .range ());
778+
779+ // Now that metadata is all updated, we can truncate the file
780+ // This should be the last step to make sure there is no issue
781+ // updating any of the metadata/tracking before truncation
782+ writeFile .getRaf ().setLength (newDiskSize );
783+ if (enableDiskSyncs ) {
784+ writeFile .sync ();
785+ }
786+
787+ LOG .debug ("Page file was compacted, new length: {}, old length:{}" , newDiskSize , diskSize );
788+ LOG .debug ("New page count: {}, free page count: {}" , getPageCount (), getFreePageCount ());
789+ }
790+ }
791+
616792
617793 @ Override
618794 public String toString () {
@@ -889,6 +1065,57 @@ public void setUseLFRUEviction(boolean useLFRUEviction) {
8891065 this .useLFRUEviction = useLFRUEviction ;
8901066 }
8911067
1068+ public PageFileCompactionStrategy getCompactionStrategy () {
1069+ return compactionStrategy ;
1070+ }
1071+
1072+ public void setCompactionStrategy (PageFileCompactionStrategy compactionStrategy ) {
1073+ assertNotLoaded ();
1074+ this .compactionStrategy = Objects .requireNonNull (compactionStrategy );
1075+ }
1076+
1077+ public float getMinFreePageCompactionRatio () {
1078+ return minFreePageCompactionRatio ;
1079+ }
1080+
1081+ /**
1082+ * The ratio of the minimum amount of free pages to keep.
1083+ * The default will keep a minimum of 10% of free pages, relative to the
1084+ * current size of the file when compaction is started.
1085+ *
1086+ * @param minFreePageCompactionRatio
1087+ */
1088+ public void setMinFreePageCompactionRatio (float minFreePageCompactionRatio ) {
1089+ validateCompactionRatio (minFreePageCompactionRatio , "minFreePageCompactionRatio" );
1090+ this .minFreePageCompactionRatio = minFreePageCompactionRatio ;
1091+ }
1092+
1093+ public float getMaxFreePageCompactionRatio () {
1094+ return maxFreePageCompactionRatio ;
1095+ }
1096+
1097+ /**
1098+ * The ratio of the maximum amount of free pages to allow before triggering compaction.
1099+ * The default will trigger a compaction attempt if the percentage of free pages
1100+ * hits 30% of the total file size.
1101+ *
1102+ * @param maxFreePageCompactionRatio
1103+ */
1104+ public void setMaxFreePageCompactionRatio (float maxFreePageCompactionRatio ) {
1105+ validateCompactionRatio (maxFreePageCompactionRatio , "maxFreePageCompactionRatio" );
1106+ this .maxFreePageCompactionRatio = maxFreePageCompactionRatio ;
1107+ }
1108+
1109+ private void validateCompactionRatio (float ratio , String name ) {
1110+ assertNotLoaded ();
1111+ if (ratio < 0 ) {
1112+ throw new IllegalArgumentException (name + " must not be negative" );
1113+ }
1114+ if (ratio > 1 ) {
1115+ throw new IllegalArgumentException (name + " must not be greater than 1" );
1116+ }
1117+ }
1118+
8921119 ///////////////////////////////////////////////////////////////////
8931120 // Package Protected Methods exposed to Transaction
8941121 ///////////////////////////////////////////////////////////////////
0 commit comments