Skip to content

Commit 9bf4594

Browse files
committed
add garbage collection for incomplete subscriptions
1 parent 143bb99 commit 9bf4594

7 files changed

Lines changed: 160 additions & 3 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.trynoice.api.subscription.entities;
2+
3+
import lombok.val;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.boot.test.context.SpringBootTest;
7+
import org.springframework.transaction.annotation.Transactional;
8+
9+
import javax.persistence.EntityManager;
10+
import java.time.OffsetDateTime;
11+
import java.util.stream.Collectors;
12+
import java.util.stream.IntStream;
13+
14+
import static org.junit.jupiter.api.Assertions.assertEquals;
15+
import static org.junit.jupiter.api.Assertions.assertTrue;
16+
17+
@SpringBootTest
18+
@Transactional
19+
public class SubscriptionRepositoryTest {
20+
21+
@Autowired
22+
private EntityManager entityManager;
23+
24+
@Autowired
25+
private SubscriptionRepository subscriptionRepository;
26+
27+
@Test
28+
void deleteAllIncompleteCreatedBefore() {
29+
val plan = entityManager.merge(
30+
SubscriptionPlan.builder()
31+
.provider(SubscriptionPlan.Provider.STRIPE)
32+
.providedId("test-provided-id")
33+
.billingPeriodMonths((short) 1)
34+
.priceInIndianPaise(100)
35+
.trialPeriodDays((short) 0)
36+
.build());
37+
38+
val customer = entityManager.merge(
39+
Customer.builder()
40+
.userId(1)
41+
.build());
42+
43+
val now = OffsetDateTime.now();
44+
val stale = now.minusDays(1);
45+
46+
val completed = IntStream.range(0, 5)
47+
.mapToObj(i -> subscriptionRepository.save(
48+
Subscription.builder()
49+
.createdAt(i % 2 == 0 ? now : stale)
50+
.customer(customer)
51+
.plan(plan)
52+
.providedId("test-provided-id-" + i)
53+
.startAt(OffsetDateTime.now())
54+
.endAt(OffsetDateTime.now().minusHours(1))
55+
.isAutoRenewing(i % 2 == 0)
56+
.build()))
57+
.collect(Collectors.toUnmodifiableList());
58+
59+
val incomplete = IntStream.range(5, 10)
60+
.mapToObj(i -> subscriptionRepository.save(
61+
Subscription.builder()
62+
.createdAt(i % 2 == 0 ? stale : now)
63+
.customer(customer)
64+
.plan(plan)
65+
.providedId(null)
66+
.startAt(null)
67+
.endAt(null)
68+
.isAutoRenewing(true)
69+
.build()))
70+
.collect(Collectors.toUnmodifiableList());
71+
72+
subscriptionRepository.deleteAllIncompleteCreatedBefore(stale.plusMinutes(1));
73+
74+
completed.forEach(s -> assertTrue(subscriptionRepository.existsById(s.getId())));
75+
incomplete.forEach(s -> assertEquals(s.getCreatedAt().isEqual(stale), !subscriptionRepository.existsById(s.getId())));
76+
}
77+
}

src/main/java/com/trynoice/api/subscription/SubscriptionConfiguration.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,7 @@ class SubscriptionConfiguration {
3838

3939
@NotNull
4040
private final Duration cacheTtl;
41+
42+
@NotNull
43+
private final Duration removeIncompleteSubscriptionsAfter;
4144
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.trynoice.api.subscription;
2+
3+
import lombok.NonNull;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.scheduling.annotation.Scheduled;
7+
import org.springframework.stereotype.Component;
8+
9+
/**
10+
* Collection of scheduled tasks for {@link SubscriptionService}.
11+
*/
12+
@Component
13+
@Slf4j
14+
class SubscriptionScheduledTasks {
15+
16+
private final SubscriptionService subscriptionService;
17+
18+
@Autowired
19+
SubscriptionScheduledTasks(@NonNull SubscriptionService subscriptionService) {
20+
this.subscriptionService = subscriptionService;
21+
}
22+
23+
@Scheduled(cron = "${app.subscriptions.garbage-collection-schedule}")
24+
void garbageCollection() {
25+
log.info("performing garbage collection");
26+
subscriptionService.performGarbageCollection();
27+
}
28+
29+
@Scheduled(fixedRateString = "${app.subscriptions.foreign-exchange-rate-refresh-interval-millis}")
30+
void updateForeignExchangeRates() {
31+
log.info("updating foreign exchange rates");
32+
subscriptionService.updateForeignExchangeRates();
33+
}
34+
}

src/main/java/com/trynoice/api/subscription/SubscriptionService.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
import org.springframework.data.domain.PageRequest;
4545
import org.springframework.data.domain.Sort;
4646
import org.springframework.integration.annotation.ServiceActivator;
47-
import org.springframework.scheduling.annotation.Scheduled;
4847
import org.springframework.stereotype.Service;
4948
import org.springframework.transaction.annotation.Transactional;
5049
import org.springframework.transaction.event.TransactionPhase;
@@ -805,11 +804,16 @@ public void onUserDeleted(@NonNull AccountServiceContract.UserDeletedEvent event
805804
});
806805
}
807806

808-
@Scheduled(fixedRateString = "${app.subscriptions.foreign-exchange-rate-refresh-interval-millis}")
809807
void updateForeignExchangeRates() {
810808
exchangeRatesProvider.maybeUpdateRates();
811809
}
812810

