Skip to content

Commit

Permalink
Add LRUMemberCachePolicy (#2506)
Browse files Browse the repository at this point in the history
  • Loading branch information
MinnDevelopment authored Sep 23, 2023
1 parent ef35fc5 commit c75c04e
Show file tree
Hide file tree
Showing 4 changed files with 298 additions and 63 deletions.
62 changes: 0 additions & 62 deletions src/examples/java/LRUCachePolicy.java

This file was deleted.

39 changes: 39 additions & 0 deletions src/main/java/net/dv8tion/jda/api/utils/MemberCachePolicy.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.utils.cache.LRUMemberCachePolicy;
import net.dv8tion.jda.internal.utils.Checks;

import javax.annotation.Nonnull;
Expand All @@ -38,6 +39,14 @@
*
* <p>This can be configured with {@link net.dv8tion.jda.api.JDABuilder#setMemberCachePolicy(MemberCachePolicy) JDABuilder.setMemberCachePolicy(MemberCachePolicy)}.
*
* <p><b>Example Policy</b><br>
* <pre>{@code
* MemberCachePolicy.VOICE // Keep in cache if currently in voice (skip LRU and ONLINE)
* .or(MemberCachePolicy.ONLINE) // Otherwise, only add to cache if online
* .and(MemberCachePolicy.lru(1000) // keep 1000 recently active members
* .unloadUnless(MemberCachePolicy.VOICE)) // only unload if they are not in voice/guild owner
* }</pre>
*
* @see #DEFAULT
* @see #NONE
* @see #ALL
Expand Down Expand Up @@ -202,4 +211,34 @@ static MemberCachePolicy all(@Nonnull MemberCachePolicy policy, @Nonnull MemberC
policy = policy.and(p);
return policy;
}

/**
* Implementation using a Least-Recently-Used (LRU) cache strategy.
*
* <p><b>Example</b><br>
* <pre>{@code
* MemberCachePolicy.ONLINE.and( // only cache online members
* MemberCachePolicy.lru(1000) // of those online members, track the 1000 most active members
* .unloadUnless(MemberCachePolicy.VOICE) // always keep voice members cached regardless of age
* )
* }</pre>
*
* This policy would add online members into the pool of cached members.
* The cached members are limited to 1000 active members, which are handled by the LRU policy.
* When the LRU cache exceeds the maximum, it will evict the least recently active member from cache.
* If the sub-policy, in this case {@link MemberCachePolicy#VOICE}, evaluates to {@code true}, the member is retained in cache.
* Otherwise, the member is unloaded using {@link Guild#unloadMember(long)}.
*
* <p>Note that the LRU policy itself always returns {@code true} for {@link #cacheMember(Member)}, since that makes the member the <b>most recently used</b> instead.
*
* @param maxSize
* The maximum cache capacity of the LRU cache
*
* @return {@link LRUMemberCachePolicy}
*/
@Nonnull
static LRUMemberCachePolicy lru(int maxSize)
{
return new LRUMemberCachePolicy(maxSize);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/*
* Copyright 2015 Austin Keener, Michael Ritter, Florian Spieß, and the JDA contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package net.dv8tion.jda.api.utils.cache;

import gnu.trove.map.TObjectIntMap;
import gnu.trove.map.hash.TObjectIntHashMap;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.utils.MemberCachePolicy;
import net.dv8tion.jda.internal.utils.Checks;

import javax.annotation.Nonnull;
import java.util.*;

/**
* An implementation of a Least-Recently-Used cache.
* <br>When the cache capacity exceeds the configured maximum, the eldest cache entry is evicted.
*
* <p>You can use {@link #unloadUnless(MemberCachePolicy)}, to configure a conditional unloading.
* If the configured sub-policy evaluates to {@code true}, the member will not be unloaded even when it is an old cache entry.
*
* <p>This is implemented using a queue and counter algorithm, to achieve amortized O(1) performance.
*
* <p><b>Example</b><br>
* <pre>{@code
* MemberCachePolicy.ONLINE.and( // only cache online members
* MemberCachePolicy.lru(1000) // of those online members, track the 1000 most active members
* .unloadUnless(MemberCachePolicy.VOICE) // always keep voice members cached regardless of age
* )
* }</pre>
*
* This policy would add online members into the pool of cached members.
* The cached members are limited to 1000 active members, which are handled by the LRU policy.
* When the LRU cache exceeds the maximum, it will evict the least recently active member from cache.
* If the sub-policy, in this case {@link MemberCachePolicy#VOICE}, evaluates to {@code true}, the member is retained in cache.
* Otherwise, the member is unloaded using {@link Guild#unloadMember(long)}.
*
* <p>Note that the LRU policy itself always returns {@code true} for {@link #cacheMember(Member)}, since that makes the member the <b>most recently used</b> instead.
*
* @see MemberCachePolicy#lru(int)
*/
public class LRUMemberCachePolicy implements MemberCachePolicy
{
private static final long EPOCH_SECONDS = System.currentTimeMillis() / 1000;

private final int maxMembers;

// Low activity members (usage based, trades memory for cpu time)
private final TObjectIntMap<Member> counters;
private final ArrayDeque<MemberNode> queue;

// High activity members (time based, trades cpu time for memory)
private LinkedHashMap<Member, Integer> activeMemberCache;

private MemberCachePolicy subPolicy;
private int useActiveMemberCache;

/**
* Creates a new instance of the LRU cache policy with the configured maximum capacity.
*
* @param maxMembers
* The maximum amount members to cache
*
* @throws IllegalArgumentException
* If the provided maximum is not positive
*/
public LRUMemberCachePolicy(int maxMembers)
{
this(maxMembers, MemberCachePolicy.NONE);
}

private LRUMemberCachePolicy(int maxMembers, @Nonnull MemberCachePolicy subPolicy)
{
Checks.positive(maxMembers, "Max members");
Checks.notNull(subPolicy, "MemberCachePolicy");
this.maxMembers = maxMembers;
this.counters = new TObjectIntHashMap<>(maxMembers);
this.queue = new ArrayDeque<>(maxMembers);
this.useActiveMemberCache = Math.max(10, this.maxMembers / 10);
this.activeMemberCache = new LinkedHashMap<>();
this.subPolicy = subPolicy;
}

/**
* Configure when to unload a member.
* <br>The provided policy will prevent a member from being uncached, if the policy returns true.
* This can be useful to have a pool of least-recently-used members cached,
* while also keeping members required for certain situations in cache.
*
* @param subPolicy
* The policy to decide when to keep members cached, even when they are old cache entries
*
* @throws IllegalArgumentException
* If the provided policy is null
*
* @return The same cache policy instance, with the new sub-policy
*/
@Nonnull
public LRUMemberCachePolicy unloadUnless(@Nonnull MemberCachePolicy subPolicy)
{
Checks.notNull(subPolicy, "MemberCachePolicy");
this.subPolicy = subPolicy;
return this;
}

@Nonnull
public synchronized LRUMemberCachePolicy withActiveMemberCache(boolean enabled)
{
return withActiveMemberCache(enabled ? this.maxMembers / 10 : 0);
}

@Nonnull
public synchronized LRUMemberCachePolicy withActiveMemberCache(int activityCount)
{
this.useActiveMemberCache = activityCount;

if (this.useActiveMemberCache < 1) // disabled if 0
{
// Move them all into the low activity cache
Set<Member> moved = this.activeMemberCache.keySet();

// Add them in insertion order to the queue, since the insertion order represents oldest to newest
moved.forEach(this::cacheMember);
}

this.activeMemberCache = new LinkedHashMap<>();

return this;
}

@Override
public synchronized boolean cacheMember(@Nonnull Member member)
{
int currentCount = this.counters.adjustOrPutValue(member, 1, 1);

if (this.useActiveMemberCache > 0)
{
// Check if this member is a high activity member or low activity member

// If the active member cache already tracks this member, update their timestamp
if (this.activeMemberCache.containsKey(member))
{
this.activeMemberCache.put(member, now());
return true;
}

// If the member is not tracked yet, promote them to high activity cache if they take up 10% of the queue
if (currentCount > this.useActiveMemberCache)
{
// This step has O(n) time complexity because it needs to iterate the entire queue
// Worst-case: 10 x maxMembers operations
this.queue.removeIf((node) -> member.equals(node.member));
this.counters.remove(member);
this.activeMemberCache.put(member, now());
return true;
}
}


// Otherwise use the queue, which has O(1) time complexity
this.queue.add(new MemberNode(member));

evictOldest();
trimQueue();

return true;
}

/**
* Removes the head of the queue, with a counter equal to 1.
*/
private void evictOldest()
{
Member unloadable = null;
while (this.counters.size() + this.activeMemberCache.size() > this.maxMembers)
{
Iterator<Map.Entry<Member, Integer>> activeMemberIterator = this.activeMemberCache.entrySet().iterator();
Map.Entry<Member, Integer> oldestActive = activeMemberIterator.hasNext() ? activeMemberIterator.next() : null;

MemberNode removed = this.queue.poll();
if (removed == null || oldestActive != null && oldestActive.getValue() < removed.insertionTime)
{
activeMemberIterator.remove();
unloadable = oldestActive.getKey();
if (removed != null)
this.queue.addFirst(removed);
}
else
{
if (this.counters.get(removed.member) <= 1)
{
this.counters.remove(removed.member);
unloadable = removed.member;
}
else
{
this.counters.adjustValue(removed.member, -1);
}
}

if (unloadable != null && !this.subPolicy.cacheMember(unloadable))
{
unloadable.getGuild().unloadMember(unloadable.getIdLong());
}
}
}

/**
* Trims the queue by removing all elements with a count higher than 1.
*/
private void trimQueue()
{
while (!this.queue.isEmpty())
{
MemberNode head = this.queue.peek();
if (this.counters.get(head) > 1)
{
this.counters.adjustValue(head.member, -1);
this.queue.poll();
}
else
{
break;
}
}
}

private static int now()
{
return (int) (System.currentTimeMillis() / 1000 - EPOCH_SECONDS);
}

private static class MemberNode
{
public final int insertionTime;
public final Member member;

private MemberNode(Member member)
{
this.member = member;
this.insertionTime = now();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ public boolean equals(Object o)
@Override
public int hashCode()
{
return (guild.getIdLong() + user.getId()).hashCode();
return Objects.hash(guild.getIdLong(), user.getIdLong());
}

@Override
Expand Down

0 comments on commit c75c04e

Please sign in to comment.