This is an automated email from the ASF dual-hosted git repository.
dspavlov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite-teamcity-bot.git
The following commit(s) were added to refs/heads/master by this push:
new af732a11 IGNITE-28655 Add admin maintenance actions (#224)
af732a11 is described below
commit af732a11bebb81f5420f56014e59fd4220c63601
Author: ignitetcbot <[email protected]>
AuthorDate: Sun May 10 14:05:19 2026 +0300
IGNITE-28655 Add admin maintenance actions (#224)
Codex co-authored-by: Dmitriy Pavlov <[email protected]>
---
conf/branches.json | 2 +
.../ci/tcbot/conf/LocalFilesBasedConfig.java | 9 +
.../tcbot/visa/TcBotTriggerAndSignOffService.java | 82 +++++--
.../ci/web/rest/monitoring/CacheMetricsUi.java | 4 +-
.../ci/web/rest/monitoring/MonitoringService.java | 196 ++++++++++++++--
.../ignite/ci/web/rest/visa/TcBotVisaService.java | 8 +-
.../src/main/webapp/js/common-1.7.js | 37 ++-
ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js | 32 ++-
.../src/main/webapp/monitoring.html | 249 +++++++++++++++++++++
ignite-tc-helper-web/src/main/webapp/prs.html | 4 +-
.../persistence/scheduler/SchedulerModule.java | 1 +
.../ignite/tcbot/engine/conf/ITcBotConfig.java | 11 +
.../ignite/tcbot/engine/conf/TcBotJsonConfig.java | 10 +
.../githubignited/GitHubConnIgnitedImpl.java | 9 +
.../org/apache/ignite/ci/github/GitHubUser.java | 11 +-
.../scheduler/DirectExecNoWaitScheduler.java | 12 +
.../tcbot/persistence/scheduler/IScheduler.java | 36 +++
.../scheduler/MaintenanceActionInfo.java | 50 +++++
.../scheduler/MaintenanceActionRegistry.java | 96 ++++++++
.../tcbot/persistence/scheduler/NamedTask.java | 60 ++++-
...NoWaitScheduler.java => ScheduledTaskInfo.java} | 38 ++--
.../persistence/scheduler/TcBotScheduler.java | 27 +++
22 files changed, 928 insertions(+), 56 deletions(-)
diff --git a/conf/branches.json b/conf/branches.json
index e7a841c6..efbc5da4 100644
--- a/conf/branches.json
+++ b/conf/branches.json
@@ -4,6 +4,8 @@
// TeamCity group ids whose members can administer sensitive bot settings.
// Apache Ignite test admins are exposed by TeamCity REST as
key="IGNITE_COMMITER".
"botAdminGroups": ["IGNITE_COMMITER"],
+ // Ignite cache names admins may reset from monitoring UI.
+ "resettableCaches": ["testFixMatches", "testFixSourceUpdates"],
"confidence": 0.995,
"cleanerConfig": {
"numOfItemsToDel": 100000,
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
index a7af0d21..2764cbc1 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
@@ -151,6 +151,15 @@ public class LocalFilesBasedConfig implements ITcBotConfig
{
return userAdmins == null ? Collections.emptyList() : userAdmins;
}
+ /** {@inheritDoc} */
+ @Override public Collection<String> resettableCaches() {
+ Collection<String> resettableCaches = getConfig().resettableCaches();
+
+ return resettableCaches == null || resettableCaches.isEmpty()
+ ? ITcBotConfig.DEFAULT_RESETTABLE_CACHES
+ : resettableCaches;
+ }
+
@Override
public ITrackedBranchesConfig getTrackedBranches() {
return getConfig();
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
index def5f379..dc760177 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/visa/TcBotTriggerAndSignOffService.java
@@ -766,7 +766,9 @@ public class TcBotTriggerAndSignOffService {
visaStatus.prAuthor = author.login();
visaStatus.prAuthorAvatarUrl = author.avatarUrl();
- if (!Strings.isNullOrEmpty(author.login()))
+ if (!Strings.isNullOrEmpty(author.htmlUrl()))
+ visaStatus.prAuthorUrl = author.htmlUrl();
+ else if (!Strings.isNullOrEmpty(author.login()))
visaStatus.prAuthorUrl = "https://github.com/" +
author.login();
}
}
@@ -1262,7 +1264,8 @@ public class TcBotTriggerAndSignOffService {
AtomicInteger activeBuildLookupCnt = new AtomicInteger();
long stepStart = System.nanoTime();
- processMonitor.status(processId, "Building the contribution list from
cached bot data.");
+ processMonitor.status(processId, "Resolving GitHub, JIRA, and TeamCity
services for server " +
+ srvCodeOrAlias + ".");
IJiraIgnited jiraIntegration = jiraIgnProv.server(srvCodeOrAlias);
@@ -1272,14 +1275,17 @@ public class TcBotTriggerAndSignOffService {
serviceResolveNanos = System.nanoTime() - stepStart;
stepStart = System.nanoTime();
- processMonitor.status(processId, "Loading pull request details from
GitHub.");
+ processMonitor.status(processId, "Loading pull request details from
the GitHub cache.");
List<PullRequest> prs = gitHubConnIgnited.getPullRequests();
prsLoadNanos = System.nanoTime() - stepStart;
+ int prsCnt = prs == null ? 0 : prs.size();
+ processMonitor.status(processId, "Loaded " + prsCnt + " open pull
requests from the GitHub cache.");
stepStart = System.nanoTime();
processMonitor.status(processId, "Loading JIRA tickets for
contribution matching.");
Set<Ticket> tickets = jiraIntegration.getTickets();
ticketsLoadNanos = System.nanoTime() - stepStart;
+ processMonitor.status(processId, "Loaded " + tickets.size() + " JIRA
tickets for contribution matching.");
Map<String, Ticket> ticketsByKey = tickets.stream()
.filter(ticket -> ticket.key != null)
@@ -1289,13 +1295,15 @@ public class TcBotTriggerAndSignOffService {
IGitHubConfig ghCfg = gitHubConnIgnited.config();
stepStart = System.nanoTime();
+ processMonitor.status(processId, "Resolving default TeamCity build
type for contribution links.");
String defBtForTcServ = findDefaultBuildType(srvCodeOrAlias);
defaultBuildTypeNanos = System.nanoTime() - stepStart;
List<ContributionToCheck> contribsList = new ArrayList<>();
stepStart = System.nanoTime();
- processMonitor.status(processId, "Matching pull requests, JIRA
tickets, and TeamCity branches.");
+ processMonitor.status(processId, "Matching " + prsCnt + " pull
requests with JIRA tickets and TeamCity " +
+ "branches.");
if (prs != null) {
prs.forEach(pr -> {
ContributionToCheck c = new ContributionToCheck();
@@ -1310,7 +1318,8 @@ public class TcBotTriggerAndSignOffService {
GitHubUser user = pr.gitHubUser();
if (user != null) {
c.prAuthor = user.login();
- c.prAuthorUrl = Strings.isNullOrEmpty(user.login()) ? "" :
"https://github.com/" + user.login();
+ c.prAuthorUrl = !Strings.isNullOrEmpty(user.htmlUrl()) ?
user.htmlUrl()
+ : Strings.isNullOrEmpty(user.login()) ? "" :
"https://github.com/" + user.login();
c.prAuthorAvatarUrl = user.avatarUrl();
}
else {
@@ -1345,7 +1354,12 @@ public class TcBotTriggerAndSignOffService {
.findAny()
.ifPresent(bName -> c.tcBranchName = bName);
prBuildLookupNanos.addAndGet(System.nanoTime() -
buildLookupStart);
- prBuildLookupCnt.incrementAndGet();
+ int processed = prBuildLookupCnt.incrementAndGet();
+
+ if (processed % 10 == 0 || processed == prsCnt) {
+ processMonitor.status(processId, "Matched " + processed +
" of " + prsCnt +
+ " pull requests with JIRA tickets and TeamCity
builds.");
+ }
contribsList.add(c);
});
@@ -1353,16 +1367,19 @@ public class TcBotTriggerAndSignOffService {
prLoopNanos = System.nanoTime() - stepStart;
stepStart = System.nanoTime();
- processMonitor.status(processId, "Loading repository branches.");
+ processMonitor.status(processId, "Loading repository branches from the
GitHub cache.");
List<String> branches = gitHubConnIgnited.getBranches();
Set<String> branchesSet = new HashSet<>(branches);
branchesLoadNanos = System.nanoTime() - stepStart;
+ processMonitor.status(processId, "Loaded " + branches.size() + "
repository branches from the GitHub cache.");
stepStart = System.nanoTime();
List<Ticket> activeTickets = tickets.stream()
.filter(ticket ->
JiraTicketStatusCode.isActiveContribution(ticket.status()))
.collect(Collectors.toList());
activeTicketsFilterNanos = System.nanoTime() - stepStart;
+ processMonitor.status(processId, "Checking " + activeTickets.size() +
+ " active JIRA tickets for PR-less contributions.");
stepStart = System.nanoTime();
activeTickets.forEach(ticket -> {
@@ -1438,7 +1455,7 @@ public class TcBotTriggerAndSignOffService {
activeBuildLookupCnt.get());
}
- processMonitor.status(processId, "The contribution list is ready.");
+ processMonitor.status(processId, "The contribution list is ready: " +
contribsList.size() + " rows.");
return contribsList;
}
@@ -1462,16 +1479,29 @@ public class TcBotTriggerAndSignOffService {
public List<ContributionToCheck> refreshContributionsToCheck(String
srvCodeOrAlias,
ITcBotUserCreds credsProv,
@Nullable Long processId) {
+ processMonitor.status(processId, "Resolving GitHub connection for
server " + srvCodeOrAlias + ".");
+
IGitHubConnIgnited gitHubConnIgnited =
gitHubConnIgnitedProvider.server(srvCodeOrAlias);
+ IGitHubConfig ghCfg = gitHubConnIgnited.config();
+ String gitApiUrl = ghCfg.gitApiUrl();
- processMonitor.status(processId, "Refreshing pull requests from
GitHub.");
- gitHubConnIgnited.refreshPullRequests();
+ processMonitor.status(processId, "Refreshing pull requests from GitHub
API into the bot cache: GET " +
+ gitApiUrl + "pulls?sort=updated&direction=desc (" +
gitHubRateLimiterSummary(ghCfg) + ").");
+ String prsRefresh = gitHubConnIgnited.refreshPullRequests();
+ processMonitor.status(processId, "GitHub pull request refresh
finished: " +
+ compactProcessMessage(prsRefresh));
- if (gitHubConnIgnited.config().isPreferBranches()) {
- processMonitor.status(processId, "Refreshing repository
branches.");
- gitHubConnIgnited.refreshBranches();
+ if (ghCfg.isPreferBranches()) {
+ processMonitor.status(processId, "Refreshing repository branches
from GitHub API into the bot cache: GET " +
+ gitApiUrl + "branches (" + gitHubRateLimiterSummary(ghCfg) +
").");
+ String branchesRefresh = gitHubConnIgnited.refreshBranches();
+ processMonitor.status(processId, "GitHub branch refresh finished:
" +
+ compactProcessMessage(branchesRefresh));
}
+ else
+ processMonitor.status(processId, "Repository branch refresh
skipped because preferBranches=false.");
+ processMonitor.status(processId, "Building contribution table from
refreshed GitHub/JIRA/TeamCity data.");
return getContributionsToCheck(srvCodeOrAlias, credsProv, processId);
}
@@ -2508,6 +2538,32 @@ public class TcBotTriggerAndSignOffService {
return nanosToMillis(System.nanoTime() - startNanos);
}
+ /**
+ * @param msg Process summary returned by an integration refresh.
+ */
+ private static String compactProcessMessage(@Nullable String msg) {
+ if (Strings.isNullOrEmpty(msg))
+ return "done";
+
+ String compact = msg.replace('\r', ' ').replace('\n', ' ').trim();
+
+ if (compact.length() <= 220)
+ return compact;
+
+ return compact.substring(0, 217) + "...";
+ }
+
+ /**
+ * @param cfg GitHub config.
+ */
+ private static String gitHubRateLimiterSummary(IGitHubConfig cfg) {
+ if (cfg.isGitTokenAvailable())
+ return "GitHub rate limiter active with token, up to 5000
requests/hour";
+
+ return "GitHub rate limiter active without token, up to 60
requests/hour; configure GitHub token for faster " +
+ "refresh";
+ }
+
/**
* Logs slow visa operation.
*/
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
index bca7ebe0..98e5f403 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/CacheMetricsUi.java
@@ -22,10 +22,12 @@ public class CacheMetricsUi {
public String name;
public Integer size;
public Integer parts;
+ public boolean resettable;
- public CacheMetricsUi(String name, int size, int parts) {
+ public CacheMetricsUi(String name, int size, int parts, boolean
resettable) {
this.name = name;
this.size = size;
this.parts = parts;
+ this.resettable = resettable;
}
}
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
index 60342226..6a6937c5 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
@@ -28,6 +28,31 @@ import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.security.RolesAllowed;
+import javax.servlet.ServletContext;
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.ClientErrorException;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.GET;
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
import org.apache.ignite.Ignite;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.cache.CacheMetrics;
@@ -42,24 +67,15 @@ import
org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
import org.apache.ignite.tcbot.engine.conf.INotificationChannel;
import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
import org.apache.ignite.tcbot.engine.conf.NotificationsConfig;
+import org.apache.ignite.tcbot.engine.process.BotProcessMonitor;
+import org.apache.ignite.tcbot.engine.process.BotProcessStatus;
import org.apache.ignite.tcbot.notify.IEmailSender;
import org.apache.ignite.tcbot.notify.ISendEmailConfig;
import org.apache.ignite.tcbot.notify.ISlackSender;
-
-import javax.annotation.security.RolesAllowed;
-import javax.servlet.ServletContext;
-import javax.ws.rs.*;
-import javax.ws.rs.core.Context;
-import javax.ws.rs.core.MediaType;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.List;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import org.apache.ignite.tcbot.persistence.scheduler.IScheduler;
+import org.apache.ignite.tcbot.persistence.scheduler.MaintenanceActionInfo;
+import org.apache.ignite.tcbot.persistence.scheduler.MaintenanceActionRegistry;
+import org.apache.ignite.tcbot.persistence.scheduler.ScheduledTaskInfo;
@Path("monitoring")
@Produces(MediaType.APPLICATION_JSON)
@@ -121,6 +137,109 @@ public class MonitoringService {
}).collect(Collectors.toList());
}
+ @GET
+ @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+ @Path("scheduledTasks")
+ public List<ScheduledTaskInfo> getScheduledTasks() {
+ MaintenanceActionRegistry actions =
instance(MaintenanceActionRegistry.class);
+ BotProcessMonitor process = instance(BotProcessMonitor.class);
+
+ return instance(IScheduler.class).scheduledTasks().stream()
+ .peek(task -> enrichScheduledTask(task, actions, process))
+ .collect(Collectors.toList());
+ }
+
+ @GET
+ @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+ @Path("maintenanceActions")
+ public List<MaintenanceActionInfo> getMaintenanceActions() {
+ IScheduler scheduler = instance(IScheduler.class);
+ MaintenanceActionRegistry actions =
instance(MaintenanceActionRegistry.class);
+ BotProcessMonitor process = instance(BotProcessMonitor.class);
+ Map<String, ScheduledTaskInfo> scheduled =
scheduler.scheduledTasks().stream()
+ .peek(task -> enrichScheduledTask(task, actions, process))
+ .collect(Collectors.toMap(task -> task.name, task -> task, (first,
second) -> first));
+
+ return actions.actions().stream()
+ .peek(action -> {
+ ScheduledTaskInfo task = scheduled.get(action.name);
+
+ if (task == null)
+ return;
+
+ action.status = task.status;
+ action.processStatus = task.processStatus;
+ action.processId = task.processId;
+ action.canStartNow = task.canStartNow;
+ })
+ .collect(Collectors.toList());
+ }
+
+ @POST
+ @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+ @Path("maintenanceActions/start")
+ public SimpleResult startMaintenanceAction(@FormParam("name") String name,
+ @QueryParam("processId") Long processId) {
+ if (Strings.isNullOrEmpty(name))
+ throw new BadRequestException("Action name is required");
+
+ MaintenanceActionRegistry actions =
instance(MaintenanceActionRegistry.class);
+ IScheduler scheduler = instance(IScheduler.class);
+ BotProcessMonitor process = instance(BotProcessMonitor.class);
+
+ if (!actions.hasAction(name)) {
+ process.fail(processId, "Maintenance action is not registered: " +
name);
+
+ throw new NotFoundException("Maintenance action is not registered:
" + name);
+ }
+
+ process.start(processId, "maintenanceAction", "Maintenance action
request accepted: " + name);
+
+ boolean accepted = scheduler.runNamedNow(name, () -> {
+ try {
+ process.status(processId, "Running maintenance action: " +
name);
+
+ String result = actions.run(name);
+
+ process.finish(processId, result);
+ }
+ catch (Exception e) {
+ process.fail(processId, e);
+ throw new RuntimeException(e);
+ }
+ }, processId);
+
+ if (!accepted) {
+ process.fail(processId, "Maintenance action is already queued or
running: " + name);
+
+ throw new ClientErrorException("Maintenance action is already
queued or running: " + name,
+ Response.Status.CONFLICT);
+ }
+
+ return new SimpleResult("Maintenance action start requested: " + name);
+ }
+
+ /**
+ * @param task Scheduled task.
+ * @param actions Maintenance actions.
+ * @param process Bot process monitor.
+ */
+ private void enrichScheduledTask(ScheduledTaskInfo task,
MaintenanceActionRegistry actions,
+ BotProcessMonitor process) {
+ if (task.processId != null) {
+ BotProcessStatus status = process.status(task.processId);
+
+ if (status.id != null && !Strings.isNullOrEmpty(status.status)) {
+ task.processStatus = status.status;
+
+ if (status.isRunning())
+ task.status = status.status;
+ }
+ }
+
+ task.canStartNow = actions.hasAction(task.name) && task.canStartNow;
+ }
+
@GET
@RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
@Path("appLogSummaryLink")
@@ -454,6 +573,7 @@ public class MonitoringService {
@Path("cacheMetrics")
public List<CacheMetricsUi> getCacheStat() {
Ignite ignite = instance(Ignite.class);
+ Set<String> resettableCaches = resettableCaches();
final Collection<String> strings = ignite.cacheNames();
@@ -477,11 +597,55 @@ public class MonitoringService {
Affinity<Object> affinity = ignite.affinity(next);
- res.add(new CacheMetricsUi(next, size, affinity.partitions()));
+ res.add(new CacheMetricsUi(next, size, affinity.partitions(),
resettableCaches.contains(next)));
}
return res;
}
+ @POST
+ @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
+ @Path("resetCache")
+ public SimpleResult resetCache(@FormParam("name") String name,
@QueryParam("processId") Long processId) {
+ BotProcessMonitor process = instance(BotProcessMonitor.class);
+ Set<String> resettableCaches = resettableCaches();
+
+ process.start(processId, "resetCache", "Cache reset request accepted:
" + name);
+
+ if (!resettableCaches.contains(name)) {
+ process.fail(processId, "Cache reset is not allowed for: " + name);
+
+ throw new BadRequestException("Cache reset is not allowed for: " +
name);
+ }
+
+ Ignite ignite = instance(Ignite.class);
+ IgniteCache<?, ?> cache = ignite.cache(name);
+
+ if (cache == null) {
+ process.fail(processId, "Cache not found: " + name);
+
+ throw new NotFoundException("Cache not found: " + name);
+ }
+
+ int size = cache.size();
+
+ process.status(processId, "Clearing cache: " + name);
+
+ cache.clear();
+
+ String result = "Cache reset: " + name + ", cleared entries: " + size;
+
+ process.finish(processId, result);
+
+ return new SimpleResult(result);
+ }
+
+ /**
+ * @return Cache names admins may reset from monitoring UI.
+ */
+ private Set<String> resettableCaches() {
+ return Set.copyOf(instance(ITcBotConfig.class).resettableCaches());
+ }
+
@GET
@Path("requests")
public List<RequestStat> getRequestStats() {
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
index 2c6b6a17..b3e378e0 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/visa/TcBotVisaService.java
@@ -157,15 +157,19 @@ public class TcBotVisaService {
TcBotApplicationContext appCtx =
CtxListener.getApplicationContext(ctx);
BotProcessMonitor process =
appCtx.getInstance(BotProcessMonitor.class);
- process.start(processId, "refreshContributions", "Sending PR refresh
request to the bot REST API.");
+ process.start(processId, "refreshContributions", "PR refresh REST
endpoint accepted the request.");
try {
+ process.status(processId, "Checking TeamCity credentials for
server " + srvCode + ".");
+
appCtx.getInstance(ITeamcityIgnitedProvider.class).checkAccess(srvCode,
credsProv);
+ process.status(processId, "TeamCity credentials accepted. Starting
GitHub cache refresh.");
+
List<ContributionToCheck> res =
appCtx.getInstance(TcBotTriggerAndSignOffService.class)
.refreshContributionsToCheck(srvCode, credsProv, processId);
- process.finish(processId, "count=" + res.size());
+ process.finish(processId, "PR refresh finished. Loaded " +
res.size() + " contributions.");
return res;
}
diff --git a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
index 3893be2e..1b9e17cd 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
@@ -121,9 +121,28 @@ function startBotProcessPolling(processId, onStatus,
options) {
var opts = options || {};
var lastStatus = null;
+ var lastReportAt = 0;
var stopped = false;
var timer;
+ function reportStatus(status) {
+ var now = Date.now();
+ var statusText = status && isDefinedAndFilled(status.status) ?
status.status : "";
+ var repeated = statusText === lastStatus;
+
+ if (repeated && (!opts.repeatMs || now - lastReportAt < opts.repeatMs))
+ return;
+
+ if (repeated && typeof opts.repeatText === "function") {
+ status = $.extend({}, status);
+ status.status = opts.repeatText(status);
+ }
+
+ lastStatus = statusText;
+ lastReportAt = now;
+ onStatus(status);
+ }
+
function poll() {
if (stopped)
return;
@@ -138,13 +157,23 @@ function startBotProcessPolling(processId, onStatus,
options) {
if (!isDefinedAndFilled(status.kind) && opts.skipUnknown !==
false)
return;
- if (status.status !== lastStatus) {
- lastStatus = status.status;
- onStatus(status);
- }
+ reportStatus(status);
if (status.running === false)
stop();
+ },
+ error: function (jqXHR, textStatus, errorThrown) {
+ if (stopped || opts.reportErrors !== true || textStatus ===
"abort")
+ return;
+
+ reportStatus({
+ id: processId,
+ kind: "process-status",
+ running: true,
+ status: "Unable to read bot process status: " +
+ (jqXHR.status ? "HTTP " + jqXHR.status + " " : "") +
+ (errorThrown || jqXHR.statusText || textStatus ||
"unknown error")
+ });
}
});
}
diff --git a/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js
b/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js
index 28045cd5..075f6933 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/prs-1.3.js
@@ -183,7 +183,17 @@ function requestTableForServer(srvId, element) {
loadGithubResolutionForContributions(srvId);
fillBranchAutocompleteList(result, srvId);
setAutocompleteFilter();
+ },
+ error: function (jqXHR, exception) {
+ let table = $("#" + tableId);
+
+ if (table.length > 0) {
+ table.find("thead").html("<tr
class='ui-widget-header'><th>Failed to load contributions</th></tr>");
+ table.append("<tbody><tr><td>" + escapeHtml(jqXHR.responseText
|| "Request failed") + "</td></tr></tbody>");
}
+
+ showErrInLoadStatus(jqXHR, exception);
+ }
});
}
@@ -230,10 +240,30 @@ function refreshContributionsNow(srvId) {
openCenteredDialog(dialog, actionDialogOptions("Refresh pull requests",
{}));
+ appendStage("Created browser-side process id " + processId + ".");
appendStage("Sending PR refresh request to the bot REST API.");
+ appendStage("Waiting for the backend to register the refresh process.");
stopProcessPolling = startBotProcessPolling(processId, function
(processStatus) {
- appendStage(botProcessStatusText(processStatus));
+ let statusText = botProcessStatusText(processStatus);
+
+ if (!isDefinedAndFilled(processStatus.kind))
+ statusText += " The refresh HTTP request may still be entering the
backend endpoint.";
+ else if (processStatus.running !== false)
+ setActionStatus(dialog, statusText);
+
+ appendStage(statusText);
+ }, {
+ intervalMs: 1000,
+ skipUnknown: false,
+ reportErrors: true,
+ repeatMs: 7000,
+ repeatText: function (processStatus) {
+ if (!isDefinedAndFilled(processStatus.kind))
+ return "Still waiting for the bot to register process " +
processId + ".";
+
+ return "Still running: " + botProcessStatusText(processStatus);
+ }
});
xhr = $.ajax({
diff --git a/ignite-tc-helper-web/src/main/webapp/monitoring.html
b/ignite-tc-helper-web/src/main/webapp/monitoring.html
index 7f326e47..68c5bdb1 100644
--- a/ignite-tc-helper-web/src/main/webapp/monitoring.html
+++ b/ignite-tc-helper-web/src/main/webapp/monitoring.html
@@ -94,6 +94,9 @@
error: showErrInLoadStatus
});
+ loadScheduledTasksData();
+ loadMaintenanceActionsData();
+
$.ajax({
url: "rest/monitoring/appLogSummaryLink",
success: showAppLogSummaryLink,
@@ -117,6 +120,36 @@
loadAiPromptsData();
}
+ function loadScheduledTasksData() {
+ if (!monitoringAdmin)
+ return;
+
+ $.ajax({
+ url: "rest/monitoring/scheduledTasks",
+ success: function(result) {
+ $("#loadStatus").html("");
+
+ showScheduledTasks(result);
+ },
+ error: showErrInLoadStatus
+ });
+ }
+
+ function loadMaintenanceActionsData() {
+ if (!monitoringAdmin)
+ return;
+
+ $.ajax({
+ url: "rest/monitoring/maintenanceActions",
+ success: function(result) {
+ $("#loadStatus").html("");
+
+ showMaintenanceActions(result);
+ },
+ error: showErrInLoadStatus
+ });
+ }
+
function loadAiPromptsData() {
$.ajax({
url: "rest/monitoring/aiPrompts",
@@ -163,6 +196,78 @@
$("#tasks").html(res);
}
+ function showScheduledTasks(result) {
+ var res = "<table class='stat'>" ;
+
+ res += "<tr>";
+ res += "<th>Name</th>";
+ res += "<th>Status</th>";
+ res += "<th>Last finished</th>";
+ res += "<th>Quiet period</th>";
+ res += "<th>Runnable</th>";
+ res += "<th>Actions</th>";
+ res += "</tr>";
+
+ for (var i = 0; i < result.length; i++) {
+ var task = result[i];
+ var status = task.processStatus || task.status;
+
+ res += "<tr>";
+ res += "<td>" + escapeHtml(task.name) + "</td>";
+ res += "<td>" + escapeHtml(status) + "</td>";
+ res += "<td>" + escapeHtml(formatTs(task.lastFinishedTs)) +
"</td>";
+ res += "<td>" + escapeHtml(formatDuration(task.quietPeriodMs)) +
"</td>";
+ res += "<td>" + escapeHtml(task.runnableAvailable) + "</td>";
+ res += "<td>";
+
+ if (task.canStartNow) {
+ res += "<button onclick='startMaintenanceAction(" +
JSON.stringify(task.name) +
+ ")'>Start now</button>";
+ }
+
+ res += "</td>";
+ res += "</tr>";
+ }
+
+ res += "</table>";
+
+ $("#scheduledTasks").html(res);
+ }
+
+ function showMaintenanceActions(result) {
+ var res = "<table class='stat'>" ;
+
+ res += "<tr>";
+ res += "<th>Name</th>";
+ res += "<th>Description</th>";
+ res += "<th>Status</th>";
+ res += "<th>Actions</th>";
+ res += "</tr>";
+
+ for (var i = 0; i < result.length; i++) {
+ var action = result[i];
+ var status = action.processStatus || action.status || "";
+
+ res += "<tr>";
+ res += "<td>" + escapeHtml(action.name) + "</td>";
+ res += "<td>" + escapeHtml(action.description) + "</td>";
+ res += "<td>" + escapeHtml(status) + "</td>";
+ res += "<td>";
+
+ if (action.canStartNow !== false) {
+ res += "<button onclick='startMaintenanceAction(" +
JSON.stringify(action.name) +
+ ")'>Start now</button>";
+ }
+
+ res += "</td>";
+ res += "</tr>";
+ }
+
+ res += "</table>";
+
+ $("#maintenanceActions").html(res);
+ }
+
function showAppLogSummaryLink(result) {
var logUrl = "monitoring-log.html?startTs=" +
encodeURIComponent(result.startTs)
+ "&endTs=0"
@@ -235,6 +340,8 @@
res += "<th>Name</th>";
res += "<th>Size</th>";
res += "<th>Parts</th>";
+ if (monitoringAdmin)
+ res += "<th>Actions</th>";
res += "</tr>";
for (var i = 0; i < result.length; i++) {
var inv = result[i];
@@ -242,6 +349,14 @@
res += "<td>" + escapeHtml(inv.name) + "</td>";
res += "<td>" + escapeHtml(inv.size) + "</td>";
res += "<td>" + escapeHtml(inv.parts) + "</td>";
+ if (monitoringAdmin) {
+ res += "<td>";
+ if (inv.resettable) {
+ res += "<button onclick='resetCache(" +
JSON.stringify(inv.name) +
+ ")'>Reset</button>";
+ }
+ res += "</td>";
+ }
res += "</tr>";
}
$("#caches").html(res);
@@ -306,6 +421,132 @@
});
}
+ function resetCache(name) {
+ if (!monitoringAdmin)
+ return false;
+
+ confirmAdminAction("Reset cache",
+ "Clear all entries from cache \"" + name + "\"? This action is
admin-only.",
+ function() {
+ var processId = createBotProcessId("resetCache");
+ var dialog = openAdminProcessDialog("Reset cache",
+ "Resetting cache \"" + name + "\"...");
+ var stopPolling = pollAdminProcess(dialog, processId,
loadData);
+
+ $.ajax({
+ url: "rest/monitoring/resetCache?processId=" +
encodeURIComponent(processId),
+ method: "post",
+ data: {name: name},
+ success: function(result) {
+ appendActionStage(dialog, result.result || "Ok");
+ },
+ error: function(jqXHR, exception) {
+ if (stopPolling)
+ stopPolling();
+
+ showActionError(dialog, jqXHR.responseText ||
exception);
+ dialog.dialog("option", "buttons", {"Ok": function() {
$(this).dialog("close"); }});
+ }
+ });
+ });
+
+ return false;
+ }
+
+ function startMaintenanceAction(name) {
+ if (!monitoringAdmin)
+ return false;
+
+ confirmAdminAction("Start maintenance action",
+ "Start maintenance action \"" + name + "\" now? This action is
admin-only.",
+ function() {
+ var processId = createBotProcessId("maintenanceAction");
+ var dialog = openAdminProcessDialog("Maintenance action",
+ "Starting \"" + name + "\"...");
+ var stopPolling = pollAdminProcess(dialog, processId,
function() {
+ loadScheduledTasksData();
+ loadMaintenanceActionsData();
+ });
+
+ $.ajax({
+ url: "rest/monitoring/maintenanceActions/start?processId="
+ encodeURIComponent(processId),
+ method: "post",
+ data: {name: name},
+ success: function(result) {
+ appendActionStage(dialog, result.result || "Started");
+ },
+ error: function(jqXHR, exception) {
+ if (stopPolling)
+ stopPolling();
+
+ showActionError(dialog, jqXHR.responseText ||
exception);
+ dialog.dialog("option", "buttons", {"Ok": function() {
$(this).dialog("close"); }});
+ }
+ });
+ });
+
+ return false;
+ }
+
+ function confirmAdminAction(title, text, onConfirm) {
+ var dialog = ensureActionDialog("adminConfirmDialog", title);
+
+ dialog.html(actionStatusHtml(text));
+
+ openActionDialog(dialog, title, {
+ "Confirm": function() {
+ $(this).dialog("close");
+ onConfirm();
+ },
+ "Cancel": function() {
+ $(this).dialog("close");
+ }
+ });
+ }
+
+ function openAdminProcessDialog(title, status) {
+ var dialog = ensureActionDialog("adminProcessDialog", title);
+
+ dialog.html(actionStatusHtml(status) + actionStagesHtml(false) +
actionErrorHtml());
+ openActionDialog(dialog, title, {});
+ appendActionStage(dialog, "Sending request to the bot REST API.");
+
+ return dialog;
+ }
+
+ function pollAdminProcess(dialog, processId, onComplete) {
+ return startBotProcessPolling(processId, function(status) {
+ appendActionStage(dialog, botProcessStatusText(status));
+
+ if (status.running === false) {
+ dialog.dialog("option", "buttons", {"Ok": function() {
$(this).dialog("close"); }});
+
+ if (onComplete)
+ onComplete();
+ }
+ }, {skipUnknown: false});
+ }
+
+ function formatTs(ts) {
+ if (!ts || ts <= 0)
+ return "";
+
+ return new Date(ts).toLocaleString();
+ }
+
+ function formatDuration(ms) {
+ if (!ms || ms <= 0)
+ return "";
+
+ if (ms < 60000)
+ return Math.round(ms / 1000) + "s";
+
+ if (ms < 3600000)
+ return Math.round(ms / 60000) + "m";
+
+ return Math.round(ms / 3600000) + "h";
+ }
+
function testSlackNotification() {
if (!monitoringAdmin)
return false;
@@ -359,6 +600,14 @@
<div id="appLogSummary" style="font-family: monospace"></div>
<div id="tasks" style="font-family: monospace"></div>
<br>
+
+ Scheduled Tasks:
+ <div id="scheduledTasks" style="font-family: monospace"></div>
+ <br>
+
+ Maintenance Actions:
+ <div id="maintenanceActions" style="font-family: monospace"></div>
+ <br>
</div>
<div id="userAdminBlock" style="display: none">
diff --git a/ignite-tc-helper-web/src/main/webapp/prs.html
b/ignite-tc-helper-web/src/main/webapp/prs.html
index e299ed53..14e65e7f 100644
--- a/ignite-tc-helper-web/src/main/webapp/prs.html
+++ b/ignite-tc-helper-web/src/main/webapp/prs.html
@@ -17,9 +17,9 @@
<link rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.4.2/css/all.css"
integrity="sha384-/rXc/GQVaYpyDdyxK+ecHPVYJSN9bmVFBvjA/9eOB+pb3F2w2N6fc5qB9Ew5yIns"
crossorigin="anonymous">
- <script src="js/common-1.7.js?v=20260509-6"></script>
+ <script src="js/common-1.7.js?v=20260510-1"></script>
<script src="js/testfails-2.3.js?v=20260509-22"></script>
- <script src="js/prs-1.3.js?v=20260510-2"></script>
+ <script src="js/prs-1.3.js?v=20260510-4"></script>
<style>
diff --git
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
index bc792ab2..c3652491 100644
---
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
+++
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/SchedulerModule.java
@@ -24,5 +24,6 @@ public class SchedulerModule extends AbstractModule {
/** {@inheritDoc} */
@Override protected void configure() {
bind(IScheduler.class).to(TcBotScheduler.class).in(Scopes.SINGLETON);
+ bind(MaintenanceActionRegistry.class).in(Scopes.SINGLETON);
}
}
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
index c9473a5c..8bbb26b9 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
@@ -23,6 +23,7 @@ import
org.apache.ignite.tcbot.common.conf.IDataSourcesConfigSupplier;
import java.util.Collection;
import java.util.Collections;
+import java.util.List;
/**
* Teamcity Bot configuration access interface.
@@ -40,6 +41,9 @@ public interface ITcBotConfig extends
IDataSourcesConfigSupplier {
/** Default TeamCity group id whose members are allowed to administer bot
settings. */
String DEFAULT_BOT_ADMIN_GROUP = "IGNITE_COMMITER";
+ /** Caches safe to reset from monitoring UI by default. */
+ List<String> DEFAULT_RESETTABLE_CACHES = List.of("testFixMatches",
"testFixSourceUpdates");
+
/** */
String primaryServerCode();
@@ -84,6 +88,13 @@ public interface ITcBotConfig extends
IDataSourcesConfigSupplier {
return Collections.emptyList();
}
+ /**
+ * @return Ignite cache names that admins may reset from monitoring UI.
+ */
+ default Collection<String> resettableCaches() {
+ return DEFAULT_RESETTABLE_CACHES;
+ }
+
/**
* @return notification settings config.
*/
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
index e53a6ae7..06e334cd 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
@@ -53,6 +53,9 @@ public class TcBotJsonConfig implements
ITrackedBranchesConfig {
/** Bot user logins allowed to manage users. */
@Nullable private List<String> userAdmins;
+ /** Ignite cache names admins may reset from monitoring UI. */
+ @Nullable private List<String> resettableCaches;
+
/** Additional list Servers to be used for validation of PRs, but not for
tracking any branches. */
private List<TcServerConfig> tcServers = new ArrayList<>();
@@ -135,6 +138,13 @@ public class TcBotJsonConfig implements
ITrackedBranchesConfig {
return userAdmins;
}
+ /**
+ * @return Ignite cache names admins may reset from monitoring UI.
+ */
+ @Nullable public List<String> resettableCaches() {
+ return resettableCaches;
+ }
+
public Optional<TcServerConfig> getTcConfig(String code) {
return tcServers.stream().filter(s -> Objects.equals(code,
s.getCode())).findAny();
}
diff --git
a/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
b/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
index db163c2d..117dd11f 100644
---
a/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
+++
b/tcbot-github-ignited/src/main/java/org/apache/ignite/githubignited/GitHubConnIgnitedImpl.java
@@ -45,6 +45,7 @@ import org.apache.ignite.ci.github.GitHubIssueComment;
import org.apache.ignite.ci.github.GitHubUser;
import org.apache.ignite.ci.github.PullRequest;
import org.apache.ignite.tcbot.common.conf.IGitHubConfig;
+import org.apache.ignite.tcbot.persistence.scheduler.MaintenanceActionRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,6 +69,9 @@ class GitHubConnIgnitedImpl implements IGitHubConnIgnited {
/** Scheduler. */
@Inject IScheduler scheduler;
+ /** Admin maintenance actions. */
+ @Inject MaintenanceActionRegistry maintenanceActions;
+
/** Server ID mask for cache Entries. */
private int srvIdMaskHigh;
@@ -97,6 +101,11 @@ class GitHubConnIgnitedImpl implements IGitHubConnIgnited {
prCache =
ignite.getOrCreateCache(CacheConfigs.getCache8PartsConfig(GIT_HUB_PR));
branchCache =
ignite.getOrCreateCache(CacheConfigs.getCache8PartsConfig(GIT_HUB_BRANCHES));
userCache =
ignite.getOrCreateCache(CacheConfigs.getCache8PartsConfig(GIT_HUB_USERS));
+
+ maintenanceActions.register(taskName("actualizePrs"), "Refresh GitHub
pull requests for " + srvCode,
+ this::refreshPullRequests);
+ maintenanceActions.register(taskName("actualizeBranches"), "Refresh
GitHub branches for " + srvCode,
+ this::refreshBranches);
}
/** {@inheritDoc} */
diff --git
a/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java
b/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java
index 4f4e30e2..ac0e0faf 100644
--- a/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java
+++ b/tcbot-github/src/main/java/org/apache/ignite/ci/github/GitHubUser.java
@@ -25,6 +25,7 @@ import java.util.Objects;
public class GitHubUser {
@SerializedName("login") private String login;
@SerializedName("url") private String url;
+ @SerializedName("html_url") private String htmlUrl;
@SerializedName("avatar_url") private String avatarUrl;
@SerializedName("email") private String email;
/*See full example in prsList.json */
@@ -43,6 +44,13 @@ public class GitHubUser {
return url;
}
+ /**
+ * @return GitHub web user URL.
+ */
+ public String htmlUrl() {
+ return htmlUrl;
+ }
+
/**
* @return Avatar url.
*/
@@ -66,12 +74,13 @@ public class GitHubUser {
GitHubUser user = (GitHubUser)o;
return Objects.equals(login, user.login) &&
Objects.equals(url, user.url) &&
+ Objects.equals(htmlUrl, user.htmlUrl) &&
Objects.equals(avatarUrl, user.avatarUrl) &&
Objects.equals(email, user.email);
}
/** {@inheritDoc} */
@Override public int hashCode() {
- return Objects.hash(login, url, avatarUrl, email);
+ return Objects.hash(login, url, htmlUrl, avatarUrl, email);
}
}
diff --git
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
index 0b22bd7d..ece632f2 100644
---
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
+++
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
@@ -32,6 +32,18 @@ public class DirectExecNoWaitScheduler implements IScheduler
{
cmd.run();
}
+ /** {@inheritDoc} */
+ @Override public boolean runNamedNow(String fullName, Runnable cmd) {
+ return runNamedNow(fullName, cmd, null);
+ }
+
+ /** {@inheritDoc} */
+ @Override public boolean runNamedNow(String fullName, Runnable cmd, Long
processId) {
+ cmd.run();
+
+ return true;
+ }
+
/** {@inheritDoc} */
@Override public void stop() {
diff --git
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
index c9e46ac3..4f545720 100644
---
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
+++
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/IScheduler.java
@@ -18,6 +18,8 @@ package org.apache.ignite.tcbot.persistence.scheduler;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
+import java.util.Collections;
+import java.util.List;
/**
* Scheduler is a way to run background syncs between Ignite DB and REST
services.
@@ -36,5 +38,39 @@ public interface IScheduler {
public void sheduleNamed(String fullName, Runnable cmd, long queitPeriod,
TimeUnit unit);
+ /**
+ * Runs named action as soon as possible using scheduler executor.
+ *
+ * @param fullName Named task name.
+ * @param cmd Task body.
+ * @return {@code true} if the action was accepted, {@code false} if the
same named task is already running or
+ * queued.
+ */
+ public default boolean runNamedNow(String fullName, Runnable cmd) {
+ return runNamedNow(fullName, cmd, null);
+ }
+
+ /**
+ * Runs named action as soon as possible using scheduler executor.
+ *
+ * @param fullName Named task name.
+ * @param cmd Task body.
+ * @param processId Optional process monitor id associated with this
manual run.
+ * @return {@code true} if the action was accepted, {@code false} if the
same named task is already running or
+ * queued.
+ */
+ public default boolean runNamedNow(String fullName, Runnable cmd, Long
processId) {
+ sheduleNamed(fullName, cmd, 0, TimeUnit.MILLISECONDS);
+
+ return true;
+ }
+
+ /**
+ * @return User-visible named task state.
+ */
+ public default List<ScheduledTaskInfo> scheduledTasks() {
+ return Collections.emptyList();
+ }
+
public void stop();
}
diff --git
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionInfo.java
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionInfo.java
new file mode 100644
index 00000000..16d652e6
--- /dev/null
+++
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionInfo.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.ignite.tcbot.persistence.scheduler;
+
+/**
+ * User-visible maintenance action registered by a service.
+ */
+@SuppressWarnings("PublicField")
+public class MaintenanceActionInfo {
+ /** Action name. */
+ public String name;
+
+ /** Action description. */
+ public String description;
+
+ /** Matching scheduler status, if the action has a named scheduler task. */
+ public String status;
+
+ /** Current user-visible bot process status, if this action is running
through a monitored manual action. */
+ public String processStatus;
+
+ /** Bot process monitor id associated with the current manual run. */
+ public Long processId;
+
+ /** Action can be started by admin now. */
+ public boolean canStartNow = true;
+
+ /**
+ * @param name Action name.
+ * @param description Action description.
+ */
+ public MaintenanceActionInfo(String name, String description) {
+ this.name = name;
+ this.description = description;
+ }
+}
diff --git
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionRegistry.java
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionRegistry.java
new file mode 100644
index 00000000..f1e46cdc
--- /dev/null
+++
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/MaintenanceActionRegistry.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.ignite.tcbot.persistence.scheduler;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+/**
+ * Registry of admin-triggerable maintenance actions owned by concrete
services.
+ */
+public class MaintenanceActionRegistry {
+ /** Actions by name. */
+ private final ConcurrentMap<String, Action> actions = new
ConcurrentHashMap<>();
+
+ /**
+ * @param name Action name.
+ * @param description Action description.
+ * @param action Action body.
+ */
+ public void register(String name, String description, Callable<String>
action) {
+ actions.put(name, new Action(name, description, action));
+ }
+
+ /**
+ * @return Action list.
+ */
+ public List<MaintenanceActionInfo> actions() {
+ return actions.values().stream()
+ .map(action -> new MaintenanceActionInfo(action.name,
action.description))
+ .sorted(Comparator.comparing(info -> info.name))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @param name Action name.
+ * @return {@code true} if action is registered.
+ */
+ public boolean hasAction(@Nullable String name) {
+ return name != null && actions.containsKey(name);
+ }
+
+ /**
+ * @param name Action name.
+ * @return Action result.
+ */
+ public String run(String name) throws Exception {
+ Action action = actions.get(name);
+
+ if (action == null)
+ throw new IllegalArgumentException("Maintenance action is not
registered: " + name);
+
+ return action.action.call();
+ }
+
+ /** Registered action. */
+ private static class Action {
+ /** Action name. */
+ private final String name;
+
+ /** Action description. */
+ private final String description;
+
+ /** Action body. */
+ private final Callable<String> action;
+
+ /**
+ * @param name Action name.
+ * @param description Action description.
+ * @param action Action body.
+ */
+ private Action(String name, String description, Callable<String>
action) {
+ this.name = name;
+ this.description = description;
+ this.action = action;
+ }
+ }
+}
diff --git
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
index 8c1ef609..29e09ebc 100644
---
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
+++
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/NamedTask.java
@@ -37,6 +37,12 @@ class NamedTask {
@GuardedBy("lock")
private volatile long resValidityMs = 0;
+ @GuardedBy("lock")
+ private volatile boolean forceRun;
+
+ @GuardedBy("lock")
+ private volatile Long processId;
+
enum Status {
CREATED, RUNNING, COMPLETED;
}
@@ -77,6 +83,28 @@ class NamedTask {
}
+ /**
+ * @param cmd Task body.
+ * @return {@code true} if task was queued, {@code false} if it is already
queued or running.
+ */
+ public boolean scheduleNow(@Nonnull Runnable cmd, Long processId) {
+ long writeLockStamp = lock.writeLock();
+
+ try {
+ if (status == Status.RUNNING || this.cmd != null)
+ return false;
+
+ this.cmd = cmd;
+ forceRun = true;
+ this.processId = processId;
+
+ return true;
+ }
+ finally {
+ lock.unlock(writeLockStamp);
+ }
+ }
+
public Runnable runIfNeeded() throws Exception {
long optReadStamp = lock.tryOptimisticRead();
boolean canSkip = canSkipStartNow();
@@ -108,8 +136,10 @@ class NamedTask {
this.cmd = null;
// because here lock is not upgraded from read lock cmd may come
here with null
- if (cmd != null)
+ if (cmd != null) {
+ forceRun = false;
status = Status.RUNNING;
+ }
}
finally {
lock.unlock(writeLockStamp);
@@ -126,6 +156,7 @@ class NamedTask {
try {
lastFinishedTs = System.currentTimeMillis();
status = Status.COMPLETED;
+ processId = null;
}
finally {
lock.unlock(writeLockStamp2);
@@ -135,7 +166,34 @@ class NamedTask {
return cmd;
}
+ /**
+ * @return User-visible task snapshot.
+ */
+ public ScheduledTaskInfo info() {
+ long readStamp = lock.readLock();
+
+ try {
+ ScheduledTaskInfo res = new ScheduledTaskInfo();
+
+ res.name = name;
+ res.status = status.name();
+ res.lastFinishedTs = lastFinishedTs;
+ res.quietPeriodMs = resValidityMs;
+ res.runnableAvailable = cmd != null;
+ res.canStartNow = status != Status.RUNNING && cmd == null;
+ res.processId = processId;
+
+ return res;
+ }
+ finally {
+ lock.unlockRead(readStamp);
+ }
+ }
+
public boolean canSkipStartNow() {
+ if (forceRun)
+ return false;
+
boolean canSkip = false;
if (status == Status.RUNNING)
canSkip = true;
diff --git
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/ScheduledTaskInfo.java
similarity index 52%
copy from
tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
copy to
tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/ScheduledTaskInfo.java
index 0b22bd7d..72bbd03f 100644
---
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/DirectExecNoWaitScheduler.java
+++
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/ScheduledTaskInfo.java
@@ -16,24 +16,32 @@
*/
package org.apache.ignite.tcbot.persistence.scheduler;
-import java.util.concurrent.TimeUnit;
-
/**
- * Scheduler which never waits. Can be used for tests.
+ * User-visible state of a named scheduled task.
*/
-public class DirectExecNoWaitScheduler implements IScheduler {
- /** {@inheritDoc} */
- @Override public void invokeLater(Runnable cmd, long delay, TimeUnit unit)
{
- cmd.run();
- }
+@SuppressWarnings("PublicField")
+public class ScheduledTaskInfo {
+ /** Task name. */
+ public String name;
+
+ /** Task status. */
+ public String status;
+
+ /** Last finished timestamp. */
+ public long lastFinishedTs;
+
+ /** Fresh result validity period. */
+ public long quietPeriodMs;
+
+ /** Task has runnable action registered. */
+ public boolean runnableAvailable;
- /** {@inheritDoc} */
- @Override public void sheduleNamed(String fullName, Runnable cmd, long
queitPeriod, TimeUnit unit) {
- cmd.run();
- }
+ /** Task can be started by admin now. */
+ public boolean canStartNow;
- /** {@inheritDoc} */
- @Override public void stop() {
+ /** Bot process monitor id associated with the currently queued/running
manual action. */
+ public Long processId;
- }
+ /** Current user-visible bot process status, if this task was started
through a monitored manual action. */
+ public String processStatus;
}
diff --git
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
index 495cb2d2..24c5fcd9 100644
---
a/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
+++
b/tcbot-persistence/src/main/java/org/apache/ignite/tcbot/persistence/scheduler/TcBotScheduler.java
@@ -19,6 +19,7 @@ package org.apache.ignite.tcbot.persistence.scheduler;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -29,6 +30,7 @@ import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
import org.apache.ignite.tcbot.common.interceptor.MonitoredTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -77,6 +79,23 @@ class TcBotScheduler implements IScheduler {
}
}
+ /** {@inheritDoc} */
+ @Override public boolean runNamedNow(String fullName, Runnable cmd) {
+ return runNamedNow(fullName, cmd, null);
+ }
+
+ /** {@inheritDoc} */
+ @Override public boolean runNamedNow(String fullName, Runnable cmd, Long
processId) {
+ NamedTask task = namedTasks.computeIfAbsent(fullName, NamedTask::new);
+
+ if (!task.scheduleNow(cmd, processId))
+ return false;
+
+ service().execute(() -> checkNamedTasks("Manual"));
+
+ return true;
+ }
+
/**
* @param threadNme Runner name to be used in display.
*/
@@ -100,6 +119,14 @@ class TcBotScheduler implements IScheduler {
return "Finished " + run.get() + " task(s) " + (problems.isEmpty() ?
"" : (", exceptions: " + problems.toString()));
}
+ /** {@inheritDoc} */
+ @Override public List<ScheduledTaskInfo> scheduledTasks() {
+ return namedTasks.values().stream()
+ .map(NamedTask::info)
+ .sorted(Comparator.comparing(info -> info.name))
+ .collect(Collectors.toList());
+ }
+
/** {@inheritDoc} */
@Override public void stop() {
if (executorSvc != null) {