Skip to content

Commit a6892aa

Browse files
committed
Add support for PageFile free page truncation
This commit adds support for index compaction using a truncation strategy as described in #2232 Compaction works by reclaiming space by truncating free pages at the end of the file. A large amount of free pages can be allocated during certain use cases (such as a large message backlogs) and this strategy helps by removing the excess space when possible. The amount of space reclaimed is configurable using a min/max free page ratio. The min free page ratio defines the amount of free pages to leave and the max free page ratio defines the max free pages to allow before attemping to reclaim space. The compaction check will be performed during the normal checkpoint cycle.
1 parent dce599f commit a6892aa

5 files changed

Lines changed: 811 additions & 2 deletions

File tree

activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/KahaDBPersistenceAdapter.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import org.apache.activemq.store.kahadb.data.KahaXATransactionId;
4747
import org.apache.activemq.store.kahadb.disk.journal.DataFileFactory;
4848
import org.apache.activemq.store.kahadb.disk.journal.Journal.JournalDiskSyncStrategy;
49+
import org.apache.activemq.store.kahadb.disk.page.PageFile.PageFileCompactionStrategy;
4950
import org.apache.activemq.usage.SystemUsage;
5051
import org.apache.activemq.util.ServiceStopper;
5152

@@ -774,6 +775,30 @@ public void setEnableSubscriptionStatistics(boolean enableSubscriptionStatistics
774775
letter.setEnableSubscriptionStatistics(enableSubscriptionStatistics);
775776
}
776777

778+
public float getMinFreePageCompactionRatio() {
779+
return letter.getMinFreePageCompactionRatio();
780+
}
781+
782+
public void setMinFreePageCompactionRatio(float minFreePageCompactionRatio) {
783+
letter.setMinFreePageCompactionRatio(minFreePageCompactionRatio);
784+
}
785+
786+
public float getMaxFreePageCompactionRatio() {
787+
return letter.getMaxFreePageCompactionRatio();
788+
}
789+
790+
public void setMaxFreePageCompactionRatio(float maxFreePageCompactionRatio) {
791+
letter.setMaxFreePageCompactionRatio(maxFreePageCompactionRatio);
792+
}
793+
794+
public PageFileCompactionStrategy getIndexCompactionStrategy() {
795+
return letter.getIndexCompactionStrategy();
796+
}
797+
798+
public void setIndexCompactionStrategy(PageFileCompactionStrategy indexCompactionStrategy) {
799+
letter.setIndexCompactionStrategy(indexCompactionStrategy);
800+
}
801+
777802
public KahaDBStore getStore() {
778803
return letter;
779804
}

activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
import org.apache.activemq.store.kahadb.disk.journal.TargetedDataFileAppender;
9898
import org.apache.activemq.store.kahadb.disk.page.Page;
9999
import org.apache.activemq.store.kahadb.disk.page.PageFile;
100+
import org.apache.activemq.store.kahadb.disk.page.PageFile.PageFileCompactionStrategy;
100101
import org.apache.activemq.store.kahadb.disk.page.Transaction;
101102
import org.apache.activemq.store.kahadb.disk.util.LocationMarshaller;
102103
import org.apache.activemq.store.kahadb.disk.util.LongMarshaller;
@@ -291,6 +292,9 @@ public enum PurgeRecoveredXATransactionStrategy {
291292
private boolean enableIndexDiskSyncs = true;
292293
private boolean enableIndexRecoveryFile = true;
293294
private boolean enableIndexPageCaching = true;
295+
private PageFileCompactionStrategy indexCompactionStrategy = PageFileCompactionStrategy.NEVER;
296+
private float minFreePageCompactionRatio = .1F;
297+
private float maxFreePageCompactionRatio = .3F;
294298
ReentrantReadWriteLock checkpointLock = new ReentrantReadWriteLock();
295299

296300
private boolean enableAckCompaction = true;
@@ -1699,6 +1703,8 @@ private void checkpointUpdate(final boolean cleanup) throws IOException {
16991703
Set<Integer> filesToGc = pageFile.tx().execute((Transaction.CallableClosure<Set<Integer>, IOException>)
17001704
tx -> checkpointUpdate(tx, cleanup));
17011705
pageFile.flush();
1706+
pageFile.compact();
1707+
17021708
// after the index update such that partial removal does not leave dangling references in the index.
17031709
journal.removeDataFiles(filesToGc);
17041710
} finally {
@@ -3263,6 +3269,9 @@ private PageFile createPageFile() throws IOException {
32633269
index.setEnableDiskSyncs(isEnableIndexDiskSyncs());
32643270
index.setEnableRecoveryFile(isEnableIndexRecoveryFile());
32653271
index.setEnablePageCaching(isEnableIndexPageCaching());
3272+
index.setCompactionStrategy(getIndexCompactionStrategy());
3273+
index.setMaxFreePageCompactionRatio(getMaxFreePageCompactionRatio());
3274+
index.setMinFreePageCompactionRatio(getMinFreePageCompactionRatio());
32663275
return index;
32673276
}
32683277

@@ -4119,6 +4128,45 @@ public void setEnableSubscriptionStatistics(boolean enableSubscriptionStatistics
41194128
this.enableSubscriptionStatistics = enableSubscriptionStatistics;
41204129
}
41214130

4131+
public float getMinFreePageCompactionRatio() {
4132+
return minFreePageCompactionRatio;
4133+
}
4134+
4135+
/**
4136+
* The ratio of the minimum amount of free pages to keep.
4137+
* The default will keep a minimum of 10% of free pages, relative to the
4138+
* current size of the file when compaction is started.
4139+
*
4140+
* @param minFreePageCompactionRatio
4141+
*/
4142+
public void setMinFreePageCompactionRatio(float minFreePageCompactionRatio) {
4143+
this.minFreePageCompactionRatio = minFreePageCompactionRatio;
4144+
}
4145+
4146+
public float getMaxFreePageCompactionRatio() {
4147+
return maxFreePageCompactionRatio;
4148+
}
4149+
4150+
/**
4151+
* The ratio of the maximum amount of free pages to allow before triggering compaction.
4152+
* The default will trigger a compaction attempt if the percentage of free pages
4153+
* hits 30% of the total file size.
4154+
*
4155+
* @param maxFreePageCompactionRatio
4156+
*/
4157+
public void setMaxFreePageCompactionRatio(float maxFreePageCompactionRatio) {
4158+
this.maxFreePageCompactionRatio = maxFreePageCompactionRatio;
4159+
}
4160+
4161+
public PageFileCompactionStrategy getIndexCompactionStrategy() {
4162+
return indexCompactionStrategy;
4163+
}
4164+
4165+
public void setIndexCompactionStrategy(
4166+
PageFileCompactionStrategy indexCompactionStrategy) {
4167+
this.indexCompactionStrategy = indexCompactionStrategy;
4168+
}
4169+
41224170
private static class MessageDatabaseObjectInputStream extends ObjectInputStream {
41234171

41244172
public MessageDatabaseObjectInputStream(InputStream is) throws IOException {

activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/page/PageFile.java

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import java.util.LinkedHashMap;
3636
import java.util.Map;
3737
import java.util.Map.Entry;
38+
import java.util.Objects;
3839
import java.util.Properties;
3940
import java.util.TreeMap;
4041
import 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

Comments
 (0)