1818import java .time .Clock ;
1919import java .time .Duration ;
2020import java .time .Instant ;
21- import java .util .ArrayList ;
2221import java .util .Currency ;
23- import java .util .Iterator ;
2422import java .util .List ;
2523import java .util .Map ;
2624import java .util .Optional ;
27- import java .util .function .Function ;
2825import java .util .stream .Collectors ;
2926import javax .annotation .Nullable ;
3027import org .apache .arrow .util .VisibleForTesting ;
3835import org .signal .registration .sender .infobip .classic .InfobipSmsSender ;
3936import org .slf4j .Logger ;
4037import org .slf4j .LoggerFactory ;
38+ import reactor .core .publisher .Flux ;
39+ import reactor .core .publisher .Mono ;
40+ import reactor .core .scheduler .Schedulers ;
41+ import reactor .util .retry .Retry ;
4142
4243@ Singleton
4344class InfobipSmsAttemptAnalyzer {
@@ -50,10 +51,9 @@ class InfobipSmsAttemptAnalyzer {
5051 private final Currency defaultPriceCurrency ;
5152 private final MeterRegistry meterRegistry ;
5253 private final int pageSize ;
53-
5454 private static final Logger logger = LoggerFactory .getLogger (InfobipSmsAttemptAnalyzer .class );
5555 private static final int MIN_MCC_MNC_LENGTH = 5 ;
56- private static final int MAX_ATTEMPTS = 10 ;
56+ private static final int MAX_RETRIES = 10 ;
5757 private static final Duration MIN_BACKOFF = Duration .ofMillis (500 );
5858 private static final Duration MAX_BACKOFF = Duration .ofSeconds (60 );
5959 private static final Duration PRICING_DEADLINE = Duration .ofHours (36 );
@@ -68,6 +68,7 @@ protected InfobipSmsAttemptAnalyzer(
6868 @ Value ("${analytics.infobip.sms.default-price-currency:USD}" ) final String defaultPriceCurrency ,
6969 final MeterRegistry meterRegistry ,
7070 @ Value ("${analytics.infobip.sms.page-size}" ) final int pageSize ) {
71+
7172 this .repository = repository ;
7273 this .attemptAnalyzedEventPublisher = attemptAnalyzedEventPublisher ;
7374 this .clock = clock ;
@@ -83,104 +84,62 @@ protected void analyzeAttempts() {
8384 // Infobip's message logs API (https://www.infobip.com/docs/api/channels/sms/sms-messaging/logs-and-status-reports/get-outbound-sms-message-logs)
8485 // has a ratelimit that prevents us from querying one by one for each attempt pending analysis.
8586 // Instead, we fetch fewer, larger pages and reconcile them against what we have stored locally.
86- final List <AttemptPendingAnalysis > buffer = new ArrayList <>(pageSize );
87- final Iterator <AttemptPendingAnalysis > attemptsPendingAnalysis =
88- repository .getBySender (InfobipSmsSender .SENDER_NAME ).iterator ();
89-
90- while (attemptsPendingAnalysis .hasNext ()) {
91- buffer .add (attemptsPendingAnalysis .next ());
92-
93- if (buffer .size () == pageSize ) {
94- try {
95- analyzeAttempts (buffer );
96- } catch (final ApiException e ) {
97- logger .warn ("Failed to analyze attempts" , e );
98- } finally {
99- buffer .clear ();
100- }
101- }
102- }
103-
104- if (!buffer .isEmpty ()) {
105- try {
106- analyzeAttempts (buffer );
107- } catch (ApiException e ) {
108- logger .warn ("Failed to analyze attempts" , e );
109- }
110- }
111- }
112-
113- private void analyzeAttempts (final List <AttemptPendingAnalysis > attemptsPendingAnalysis ) throws ApiException {
114- final Map <String , SmsLog > smsLogsByRemoteId =
115- fetchSmsLogsWithBackoff (attemptsPendingAnalysis .stream ().map (AttemptPendingAnalysis ::getRemoteId ).toList ());
116-
117- for (final AttemptPendingAnalysis attemptPendingAnalysis : attemptsPendingAnalysis ) {
118- final AttemptAnalysis attemptAnalysis ;
119-
120- if (smsLogsByRemoteId .containsKey (attemptPendingAnalysis .getRemoteId ())) {
121- final SmsLog smsLog = smsLogsByRemoteId .get (attemptPendingAnalysis .getRemoteId ());
122- final MccMnc mccMnc = MccMnc .fromString (smsLog .getMccMnc ());
123- final Optional <Money > maybeEstimatedPriceByMccMnc = estimatePrice (mccMnc .toString ());
124-
125- attemptAnalysis = new AttemptAnalysis (
126- extractPrice (smsLog ),
127- maybeEstimatedPriceByMccMnc .or (() -> estimatePrice (attemptPendingAnalysis .getRegion ())),
128- Optional .ofNullable (mccMnc .mcc ),
129- Optional .ofNullable (mccMnc .mnc ));
130- } else {
131- attemptAnalysis = new AttemptAnalysis (
132- Optional .empty (),
133- estimatePrice (attemptPendingAnalysis .getRegion ()),
134- Optional .empty (),
135- Optional .empty ());
136- }
137-
138- final Instant attemptTimestamp = Instant .ofEpochMilli (attemptPendingAnalysis .getTimestampEpochMillis ());
139- final boolean pricingDeadlineExpired = clock .instant ().isAfter (attemptTimestamp .plus (PRICING_DEADLINE ));
140-
141- if (attemptAnalysis .price ().isPresent () || pricingDeadlineExpired ) {
142- meterRegistry .counter (MetricsUtil .name (getClass (), "attemptAnalyzed" ),
143- "hasPrice" , String .valueOf (attemptAnalysis .price ().isPresent ())).increment ();
144- repository .remove (attemptPendingAnalysis );
145- attemptAnalyzedEventPublisher .publishEvent (new AttemptAnalyzedEvent (attemptPendingAnalysis , attemptAnalysis ));
146- }
147- }
87+ Flux .fromStream (repository .getBySender (InfobipSmsSender .SENDER_NAME ))
88+ .buffer (pageSize )
89+ .flatMap (attemptsPendingAnalysis -> fetchSmsLogsWithBackoff (attemptsPendingAnalysis .stream ().map (AttemptPendingAnalysis ::getRemoteId ).toList ())
90+ .flatMapMany (smsLogsByRemoteId -> Flux .fromIterable (attemptsPendingAnalysis )
91+ .map (attemptPendingAnalysis -> {
92+ final AttemptAnalysis attemptAnalysis ;
93+
94+ if (smsLogsByRemoteId .containsKey (attemptPendingAnalysis .getRemoteId ())) {
95+ final SmsLog smsLog = smsLogsByRemoteId .get (attemptPendingAnalysis .getRemoteId ());
96+ final MccMnc mccMnc = MccMnc .fromString (smsLog .getMccMnc ());
97+ final Optional <Money > maybeEstimatedPriceByMccMnc = estimatePrice (mccMnc .toString ());
98+
99+ attemptAnalysis = new AttemptAnalysis (
100+ extractPrice (smsLog ),
101+ maybeEstimatedPriceByMccMnc .or (() -> estimatePrice (attemptPendingAnalysis .getRegion ())),
102+ Optional .ofNullable (mccMnc .mcc ),
103+ Optional .ofNullable (mccMnc .mnc ));
104+ } else {
105+ attemptAnalysis = new AttemptAnalysis (
106+ Optional .empty (),
107+ estimatePrice (attemptPendingAnalysis .getRegion ()),
108+ Optional .empty (),
109+ Optional .empty ());
110+ }
111+
112+ return new AttemptAnalyzedEvent (attemptPendingAnalysis , attemptAnalysis );
113+ })), 1 ) // Request at most one page of results from Infobip at a time
114+ .filter (attemptAnalyzedEvent -> {
115+ final Instant attemptTimestamp = Instant .ofEpochMilli (attemptAnalyzedEvent .attemptPendingAnalysis ().getTimestampEpochMillis ());
116+ final boolean pricingDeadlineExpired = clock .instant ().isAfter (attemptTimestamp .plus (PRICING_DEADLINE ));
117+
118+ return attemptAnalyzedEvent .attemptAnalysis ().price ().isPresent () || pricingDeadlineExpired ;
119+ })
120+ .doOnNext (attemptAnalyzedEvent -> {
121+ meterRegistry .counter (MetricsUtil .name (getClass (), "attemptAnalyzed" ),
122+ "hasPrice" , String .valueOf (attemptAnalyzedEvent .attemptAnalysis ().price ().isPresent ())).increment ();
123+ repository .remove (attemptAnalyzedEvent .attemptPendingAnalysis ());
124+ attemptAnalyzedEventPublisher .publishEvent (attemptAnalyzedEvent );
125+ })
126+ .then ()
127+ .block ();
148128 }
149129
150- private Map <String , SmsLog > fetchSmsLogsWithBackoff (final List <String > remoteIds ) throws ApiException {
151- Duration backoffDelay = MIN_BACKOFF ;
152- ApiException lastException = null ;
153-
154- for (int attempt = 0 ; attempt < MAX_ATTEMPTS ; attempt ++) {
155- try {
156- final List <SmsLog > smsLogs = getSmsLogs (remoteIds );
157- return smsLogs .stream ().collect (Collectors .toMap (SmsLog ::getMessageId , Function .identity ()));
158- } catch (final ApiException e ) {
159- lastException = e ;
160-
161- if (e .responseStatusCode () == HTTP_TOO_MANY_REQUESTS_CODE ) {
162- try {
163- Thread .sleep (backoffDelay );
164- } catch (final InterruptedException ignored ) {
165- }
166-
167- backoffDelay = backoffDelay .multipliedBy (2 );
168-
169- if (backoffDelay .compareTo (MAX_BACKOFF ) > 0 ) {
170- backoffDelay = MAX_BACKOFF ;
171- }
172- } else {
173- throw e ;
174- }
175- }
176- }
177-
178- throw lastException ;
130+ private Mono <Map <String , SmsLog >> fetchSmsLogsWithBackoff (final List <String > remoteIds ) {
131+ return Mono .fromCallable (() -> getSmsLogs (remoteIds ))
132+ .retryWhen (Retry .backoff (MAX_RETRIES , MIN_BACKOFF )
133+ .filter (throwable -> throwable instanceof ApiException apiException &&
134+ apiException .responseStatusCode () == HTTP_TOO_MANY_REQUESTS_CODE )
135+ .maxBackoff (MAX_BACKOFF ))
136+ .map (smsLogs -> smsLogs .stream ().collect (Collectors .toMap (SmsLog ::getMessageId , smsLog -> smsLog )));
179137 }
180138
181139 private List <SmsLog > getSmsLogs (final List <String > remoteIds ) throws ApiException {
182140 try {
183- final List <SmsLog > smsLogs = infobipSmsApiClient .getOutboundSmsMessageLogs ().messageId (remoteIds ).execute ().getResults ();
141+ final List <SmsLog > smsLogs =
142+ infobipSmsApiClient .getOutboundSmsMessageLogs ().messageId (remoteIds ).execute ().getResults ();
184143 meterRegistry .counter (MetricsUtil .name (getClass (), "getSmsLogs" )).increment (smsLogs .size ());
185144 return smsLogs ;
186145 } catch (final ApiException e ) {
@@ -196,10 +155,10 @@ private List<SmsLog> getSmsLogs(final List<String> remoteIds) throws ApiExceptio
196155
197156 private Optional <Money > extractPrice (final SmsLog smsLog ) {
198157 final boolean hasPriceData = smsLog .getPrice () != null && smsLog .getPrice ().getPricePerMessage () != null
199- && smsLog .getPrice ().getCurrency () != null ;
158+ && smsLog .getPrice ().getCurrency () != null ;
200159 return hasPriceData
201- ? Optional .of (new Money (BigDecimal .valueOf (smsLog .getPrice ().getPricePerMessage ()), Currency .getInstance (smsLog .getPrice ().getCurrency ())))
202- : Optional .empty ();
160+ ? Optional .of (new Money (BigDecimal .valueOf (smsLog .getPrice ().getPricePerMessage ()), Currency .getInstance (smsLog .getPrice ().getCurrency ())))
161+ : Optional .empty ();
203162 }
204163
205164 private Optional <Money > estimatePrice (final String key ) {
@@ -225,7 +184,7 @@ static MccMnc fromString(@Nullable final String mccMnc) {
225184 // Mobile country code is always 3 digits: https://en.wikipedia.org/wiki/Mobile_country_code
226185 return new MccMnc (mccMnc .substring (0 , 3 ), mccMnc .substring (3 ));
227186 }
228-
187+
229188 public String toString () {
230189 return mcc + mnc ;
231190 }
0 commit comments