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.
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. 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. - Declared methods only. The binding is found on the bean class's own declared methods, the same as an unbound
@Scheduled. A method inherited from a superclass is neither scheduled nor checked, so declare the bound method on the bean class itself (#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();
}
}