811+
@Transactional(rollbackFor = Throwable.class)
812+
public void performGarbageCollection() {
813+
val deleteBefore = OffsetDateTime.now().minus(subscriptionConfig.getRemoveIncompleteSubscriptionsAfter());
814+
subscriptionRepository.deleteAllIncompleteCreatedBefore(deleteBefore);
815+
}
816+
813817
private void evictIsSubscribedCache(long userId) {
814818
cache.evictIfPresent(String.format("isSubscribed:%d", userId));
815819
}

src/main/java/com/trynoice/api/subscription/entities/SubscriptionRepository.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import lombok.NonNull;
44
import org.springframework.data.domain.Page;
55
import org.springframework.data.domain.Pageable;
6+
import org.springframework.data.jpa.repository.Modifying;
67
import org.springframework.data.jpa.repository.Query;
78
import org.springframework.data.repository.PagingAndSortingRepository;
89
import org.springframework.stereotype.Repository;
910
import org.springframework.transaction.annotation.Transactional;
1011

12+
import java.time.OffsetDateTime;
1113
import java.util.List;
1214
import java.util.Optional;
1315

@@ -64,4 +66,17 @@ public interface SubscriptionRepository extends PagingAndSortingRepository<Subsc
6466
@Transactional(readOnly = true)
6567
@Query("select e from Subscription e where e.customer.userId = ?1 and e.startAt < now() and e.endAt > now()")
6668
Optional<Subscription> findActiveByCustomerUserId(@NonNull Long customerUserId);
69+
70+
/**
71+
* Deletes all entities from the database that have {@literal null} {@link
72+
* Subscription#getProvidedId()}, {@link Subscription#getStartAt()} and {@link
73+
* Subscription#getEndAt()}, and were created before the given {@code createdBefore} timestamp.
74+
*
75+
* @param createdBefore a not {@literal null} timestamp to remove incomplete entities that were
76+
* created before this instant.
77+
*/
78+
@Modifying(flushAutomatically = true, clearAutomatically = true)
79+
@Transactional
80+
@Query("delete from Subscription e where e.providedId is null and e.startAt is null and e.endAt is null and e.createdAt < ?1")
81+
void deleteAllIncompleteCreatedBefore(@NonNull OffsetDateTime createdBefore);
6782
}

src/main/resources/application.properties

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ app.auth.sign-in-reattempt-max-delay=6h
7272
# and access tokens using cookies.
7373
app.auth.cookie-domain=
7474

75-
# A Spring cron-like expression to specify a garbage collection schedule for the account service.
75+
# A Spring cron-like expression to specify a garbage collection schedule for the
76+
# account service.
7677
app.auth.garbage-collection-schedule=0 0 0 * * *
7778

7879
# During garbage collection, permanently delete accounts that were deactivated
@@ -121,6 +122,15 @@ app.subscriptions.cache-ttl=5m
121122
# Refresh interval for foreign exchange rates (in milliseconds).
122123
app.subscriptions.foreign-exchange-rate-refresh-interval-millis=3600000
123124

125+
# A Spring cron-like expression to specify a garbage collection schedule for
126+
# the subscription service.
127+
app.subscriptions.garbage-collection-schedule=0 0 0 * * *
128+
129+
# During garbage collection, permanently delete subscription entities that were
130+
# initiated before this duration, but their payment flow was never completed, so
131+
# they never became active.
132+
app.subscriptions.remove-incomplete-subscriptions-after=7d
133+
124134
# S3 bucket prefix where the sound library is hosted (excluding the library
125135
# version). e.g. if the current library version is hosted at
126136
# `s3://bucket-name/v0.1.0`, then this value should be `s3://bucket-name`.

src/test/java/com/trynoice/api/subscription/SubscriptionServiceTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import org.springframework.data.domain.Sort;
4242

4343
import java.io.IOException;
44+
import java.time.Duration;
4445
import java.time.OffsetDateTime;
4546
import java.util.HashMap;
4647
import java.util.List;
@@ -56,6 +57,7 @@
5657
import static org.junit.jupiter.api.Assertions.assertThrows;
5758
import static org.junit.jupiter.params.provider.Arguments.arguments;
5859
import static org.mockito.ArgumentMatchers.any;
60+
import static org.mockito.ArgumentMatchers.argThat;
5961
import static org.mockito.ArgumentMatchers.eq;
6062
import static org.mockito.Mockito.lenient;
6163
import static org.mockito.Mockito.mock;
@@ -481,6 +483,18 @@ void isUserSubscribed() {
481483
assertEquals(isSubscribed, service.isUserSubscribed(userId)));
482484
}
483485

486+
@Test
487+
void performGarbageCollection() {
488+
when(subscriptionConfiguration.getRemoveIncompleteSubscriptionsAfter())
489+
.thenReturn(Duration.ofHours(1L));
490+
491+
service.performGarbageCollection();
492+
493+
val now = OffsetDateTime.now();
494+
verify(subscriptionRepository, times(1)).deleteAllIncompleteCreatedBefore(
495+
argThat(t -> t.isBefore(now.minusMinutes(59)) && t.isAfter(now.minusMinutes(61))));
496+
}
497+
484498
@NonNull
485499
private static SubscriptionPlan buildSubscriptionPlan(@NonNull SubscriptionPlan.Provider provider, @NonNull String providedId) {
486500
return SubscriptionPlan.builder()

0 commit comments

Comments
 (0)