|
46 | 46 | import com.dbaagent.repository.SlackChannelBindingRepository; |
47 | 47 | import com.dbaagent.repository.SlackDigestLogRepository; |
48 | 48 | import com.dbaagent.repository.TableStatsHistoryRepository; |
| 49 | +import com.dbaagent.service.digest.DigestCronMatcher; |
49 | 50 | import com.dbaagent.service.digest.DigestInsightAssemblerService; |
50 | 51 | import com.slack.api.Slack; |
51 | 52 | import com.slack.api.methods.MethodsClient; |
|
60 | 61 |
|
61 | 62 | import java.io.IOException; |
62 | 63 | import java.time.LocalDateTime; |
| 64 | +import java.time.ZoneId; |
| 65 | +import java.time.Instant; |
| 66 | +import java.time.Duration; |
63 | 67 | import java.time.format.DateTimeFormatter; |
64 | 68 | import java.util.ArrayList; |
65 | 69 | import java.util.Comparator; |
@@ -89,6 +93,14 @@ public class SlackDailyDigestService { |
89 | 93 | @Value("${slack.digest.admins-only:true}") |
90 | 94 | private boolean digestAdminsOnly; |
91 | 95 |
|
| 96 | + /** |
| 97 | + * Global / legacy digest schedule. Also the default when a preference leaves |
| 98 | + * {@code cronExpression} blank. Interpreted in UTC for legacy; per-user prefs |
| 99 | + * use their own timezone. |
| 100 | + */ |
| 101 | + @Value("${slack.daily-digest.cron:0 0 9 * * *}") |
| 102 | + private String globalDigestCron; |
| 103 | + |
92 | 104 | private final SlackRuntimeSettingsService slackRuntimeSettingsService; |
93 | 105 | private final SlackChannelBindingRepository channelBindingRepository; |
94 | 106 | private final CredentialService credentialService; |
@@ -828,9 +840,149 @@ private LocalDateTime getLastDigestTime(String connectionId) { |
828 | 840 | .orElse(LocalDateTime.now().minusHours(24)); |
829 | 841 | } |
830 | 842 |
|
| 843 | + private static final Duration DIGEST_TICK_LOOKBACK = Duration.ofMinutes(1); |
| 844 | + |
| 845 | + /** |
| 846 | + * Minute-tick entry point used by {@code SlackDailyDigestTaskConfig}. |
| 847 | + * |
| 848 | + * <p>When no enabled preferences exist, runs the legacy channel broadcast |
| 849 | + * only if the global {@code slack.daily-digest.cron} is due (UTC). |
| 850 | + * When preferences exist, delivers only to preferences whose cron matches |
| 851 | + * in that user's timezone and that have not already been logged for this |
| 852 | + * fire window. |
| 853 | + */ |
| 854 | + public void processDigestTick() { |
| 855 | + processDigestTick(Instant.now()); |
| 856 | + } |
| 857 | + |
| 858 | + /** |
| 859 | + * Testable overload of {@link #processDigestTick()}. |
| 860 | + */ |
| 861 | + public void processDigestTick(Instant now) { |
| 862 | + if (now == null) { |
| 863 | + now = Instant.now(); |
| 864 | + } |
| 865 | + |
| 866 | + long enabledCount = userDigestPreferenceRepository.countByEnabledTrue(); |
| 867 | + if (enabledCount == 0) { |
| 868 | + if (DigestCronMatcher.isDue(globalDigestCron, ZoneId.of("UTC"), now, DIGEST_TICK_LOOKBACK)) { |
| 869 | + log.info("Digest tick: no enabled preferences; running legacy broadcast (global cron due)"); |
| 870 | + runLegacyBroadcastForAllConnections(); |
| 871 | + } else { |
| 872 | + log.debug("Digest tick: no enabled preferences; global cron not due"); |
| 873 | + } |
| 874 | + return; |
| 875 | + } |
| 876 | + |
| 877 | + String globalCron = (globalDigestCron == null || globalDigestCron.isBlank()) |
| 878 | + ? "0 0 9 * * *" |
| 879 | + : globalDigestCron; |
| 880 | + |
| 881 | + List<UserDigestPreference> enabledPrefs = userDigestPreferenceRepository |
| 882 | + .findByEnabledTrueAndDeliveryMethod(DigestDeliveryMethod.SLACK_DM); |
| 883 | + |
| 884 | + int dueCount = 0; |
| 885 | + int delivered = 0; |
| 886 | + for (UserDigestPreference pref : enabledPrefs) { |
| 887 | + String cron = pref.getEffectiveCronExpression(globalCron); |
| 888 | + ZoneId zone = DigestCronMatcher.resolveZone(pref.getTimezone()); |
| 889 | + var window = DigestCronMatcher.dueWindowStart(cron, zone, now, DIGEST_TICK_LOOKBACK); |
| 890 | + if (window.isEmpty()) { |
| 891 | + continue; |
| 892 | + } |
| 893 | + dueCount++; |
| 894 | + Instant fireInstant = window.get(); |
| 895 | + List<String> connectionIds = resolvePreferenceConnections(pref); |
| 896 | + for (String connectionId : connectionIds) { |
| 897 | + if (alreadyDeliveredForWindow(pref, connectionId, fireInstant)) { |
| 898 | + log.debug("Skipping digest for {} / {} — already delivered this window", |
| 899 | + pref.getUsername(), connectionId); |
| 900 | + continue; |
| 901 | + } |
| 902 | + try { |
| 903 | + boolean slackEnabled = isSlackDeliveryEnabled(); |
| 904 | + String connName = connectionName(connectionId); |
| 905 | + LocalDateTime since = getLastDigestTime(connectionId); |
| 906 | + PersonalizedDigestResult result = sendPersonalizedDigestToUser( |
| 907 | + connectionId, connName, pref, since, slackEnabled); |
| 908 | + if (result.generated() || result.sent()) { |
| 909 | + delivered++; |
| 910 | + } |
| 911 | + } catch (Exception e) { |
| 912 | + log.error("Failed due-preference digest for user {} connection {}: {}", |
| 913 | + pref.getUsername(), connectionId, e.getMessage(), e); |
| 914 | + } |
| 915 | + } |
| 916 | + } |
| 917 | + |
| 918 | + log.info("Digest tick (per-user): {} due preference(s), {} delivery attempt(s)", |
| 919 | + dueCount, delivered); |
| 920 | + |
| 921 | + // Connections with no recipients still get legacy broadcast when the global cron fires. |
| 922 | + if (DigestCronMatcher.isDue(globalCron, ZoneId.of("UTC"), now, DIGEST_TICK_LOOKBACK)) { |
| 923 | + for (String connectionId : digestConnectionIds()) { |
| 924 | + if (userDigestPreferenceRepository.findEnabledForConnection(connectionId).isEmpty()) { |
| 925 | + try { |
| 926 | + sendLegacyDigest(connectionId); |
| 927 | + } catch (Exception e) { |
| 928 | + log.error("Legacy fallback digest failed for {}: {}", connectionId, e.getMessage(), e); |
| 929 | + } |
| 930 | + } |
| 931 | + } |
| 932 | + } |
| 933 | + } |
| 934 | + |
| 935 | + private void runLegacyBroadcastForAllConnections() { |
| 936 | + List<String> connectionIds = digestConnectionIds(); |
| 937 | + if (connectionIds.isEmpty()) { |
| 938 | + log.info("No connections available — skipping legacy digest"); |
| 939 | + return; |
| 940 | + } |
| 941 | + for (String connectionId : connectionIds) { |
| 942 | + try { |
| 943 | + sendLegacyDigest(connectionId); |
| 944 | + } catch (Exception e) { |
| 945 | + log.error("Failed legacy digest for connection {}: {}", connectionId, e.getMessage(), e); |
| 946 | + } |
| 947 | + } |
| 948 | + } |
| 949 | + |
| 950 | + private List<String> resolvePreferenceConnections(UserDigestPreference pref) { |
| 951 | + if (pref.getConnectionId() != null && !pref.getConnectionId().isBlank()) { |
| 952 | + return List.of(pref.getConnectionId()); |
| 953 | + } |
| 954 | + return digestConnectionIds(); |
| 955 | + } |
| 956 | + |
| 957 | + /** |
| 958 | + * True when SlackDigestLog already has a row for this preference/connection |
| 959 | + * at or after the cron fire time (idempotent minute-tick). |
| 960 | + */ |
| 961 | + private boolean alreadyDeliveredForWindow( |
| 962 | + UserDigestPreference pref, |
| 963 | + String connectionId, |
| 964 | + Instant fireInstant) { |
| 965 | + // sentAt is LocalDateTime.now() (JVM default zone) — compare in that zone. |
| 966 | + LocalDateTime since = LocalDateTime.ofInstant(fireInstant, ZoneId.systemDefault()); |
| 967 | + if (pref.getId() != null) { |
| 968 | + if (digestLogRepository.existsByPreferenceIdAndConnectionIdAndSentAtGreaterThanEqual( |
| 969 | + pref.getId(), connectionId, since)) { |
| 970 | + return true; |
| 971 | + } |
| 972 | + } |
| 973 | + if (pref.getUsername() != null && !pref.getUsername().isBlank()) { |
| 974 | + return digestLogRepository.existsByConnectionIdAndRecipientUsernameAndSentAtGreaterThanEqual( |
| 975 | + connectionId, pref.getUsername(), since); |
| 976 | + } |
| 977 | + return false; |
| 978 | + } |
| 979 | + |
831 | 980 | /** |
832 | 981 | * Entry point for the hybrid digest run: per-user when preferences exist, |
833 | 982 | * legacy broadcast otherwise. |
| 983 | + * |
| 984 | + * <p>Used by manual/admin triggers. The scheduled tick uses |
| 985 | + * {@link #processDigestTick()} so per-user crons are honored. |
834 | 986 | */ |
835 | 987 | public void sendDailyDigestHybrid() { |
836 | 988 | List<String> connectionIds = digestConnectionIds(); |
|
0 commit comments