Scheduled Tasks
Since v6.2.0
The @Scheduled annotation is available starting from UltiTools-API v6.2.0.
UltiTools provides a declarative way to schedule repeating or delayed tasks using the @Scheduled annotation. Instead of manually creating BukkitRunnable objects, you simply annotate a method and the framework handles the rest.
Basic Usage
Add @Scheduled to any void, no-argument method inside a managed bean (such as a @Service):
package com.ultikits.docs.scheduled;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AutoSaveService {
@Scheduled(period = 6000) // Every 5 minutes (6000 ticks)
public void autoSave() {
// This method is called automatically by the framework
Bukkit.getLogger().info("Auto-saving data...");
}
}Tick Conversion
Minecraft runs at 20 ticks per second: 1 second is 20 ticks, 1 minute is 1,200 ticks, 5 minutes is 6,000 ticks, and 30 minutes is 36,000 ticks.
Annotation Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
delay | long | 0 | Initial delay in ticks before first execution |
period | long | -1 | Repeat interval in ticks. -1 means run once |
async | boolean | false | Run on an async thread instead of the main server thread |
As of v6.3.0, period and delay can instead be read from a config key through config, periodKey and delayKey; see Config-Bound Timing.
Inherited Methods
As of v6.3.0 the framework looks for @Scheduled methods on the bean class and on its superclasses, so a method an abstract base service declares is scheduled for every bean that extends it. An overridden method is scheduled once, with the annotation on the most derived declaration. An override that does not carry @Scheduled itself is not scheduled, because Java does not inherit annotations on methods. Before v6.3.0 only the bean class's own declared methods were scanned, and a @Scheduled method inherited from a superclass never ran (#532).
One-Time Delayed Task
Set only delay (leave period at default -1) to run a task once after a delay:
package com.ultikits.docs.scheduled;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class WelcomeService {
@Scheduled(delay = 100) // Run once, 5 seconds after plugin loads
public void sendWelcomeMessage() {
Bukkit.broadcastMessage("Plugin loaded successfully!");
}
}Repeating Task
Set period to a positive value to create a repeating task:
package com.ultikits.docs.scheduled;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ScoreboardService {
@Scheduled(delay = 20, period = 200) // Start after 1 second, repeat every 10 seconds
public void updateScoreboard() {
for (Player player : Bukkit.getOnlinePlayers()) {
// Update each player's scoreboard
}
}
}Async Tasks
Set async = true for tasks that don't need to access the Bukkit API directly (e.g., database operations, HTTP requests):
package com.ultikits.docs.scheduled;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class InterestService {
@Autowired
private UltiToolsPlugin plugin;
@Scheduled(period = 36000, async = true) // Every 30 minutes, async
public void distributeInterest() {
DataOperator<AccountEntity> dataOperator =
plugin.getDataOperator(AccountEntity.class);
List<AccountEntity> accounts = dataOperator.getAll();
for (AccountEntity account : accounts) {
account.setBalance(account.getBalance() * 1.01);
try {
dataOperator.update(account);
} catch (IllegalAccessException e) {
Bukkit.getLogger().warning("Failed to update account: " + e.getMessage());
}
}
}
}async = true applies to literal timings only. A task whose timing is bound to a config key must be sync, as of v6.3.0.
Bukkit Thread Safety
When async = true, the task runs off the main server thread. You must not call most Bukkit API methods from async threads. If you need to interact with the Bukkit API from an async task, dispatch back to the main thread:
Bukkit.getScheduler().runTask(UltiTools.getInstance(), () -> {
// Safe to call Bukkit API here
player.sendMessage("Operation complete!");
});Config-Bound Timing
As of v6.3.0
@Scheduled can read its period and initial delay from a key in your module's own config file, so a server owner can tune the interval without you writing a second scheduler.
Instead of the period and delay literals, name a @ConfigEntry path with periodKey and delayKey, and give the config entity class with config. The value is read in seconds and multiplied by 20 to get ticks. The default lives only in the config field:
@Getter
@Setter
@ConfigEntity("config/economy.yml")
public class EconomyConfig extends AbstractConfigEntity {
@ConfigEntry(path = "interest.interval", comment = "Seconds between interest payouts")
private int interestInterval = 1800;
public EconomyConfig(String configFilePath) {
super(configFilePath);
}
}@Service
public class InterestService {
@Scheduled(config = EconomyConfig.class, periodKey = "interest.interval", delayKey = "interest.interval")
public void distributeInterest() {
// Runs every interest.interval seconds on the main thread
}
}delayKey may name the same key as periodKey. The first run then waits one full interval, which is what a task such as a payout usually wants instead of running at load. A key is matched against @ConfigEntry(path = ...) as declared, or against the field name when path is empty.
| Attribute | Type | Default | Description |
|---|---|---|---|
config | Class<? extends AbstractConfigEntity> | AbstractConfigEntity.class (unbound) | The config entity whose keys periodKey and delayKey name |
periodKey | String | "" (unbound) | Key whose value, in seconds, is the repeat interval. Replaces period |
delayKey | String | "" (unbound) | Key whose value, in seconds, is the initial delay. Replaces delay |
Load-time checks
A binding is checked when the module loads. If any check fails, that module alone is refused, and the log names the key and the value. The module is refused when:
- a literal is set together with the key that replaces it (
periodwithperiodKey, ordelaywithdelayKey); - the config class is not registered exactly once for the module. A directory
@ConfigEntitycannot be bound; - the key matches no
@ConfigEntrypath; - the bound field is not an
int,long,IntegerorLong; - the value is
null, below 1 second, or above 107,374,182 seconds (Integer.MAX_VALUE / 20, about 3.4 years).0does not mean "off".
Applying changes on reload
A changed value is applied at /ul reload, and the task keeps its place in its cycle. The next run is the last run plus the new period. Before the first run it is the time the task was armed plus the new delay. If that moment has already passed, the task runs on the next tick. A reload never runs a task early and never postpones it by restarting its clock.
A task whose value did not change is not touched. An invalid value on reload is not applied: the running value is kept and a WARNING names the key. As of v6.3.0 the reload is also reported as partial, naming the key, the refused value and the value kept, so /ul reload <name>, the /ul reload summary and a module's own reload command built on reloadWithReport() do not reply plain success (#595). An edit made from the panel takes effect at the next /ul reload. A panel write that sets a bound key outside the range above, such as 0, is refused like a @Range violation, and nothing is written.
Binding restrictions
- Sync only. A bound method cannot be
async = true; that combination is refused at load. Bind a sync task and hand the heavy work toBukkit.getScheduler().runTaskAsynchronously(...)from its body. Literalasynctasks are unaffected. Issue #535 tracks allowing async bindings. - Inherited methods included. As of v6.3.0 the binding is found on the same methods as an unbound
@Scheduled, including one inherited from a superclass, which is scheduled and checked at load like one the bean class declares. (Before v6.3.0 only the bean class's own declared methods were scanned, and a method inherited from a superclass was neither scheduled nor checked, #532.) - No
@Rangeon a bound field. The binding's range above is the field's range. A module@Rangeon the same field would make an out-of-range reload throw from the config reload itself, which aborts the rest of that module's reload (#509) instead of keeping the running value. If the field already had a@Rangebefore you bound it, remove it. - Modules only. A binding on a bean of an External Plugin API plugin is refused.
Required api-version
A module that uses a binding must declare api-version: 630 in its plugin.yml. A 6.2.x framework does not know these attributes and silently ignores them, so a bound task would run once at load instead of on its interval. The declared floor makes the older framework refuse the module instead. 6.3.0 itself refuses a module that uses a binding while declaring a lower api-version. See Module Versioning for why the pom.xml pin alone does not protect you.
Binding a cooldown
@CmdCD accepts the same kind of binding for command cooldowns, see Command cooldown.
Automatic Lifecycle
Tasks annotated with @Scheduled are automatically managed by the framework:
- Registration: Tasks are discovered and started when the plugin loads
- Cancellation: All tasks are automatically cancelled when the owning plugin is unloaded or the server shuts down
You do not need to track or cancel tasks manually.
Requirements
- The annotated method must be
voidand take no parameters - The method must be inside a bean managed by the container (e.g.,
@Service) - The bean must be in a package scanned by
@UltiToolsModule(scanBasePackages = {...})
package com.ultikits.docs.scheduled;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@UltiToolsModule(scanBasePackages = {"com.example.plugin"})
public class MyPlugin extends UltiToolsPlugin {
@Override
public boolean registerSelf() { return true; }
@Override
public void unregisterSelf() { }
}Complete Example
package com.ultikits.docs.scheduled;
import com.ultikits.ultitools.UltiTools;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ServerMonitorService {
@Autowired
private UltiToolsPlugin plugin;
// Check server health every minute
@Scheduled(delay = 1200, period = 1200, async = true)
public void checkServerHealth() {
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
long maxMemory = runtime.maxMemory();
double memoryUsage = (double) usedMemory / maxMemory * 100;
if (memoryUsage > 90) {
Bukkit.getScheduler().runTask(UltiTools.getInstance(), () -> {
Bukkit.broadcastMessage("[Monitor] Warning: Memory usage at "
+ String.format("%.1f", memoryUsage) + "%");
});
}
}
// Clean expired data daily (24 hours = 1,728,000 ticks)
@Scheduled(period = 1728000, async = true)
public void cleanExpiredData() {
DataOperator<TempDataEntity> dataOperator =
plugin.getDataOperator(TempDataEntity.class);
dataOperator.query()
.where("expireTime").lt(System.currentTimeMillis())
.delete();
}
}