diff --git a/config/AcediaSystem.ini b/config/AcediaSystem.ini index bd94299..17d627b 100644 --- a/config/AcediaSystem.ini +++ b/config/AcediaSystem.ini @@ -63,6 +63,28 @@ allowReplacingDamageTypes=true ; compatibility reasons), you should set this value at `BHIJ_Root`. broadcastHandlerInjectionLevel=BHIJ_Root +[AcediaCore.SchedulerAPI] +; How often can files be saved on disk. This is a relatively expensive +; operation and we don't want to write a lot of different files at once. +; But since we lack a way to exactly measure how much time that saving will +; take, AcediaCore falls back to simply performing every saving with same +; uniform time intervals in-between. +; This variable decides how much time there should be between two file +; writing accesses. +; Negative and zero values mean that all writing disk access will be +; granted as soon as possible, without any cooldowns. +diskSaveCooldown=0.25 +; Maximum total work units for jobs allowed per tick. Jobs are expected to be +; constructed such that they don't lead to a crash if they have to perform +; this much work. +; +; Changing default value of `10000` is not advised. +maxWorkUnits=10000 +; How many different jobs can be performed per tick. This limit is added so +; that `maxWorkUnits` won't be spread too thin if a lot of jobs get registered +; at once. +maxJobsPerTick=5 + [AcediaCore.UserAPI] userDataDBLink="local:database/users" diff --git a/sources/BaseRealm/API/Scheduler/API/MockJob.uc b/sources/BaseRealm/API/Scheduler/API/MockJob.uc new file mode 100644 index 0000000..120b539 --- /dev/null +++ b/sources/BaseRealm/API/Scheduler/API/MockJob.uc @@ -0,0 +1,44 @@ +/** + * Simple object that represents a job, capable of being scheduled on the + * `SchedulerAPI`. Use `IsCompleted()` to mark job as completed. + * Copyright 2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class MockJob extends SchedulerJob; + +var public string mark; +var public int unitsLeft; + +// We use `default` value only +var public string callStack; + +public function bool IsCompleted() +{ + return (unitsLeft <= 0); +} + +public function DoWork(int allottedWorkUnits) +{ + unitsLeft -= allottedWorkUnits; + if (IsCompleted()) { + default.callStack = default.callStack $ mark; + } +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/BaseRealm/API/Scheduler/API/TEST_SchedulerAPI.uc b/sources/BaseRealm/API/Scheduler/API/TEST_SchedulerAPI.uc new file mode 100644 index 0000000..89e2900 --- /dev/null +++ b/sources/BaseRealm/API/Scheduler/API/TEST_SchedulerAPI.uc @@ -0,0 +1,216 @@ +/** + * Set of tests for Scheduler API. + * Copyright 2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class TEST_SchedulerAPI extends TestCase + abstract; + +var int diskUses; + +protected static function UseDisk() +{ + default.diskUses += 1; +} + +protected static function MockJob MakeJob(string mark, int totalUnits) +{ + local MockJob newJob; + + newJob = MockJob(__().memory.Allocate(class'MockJob')); + newJob.mark = mark; + newJob.unitsLeft = totalUnits; + return newJob; +} + +protected static function TESTS() +{ + Test_MockJob(); +} + +protected static function Test_MockJob() +{ + Context("Testing job scheduling."); + SubText_SimpleScheduling(); + SubText_ManyScheduling(); + SubText_DiskScheduling(); + SubText_DiskSchedulingDeallocate(); + SubText_JobDiskMix(); +} + +protected static function SubText_SimpleScheduling() +{ + Issue("Simple scheduling doesn't process jobs in intended order"); + class'MockJob'.default.callStack = ""; + __().scheduler.ManualTick(); // Reset work units + __().scheduler.AddJob(MakeJob("A", 2400)); + __().scheduler.AddJob(MakeJob("B", 3000)); + __().scheduler.AddJob(MakeJob("C", 7600)); + __().scheduler.AddJob(MakeJob("D", 1000)); + __().scheduler.ManualTick(); // 10,000 units => -2,500 units per job + TEST_ExpectTrue(class'MockJob'.default.callStack == "AD"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 2); + __().scheduler.ManualTick(); // 10,000 units => -5,000 units per job + TEST_ExpectTrue(class'MockJob'.default.callStack == "ADB"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 1); + __().scheduler.ManualTick(); // 10,000 units => -5,000 units per job + TEST_ExpectTrue(class'MockJob'.default.callStack == "ADBC"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 0); +} + +protected static function SubText_ManyScheduling() +{ + Issue("After scheduling jobs over per-tick limit, scheduler doesn't process" + @ "jobs in intended order"); + class'MockJob'.default.callStack = ""; + __().scheduler.ManualTick(); // Reset work units + // 10,000 units => 2,000 units per job for 5 jobs + __().scheduler.AddJob(MakeJob("A", 3000)); + __().scheduler.AddJob(MakeJob("B", 3000)); + __().scheduler.AddJob(MakeJob("C", 3000)); + __().scheduler.AddJob(MakeJob("D", 1000)); + __().scheduler.AddJob(MakeJob("E", 3000)); + __().scheduler.AddJob(MakeJob("F", 3000)); + __().scheduler.AddJob(MakeJob("G", 1000)); + __().scheduler.AddJob(MakeJob("H", 5000)); + __().scheduler.AddJob(MakeJob("I", 1000)); + __().scheduler.ManualTick(); + // A:1000, B:1000, C:1000, D:0, E:1000, F:3000, G:1000, H:5000, I:1000 + TEST_ExpectTrue(class'MockJob'.default.callStack == "D"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 8); + __().scheduler.ManualTick(); + // A:0, B:1000, C:1000, D:0, E:1000, F:1000, G:0, H:3000, I:0 + TEST_ExpectTrue(class'MockJob'.default.callStack == "DGIA"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 5); + __().scheduler.ManualTick(); + // A:0, B:0, C:0, D:0, E:0, F:0, G:0, H:1000, I:0 + TEST_ExpectTrue(class'MockJob'.default.callStack == "DGIABCEF"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 1); + __().scheduler.ManualTick(); + // A:0, B:0, C:0, D:0, E:0, F:0, G:0, H:0, I:0 + TEST_ExpectTrue(class'MockJob'.default.callStack == "DGIABCEFH"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 0); +} + +protected static function SubText_DiskScheduling() +{ + local Text objectInstance; + + Issue("Disk scheduling doesn't happen at expected intervals."); + default.diskUses = 0; + __().scheduler.ManualTick(1.0); // Pre-fill cooldown, just in case + objectInstance = __().text.FromString("whatever"); + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + __().scheduler.ManualTick(0.001); + TEST_ExpectTrue(default.diskUses == 1); + __().scheduler.ManualTick(0.21); + TEST_ExpectTrue(default.diskUses == 1); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 3); + __().scheduler.ManualTick(0.2); + TEST_ExpectTrue(default.diskUses == 2); + __().scheduler.ManualTick(0.21); + TEST_ExpectTrue(default.diskUses == 2); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 2); + __().scheduler.ManualTick(0.2); + TEST_ExpectTrue(default.diskUses == 3); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 1); + __().scheduler.ManualTick(0.1); + TEST_ExpectTrue(default.diskUses == 3); + __().scheduler.ManualTick(0.1); + TEST_ExpectTrue(default.diskUses == 3); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 1); + __().scheduler.ManualTick(0.1); + TEST_ExpectTrue(default.diskUses == 4); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 0); +} + +protected static function SubText_DiskSchedulingDeallocate() +{ + local Text objectInstance, deletedInstance; + + Issue("Disk scheduling cannot correctly handle deallocated receivers."); + default.diskUses = 0; + __().scheduler.ManualTick(1.0); // Pre-fill cooldown, just in case + objectInstance = __().text.FromString("whatever"); + deletedInstance = __().text.FromString("heh"); + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + __().scheduler.RequestDiskAccess(deletedInstance).connect = UseDisk; + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + __().scheduler.RequestDiskAccess(deletedInstance).connect = UseDisk; + // Fuck off the `deletedInstance` object + deletedInstance.FreeSelf(); + // Test! + __().scheduler.ManualTick(0.001); + TEST_ExpectTrue(default.diskUses == 1); + __().scheduler.ManualTick(0.21); + TEST_ExpectTrue(default.diskUses == 1); + __().scheduler.ManualTick(0.2); + TEST_ExpectTrue(default.diskUses == 2); + __().scheduler.ManualTick(0.21); + TEST_ExpectTrue(default.diskUses == 2); + __().scheduler.ManualTick(0.2); + TEST_ExpectTrue(default.diskUses == 2); + __().scheduler.ManualTick(1.0); + TEST_ExpectTrue(default.diskUses == 2); +} + +protected static function SubText_JobDiskMix() +{ + local Text objectInstance; + + Issue("Job and disk scheduling doesn't happen at expected intervals."); + objectInstance = __().text.FromString("whatever"); + class'MockJob'.default.callStack = ""; + default.diskUses = 0; + __().scheduler.ManualTick(1.0); // Reset work units + // 0.2 * 10,000 = 2,000 units => 1,000 units per job for 2 jobs + __().scheduler.AddJob(MakeJob("A", 30000)); + __().scheduler.AddJob(MakeJob("B", 10000)); + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + __().scheduler.RequestDiskAccess(objectInstance).connect = UseDisk; + // Reset disk cooldown + __().scheduler.ManualTick(0.2); + // A:25000, B:5000 + TEST_ExpectTrue(default.diskUses == 1); + __().scheduler.ManualTick(0.2); // Disk on cooldown + // A:20000, B:0 + TEST_ExpectTrue(class'MockJob'.default.callStack == "B"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 1); + TEST_ExpectTrue(default.diskUses == 1); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 1); + __().scheduler.ManualTick(0.2); // Disk got off cooldown, do writing + // A:10000, B:0 + TEST_ExpectTrue(class'MockJob'.default.callStack == "B"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 1); + TEST_ExpectTrue(default.diskUses == 2); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 0); + __().scheduler.ManualTick(0.2); // Disk on cooldown + // A:0, B:0 + TEST_ExpectTrue(class'MockJob'.default.callStack == "BA"); + TEST_ExpectTrue(__().scheduler.GetJobsAmount() == 0); + TEST_ExpectTrue(default.diskUses == 2); + TEST_ExpectTrue(__().scheduler.GetDiskQueueSize() == 0); +} + +defaultproperties +{ + caseName = "SchedulerAPI" + caseGroup = "Scheduler" +} \ No newline at end of file diff --git a/sources/BaseRealm/API/Scheduler/SchedulerAPI.uc b/sources/BaseRealm/API/Scheduler/SchedulerAPI.uc new file mode 100644 index 0000000..161316f --- /dev/null +++ b/sources/BaseRealm/API/Scheduler/SchedulerAPI.uc @@ -0,0 +1,421 @@ +/** + * API that provides functions for scheduling jobs and expensive tasks such + * as writing onto the disk. Also provides methods for users to inform API that + * they've recently did an expensive operation, so that `SchedulerAPI` is to + * try and use less resources when managing jobs. + * Copyright 2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class SchedulerAPI extends AcediaObject + config(AcediaSystem); + +/** + * # `SchedulerAPI` + * + * UnrealScript is inherently single-threaded and whatever method you call, + * it will be completely executed within a single game's tick. + * This API is meant for scheduling various actions over time to help emulating + * multi-threading by spreading some code executions over several different + * game/server ticks. + * + * ## Usage + * + * ### Job scheduling + * + * One of the reasons which is faulty infinite loop detection system that + * will crash the game/server if it thinks UnrealScript code has executed too + * many operations (it is not about execution time, logging a lot of messages + * with `Log()` can take a lot of time and not crash anything, while simple + * loop, that would've finished much sooner, can trigger a crash). + * This is a very atypical problem for mods to have, but Acedia's + * introduction of databases and avarice link can lead to users trying to read + * (from database or network) an object that is too big, leading to a crash. + * Jobs are not about performance, they're about crash prevention. + * + * In case you have such a job of your own, that can potentially take too + * many steps to finish without crashing, you can convert it into + * a `SchedulerJob` (you make a subclass for your type of the job and + * instantiate it for each execution of the job). This requires you to + * restructure your algorithm in such a way, that it is able to run for some + * finite (maybe small) amount of steps and postpone the rest of calculations + * to the next tick and put it into a method + * `SchedulerJob.DoWork(int allottedWorkUnits)`, where `allottedWorkUnits` is + * how much your method is allowed to do during this call, assuming `10000` + * units of work on their own won't lead to a crash. + * Another method `SchedulerJob.IsCompleted()` needs to be setup to return + * `true` iff your job is done. + * After you prepared an instance of your job subclass, simply pass it to + * `_.scheduler.AddJob()`. + * + * ### Disk usage requests + * + * Writing to the disk (saving data into config file, saving local database + * changes) can be an expensive operation and to avoid lags in gameplay you + * might want to spread such operations over time. + * `_.scheduler.RequestDiskAccess()` method allows you to do that. It is not + * exactly a signal, but it acts similar to one: to request a right to save to + * the disk, just do the following: + * `_.scheduler.RequestDiskAccess().connect = ` + * and `disk_writing_method()` will be called once your turn come up. + * + * ## Manual ticking + * + * If any kind of level core (either server or client one) was created, + * this API will automatically perform necessary actions every tick. + * Otherwise, if only base API is available, there's no way to do that, but + * you can manually decide when to tick this API by calling `ManualTick()` + * method. + */ + +/** + * How often can files be saved on disk. This is a relatively expensive + * operation and we don't want to write a lot of different files at once. + * But since we lack a way to exactly measure how much time that saving will + * take, AcediaCore falls back to simply performing every saving with same + * uniform time intervals in-between. + * This variable decides how much time there should be between two file + * writing accesses. + * Negative and zero values mean that all writing disk access will be + * granted as soon as possible, without any cooldowns. + */ +var private config float diskSaveCooldown; +/** + * Maximum total work units for jobs allowed per tick. Jobs are expected to be + * constructed such that they don't lead to a crash if they have to perform + * this much work. + * + * Changing default value of `10000` is not advised. + */ +var private config int maxWorkUnits; +/** + * How many different jobs can be performed per tick. This limit is added so + * that `maxWorkUnits` won't be spread too thin if a lot of jobs get registered + * at once. + */ +var private config int maxJobsPerTick; + +// We can (and will) automatically tick +var private bool tickAvailable; +// `true` == it is safe to use server API for a tick +// `false` == it is safe to use client API for a tick +var private bool tickFromServer; +// Our `Tick()` method is currently connected to the `OnTick()` signal. +// Keeping track of this allows us to disconnect from `OnTick()` signal +// when it is not necessary. +var private bool connectedToTick; + +// How much time if left until we can write to the disk again? +var private float currentDiskCooldown; + +// There is a limit (`maxJobsPerTick`) to how many different jobs we can +// perform per tick and if we register an amount jobs over that limit, we need +// to uniformly spread execution time between them. +// To achieve that we simply cyclically (in order) go over `currentJobs` +// array, each time executing exactly `maxJobsPerTick` jobs. +// `nextJobToPerform` remembers what job is to be executed next tick. +var private int nextJobToPerform; +var private array currentJobs; +// Storing receiver objects, following example of signals/slots, is done +// without increasing their reference count, allowing them to get deallocated +// while we are still keeping their reference. +// To avoid using such deallocated receivers, we keep track of the life +// versions they've had when their disk requests were registered. +var private array diskQueue; +var private array receivers; +var private array receiversLifeVersions; + +/** + * Registers new scheduler job `newJob` to be executed in the API. + * + * @param newJob New job to be scheduled for execution. + * Does nothing if given `newJob` is already added. + */ +public function AddJob(SchedulerJob newJob) +{ + local int i; + + if (newJob == none) { + return; + } + for (i = 0; i < currentJobs.length; i += 1) + { + if (currentJobs[i] == newJob) { + return; + } + } + newJob.NewRef(); + currentJobs[currentJobs.length] = newJob; + UpdateTickConnection(); +} + +/** + * Requests another disk access. + * + * Use it like signal: `RequestDiskAccess().connect = `. + * Since it is meant to be used as a signal, so DO NOT STORE/RELEASE returned + * wrapper object `SchedulerDiskRequest`. + * + * @param receiver Same as for signal/slots, this is an object, responsible + * for the disk request. If this object gets deallocated - request will be + * thrown away. + * Typically this should be an object in which connected method will be + * executed. + * @return Wrapper object that provides `connect` delegate. + */ +public function SchedulerDiskRequest RequestDiskAccess(AcediaObject receiver) +{ + local SchedulerDiskRequest newRequest; + + if (receiver == none) return none; + if (!receiver.IsAllocated()) return none; + + newRequest = + SchedulerDiskRequest(_.memory.Allocate(class'SchedulerDiskRequest')); + diskQueue[diskQueue.length] = newRequest; + receivers[receivers.length] = receiver; + receiversLifeVersions[receiversLifeVersions.length] = + receiver.GetLifeVersion(); + UpdateTickConnection(); + return newRequest; +} + +/** + * Tells you how many incomplete jobs are currently registered in + * the scheduler. + * + * @return How many incomplete jobs are currently registered in the scheduler. + */ +public function int GetJobsAmount() +{ + CleanCompletedJobs(); + return currentJobs.length; +} + +/** + * Tells you how many disk access requests are currently registered in + * the scheduler. + * + * @return How many incomplete disk access requests are currently registered + * in the scheduler. + */ +public function int GetDiskQueueSize() +{ + CleanDiskQueue(); + return diskQueue.length; +} + +/** + * In case neither server, nor client core is registered, scheduler must be + * ticked manually. For that call this method each separate tick (or whatever + * is your closest approximation available for that). + * + * Before manually invoking this method, you should check if scheduler + * actually started to tick *automatically*. Use `_.scheduler.IsAutomated()` + * for that. + * + * NOTE: If neither server-/client- core is created, nor `ManualTick()` is + * invoked manually, `SchedulerAPI` won't actually do anything. + * + * @param delta Time (real one) that is supposedly passes from the moment + * `ManualTick()` was called last time. Used for tracking disk access + * cooldowns. How `SchedulerJob`s are executed is independent from this + * value. + */ +public final function ManualTick(optional float delta) +{ + Tick(delta, 1.0); +} + +/** + * Is scheduler ticking automated? It can only be automated if either + * server or client level cores are created. Scheduler can automatically enable + * automation and it cannot be prevented, but can be helped by using + * `UpdateTickConnection()` method. + * + * @return `true` if scheduler's tick is automatically called and `false` + * otherwise (and calling `ManualTick()` is required). + */ +public function bool IsAutomated() +{ + return tickAvailable; +} + +/** + * Causes `SchedulerAPI` to try automating itself by searching for level cores + * (checking if server/client APIs are enabled). + */ +public function UpdateTickConnection() +{ + local bool needsConnection; + local UnrealAPI api; + + if (!tickAvailable) + { + if (_server.IsAvailable()) + { + tickAvailable = true; + tickFromServer = true; + } + else if (_client.IsAvailable()) + { + tickAvailable = true; + tickFromServer = false; + } + if (!tickAvailable) { + return; + } + } + needsConnection = (currentJobs.length > 0 || diskQueue.length > 0); + if (connectedToTick == needsConnection) { + return; + } + if (tickFromServer) { + api = _server.unreal; + } + else { + api = _client.unreal; + } + if (connectedToTick && !needsConnection) { + api.OnTick(self).Disconnect(); + } + else if (!connectedToTick && needsConnection) { + api.OnTick(self).connect = Tick; + } + connectedToTick = needsConnection; +} + +private function Tick(float delta, float dilationCoefficient) +{ + delta = delta / dilationCoefficient; + // Manage disk cooldown + if (currentDiskCooldown > 0) { + currentDiskCooldown -= delta; + } + if (currentDiskCooldown <= 0 && diskQueue.length > 0) + { + currentDiskCooldown = diskSaveCooldown; + ProcessDiskQueue(); + } + // Manage jobs + if (currentJobs.length > 0) { + ProcessJobs(); + } + UpdateTickConnection(); +} + +private function ProcessJobs() +{ + local int unitsPerJob; + local int jobsToPerform; + + CleanCompletedJobs(); + jobsToPerform = Min(currentJobs.length, maxJobsPerTick); + if (jobsToPerform <= 0) { + return; + } + unitsPerJob = maxWorkUnits / jobsToPerform; + while (jobsToPerform > 0) + { + if (nextJobToPerform >= currentJobs.length) { + nextJobToPerform = 0; + } + currentJobs[nextJobToPerform].DoWork(unitsPerJob); + nextJobToPerform += 1; + jobsToPerform -= 1; + } +} + +private function ProcessDiskQueue() +{ + local int i; + + // Even if we clean disk queue here, we still need to double check + // lifetimes in the code below, since we have no idea what `.connect()` + // calls might do + CleanDiskQueue(); + if (diskQueue.length <= 0) { + return; + } + if (diskSaveCooldown > 0) + { + if (receivers[i].GetLifeVersion() == receiversLifeVersions[i]) { + diskQueue[i].connect(); + } + _.memory.Free(diskQueue[0]); + diskQueue.Remove(0, 1); + receivers.Remove(0, 1); + receiversLifeVersions.Remove(0, 1); + return; + } + for (i = 0; i < diskQueue.length; i += 1) + { + if (receivers[i].GetLifeVersion() == receiversLifeVersions[i]) { + diskQueue[i].connect(); + } + _.memory.Free(diskQueue[i]); + } + diskQueue.length = 0; + receivers.length = 0; + receiversLifeVersions.length = 0; +} + +// Removes completed jobs +private function CleanCompletedJobs() +{ + local int i; + + while (i < currentJobs.length) + { + if (currentJobs[i].IsCompleted()) + { + if (i < nextJobToPerform) { + nextJobToPerform -= 1; + } + currentJobs[i].FreeSelf(); + currentJobs.Remove(i, 1); + } + else { + i += 1; + } + } +} + +// Remove disk requests with deallocated receivers +private function CleanDiskQueue() +{ + local int i; + + while (i < diskQueue.length) + { + if (receivers[i].GetLifeVersion() == receiversLifeVersions[i]) + { + i += 1; + continue; + } + _.memory.Free(diskQueue[i]); + diskQueue.Remove(i, 1); + receivers.Remove(i, 1); + receiversLifeVersions.Remove(i, 1); + } +} + +defaultproperties +{ + diskSaveCooldown = 0.25 + maxWorkUnits = 10000 + maxJobsPerTick = 5 +} \ No newline at end of file diff --git a/sources/BaseRealm/API/Scheduler/SchedulerDiskRequest.uc b/sources/BaseRealm/API/Scheduler/SchedulerDiskRequest.uc new file mode 100644 index 0000000..06a09e0 --- /dev/null +++ b/sources/BaseRealm/API/Scheduler/SchedulerDiskRequest.uc @@ -0,0 +1,29 @@ +/** + * Slot-like object that represents a request for a writing disk access, + * capable of being scheduled on the `SchedulerAPI`. + * Copyright 2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class SchedulerDiskRequest extends AcediaObject; + +delegate connect() +{ +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/BaseRealm/API/Scheduler/SchedulerJob.uc b/sources/BaseRealm/API/Scheduler/SchedulerJob.uc new file mode 100644 index 0000000..b1fc45c --- /dev/null +++ b/sources/BaseRealm/API/Scheduler/SchedulerJob.uc @@ -0,0 +1,47 @@ +/** + * Template object that represents a job, capable of being scheduled on the + * `SchedulerAPI`. Use `IsCompleted()` to mark job as completed. + * Copyright 2022 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class SchedulerJob extends AcediaObject + abstract; + +/** + * Checks if caller `SchedulerJob` was completed. + * Once this method returns `true`, it shouldn't start returning `false` again. + * + * @return `true` if `SchedulerJob` is already completed and doesn't need to + * be further executed and `false` otherwise. + */ +public function bool IsCompleted(); + +/** + * Called when scheduler decides that `SchedulerJob` should be executed, taking + * amount of abstract "work units" that it is allowed to spend for work. + * + * @param allottedWorkUnits Work units allotted to the caller + * `SchedulerJob`. By default there is `10000` work units per second, so + * you can expect about 10000 / 1000 = 10 work units per millisecond or, + * on servers with 30 tick rate, about 10000 * (30 / 1000) = 300 work units + * per tick to be allotted to all the scheduled jobs. + */ +public function DoWork(int allottedWorkUnits); + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/BaseRealm/Global.uc b/sources/BaseRealm/Global.uc index 26b5e5b..238c021 100644 --- a/sources/BaseRealm/Global.uc +++ b/sources/BaseRealm/Global.uc @@ -39,6 +39,7 @@ var public UserAPI users; var public PlayersAPI players; var public JSONAPI json; var public DBAPI db; +var public SchedulerAPI scheduler; var public AvariceAPI avarice; var public AcediaEnvironment environment; @@ -74,6 +75,7 @@ protected function Initialize() players = PlayersAPI(memory.Allocate(class'PlayersAPI')); json = JSONAPI(memory.Allocate(class'JSONAPI')); db = DBAPI(memory.Allocate(class'DBAPI')); + scheduler = SchedulerAPI(memory.Allocate(class'SchedulerAPI')); avarice = AvariceAPI(memory.Allocate(class'AvariceAPI')); environment = AcediaEnvironment(memory.Allocate(class'AcediaEnvironment')); } @@ -94,6 +96,7 @@ public function DropCoreAPI() players = none; json = none; db = none; + scheduler = none; avarice = none; default.myself = none; } \ No newline at end of file diff --git a/sources/ClientRealm/ClientLevelCore.uc b/sources/ClientRealm/ClientLevelCore.uc index 850624a..4c97d47 100644 --- a/sources/ClientRealm/ClientLevelCore.uc +++ b/sources/ClientRealm/ClientLevelCore.uc @@ -29,6 +29,7 @@ public simulated static function LevelCore CreateLevelCore(Actor source) if (newCore != none) { __client().ConnectClientLevelCore(); } + __().scheduler.UpdateTickConnection(); return newCore; } diff --git a/sources/CoreRealm/CoreGlobal.uc b/sources/CoreRealm/CoreGlobal.uc index 64eddbc..0ed413b 100644 --- a/sources/CoreRealm/CoreGlobal.uc +++ b/sources/CoreRealm/CoreGlobal.uc @@ -56,6 +56,21 @@ protected function Initialize() time = TimeAPI(api.Allocate(adapterClass.default.timeAPIClass)); } +/** + * Checks is caller `CoreGlobal` is available to be used. + * + * Server and client `CoreGlobal` instances are always created, so that they + * can be added to `AcediaObject`s and `AcediaActor`s at any time, even before + * they were initialized (whether they ever will be or not). This method + * allows one to check whether they were already initialized and can be used. + * + * @return `true` if caller `CoreGlobal` can be used and `false` otherwise. + */ +public function bool IsAvailable() +{ + return initialized; +} + /** * Changes adapter class for the caller `...Global` instance. * diff --git a/sources/Data/Database/DBAPI.uc b/sources/Data/Database/DBAPI.uc index df5f038..5865051 100644 --- a/sources/Data/Database/DBAPI.uc +++ b/sources/Data/Database/DBAPI.uc @@ -1,7 +1,7 @@ /** * API that provides methods for creating/destroying and managing available * databases. - * Copyright 2021 Anton Tarasenko + * Copyright 2021-2022 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -57,6 +57,7 @@ public final function Database Load(BaseText databaseLink) local Database result; local Text immutableDatabaseName; local MutableText databaseName; + if (databaseLink == none) { return none; } @@ -99,6 +100,7 @@ public final function JSONPointer GetPointer(BaseText databaseLink) local int slashIndex; local Text textPointer; local JSONPointer result; + if (databaseLink == none) { return none; } @@ -167,6 +169,7 @@ public final function LocalDatabaseInstance LoadLocal(BaseText databaseName) local Text rootRecordName; local LocalDatabase newConfig; local LocalDatabaseInstance newLocalDBInstance; + if (databaseName == none) { return none; } @@ -224,7 +227,6 @@ public final function bool ExistsLocal(BaseText databaseName) return result; } -// TODO: deleted database must be marked as disposed + change tests too /** * Deletes local database with name `databaseName`. * @@ -237,6 +239,7 @@ public final function bool DeleteLocal(BaseText databaseName) local LocalDatabase localDatabaseConfig; local LocalDatabaseInstance localDatabase; local HashTable.Entry dbEntry; + if (databaseName == none) { return false; } @@ -246,6 +249,7 @@ public final function bool DeleteLocal(BaseText databaseName) if (localDatabase != none) { localDatabaseConfig = localDatabase.GetConfig(); + localDatabase.WriteToDisk(); _.memory.Free(localDatabase); } dbEntry = loadedLocalDatabases.TakeEntry(databaseName); @@ -270,6 +274,7 @@ private function EraseAllPackageData(BaseText packageToErase) local GameInfo game; local DBRecord nextRecord; local array allRecords; + packageName = _.text.ToString(packageToErase); if (packageName == "") { return; @@ -299,6 +304,7 @@ public final function array ListLocal() local int i; local array dbNames; local array dbNamesAsStrings; + dbNamesAsStrings = GetPerObjectNames( "AcediaDB", string(class'LocalDatabase'.name), MaxInt); diff --git a/sources/Data/Database/Local/LocalDatabaseInstance.uc b/sources/Data/Database/Local/LocalDatabaseInstance.uc index a11e5fd..c815afd 100644 --- a/sources/Data/Database/Local/LocalDatabaseInstance.uc +++ b/sources/Data/Database/Local/LocalDatabaseInstance.uc @@ -72,14 +72,9 @@ var private LocalDatabase configEntry; // Reference to the `DBRecord` that stores root object of this database var private DBRecord rootRecord; -// As long as this `Timer` runs - we are in the "cooldown" period where no disk -// updates can be done (except special cases like this object getting -// deallocated). -var private Timer diskUpdateTimer; -// Only relevant when `diskUpdateTimer` is running. `false` would mean there is -// nothing to new to write and the timer will be discarded, but `true` means -// that we have to write database on disk and restart the update timer again. -var private bool needsDiskUpdate; +// Remembers whether we've made a request for the disk access to the scheduler, +// to avoid sending multiple ones. +var private bool pendingDiskUpdate; // Last to-be-completed task added to this database var private DBTask lastTask; @@ -99,8 +94,6 @@ protected function Finalizer() WriteToDisk(); rootRecord = none; _server.unreal.OnTick(self).Disconnect(); - _.memory.Free(diskUpdateTimer); - diskUpdateTimer = none; configEntry = none; } @@ -116,39 +109,23 @@ private final function CompleteAllTasks( lastTaskLifeVersion = -1; } -private final function LocalDatabaseInstance ScheduleDiskUpdate() +private final function ScheduleDiskUpdate() { - if (diskUpdateTimer != none) + if (!pendingDiskUpdate) { - needsDiskUpdate = true; - return self; + pendingDiskUpdate = true; + _.scheduler.RequestDiskAccess(self).connect = WriteToDisk; } - WriteToDisk(); - needsDiskUpdate = false; - diskUpdateTimer = _server.time.StartTimer( - class'LocalDBSettings'.default.writeToDiskDelay); - diskUpdateTimer.OnElapsed(self).connect = DoDiskUpdate; - return self; } -private final function DoDiskUpdate(Timer source) -{ - if (needsDiskUpdate) - { - WriteToDisk(); - needsDiskUpdate = false; - diskUpdateTimer.Start(); - } - else - { - _.memory.Free(diskUpdateTimer); - diskUpdateTimer = none; - } -} - -private final function WriteToDisk() +public final function WriteToDisk() { local string packageName; + + if (!pendingDiskUpdate) { + return; + } + pendingDiskUpdate = false; if (configEntry != none) { packageName = _.text.ToString(configEntry.GetPackageName()); } diff --git a/sources/Manifest.uc b/sources/Manifest.uc index 524593e..9c43224 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -1,6 +1,6 @@ /** * Manifest is meant to describe contents of the Acedia's package. - * Copyright 2020 - 2022 Anton Tarasenko + * Copyright 2020-2022 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -53,9 +53,10 @@ defaultproperties testCases(21) = class'TEST_Command' testCases(22) = class'TEST_CommandDataBuilder' testCases(23) = class'TEST_LogMessage' - testCases(24) = class'TEST_DatabaseCommon' - testCases(25) = class'TEST_LocalDatabase' - testCases(26) = class'TEST_AcediaConfig' - testCases(27) = class'TEST_UTF8EncoderDecoder' - testCases(28) = class'TEST_AvariceStreamReader' + testCases(24) = class'TEST_SchedulerAPI' + testCases(25) = class'TEST_DatabaseCommon' + testCases(26) = class'TEST_LocalDatabase' + testCases(27) = class'TEST_AcediaConfig' + testCases(28) = class'TEST_UTF8EncoderDecoder' + testCases(29) = class'TEST_AvariceStreamReader' } \ No newline at end of file diff --git a/sources/ServerRealm/ServerLevelCore.uc b/sources/ServerRealm/ServerLevelCore.uc index abc7597..90fe938 100644 --- a/sources/ServerRealm/ServerLevelCore.uc +++ b/sources/ServerRealm/ServerLevelCore.uc @@ -31,6 +31,7 @@ public static function LevelCore CreateLevelCore(Actor source) if (newCore != none) { __server().ConnectServerLevelCore(); } + __().scheduler.UpdateTickConnection(); return newCore; }