Persistent data framework (#21055)

# Summary

This PR has the goal of adding a base framework for tracking, saving and
otherwise managing persistent data between rounds, written from scratch.

Persistent data can be anything - Most expectedly noticeboards, dirt,
papers, etc.

_Technical details about the PR below are subject to change._

## Estimated scope

The first iteration of the persistence framework should include the
functioning subsystem and the first implementation of a persistent type
using said subsystem.

- [x] Prepare database for persistent data
- [x] Add new features to `/obj/`
- [x] Logic to get and load persistent data into objects for the
subsystem
  - [x] Internal logic for tracking and other
- [x] Implement subsystem
  - [x] Basics, files, integration of subsystem
  - [x] Database queries for different operations
- [x] Methods for registering and de-registering object tracking for
`/obj`
  - [x] Round start logic
  - [x] Round end logic
- [x] Implement first persistent type: Papers on notice boards
- [ ] Code review
  - [x] Scope validation
  - [x] Testing
  - [x] Full changelog
- Merge
- [ ] Setup logging config for new subsystem
- [x] Documentation for developers ([How to add new persistent
types](https://github.com/Aurorastation/Aurora.3/wiki/Persistence))

## Inner workings

The new persistence subsystem has the following concept:
At round start the subsystem reads existing data from the database and
creates objects for them.
During the round objects can be added to the tracking using a register
function (or removed by a de-register function).
At round end the subsystem goes over all it's actively tracked objects:
Create new objects (that don't have a persistent ID from the database),
update objects that changed during the round and expire objects that are
no longer tracked.

The objects in questions can register themselves, de-register themselves
and decide themselves what content should be stored and handle how to
set themselves up during the subsystems init phase.

## Information on the new table

`id` - database generated ID for tracking persistent objects over
multiple rounds, objects without an ID are considered new and created
during the round.
`author_ckey` - With the first implementation unused, but expected to be
required in the future to allow any staff/moderation on persistent data.
Nullable as persistent data also can be things like decals without an
actual owner.
`type` - Type of persistent data used for creating objects at round
start.
`created_at` - Statistical values.
`expire_at` - Used for cleaning the database and setting limits for
persistent data types. It's optional and allows permanent persistent
data (command notice boards?).
`content` - JSON formatted data containing the actual properties of an
individual tracked object (e.g. what's written on a paper, what's its
title).
`x`, `y`, `z` - Coordinates taken and given from/to the object during
save/load. Those can be null for future purposes.

## The goal on how to add new persistent data types

`/obj` received two new methods which need to be overriden in the to-be
added new persistent type:
`persistence_get_content()` and `persistence_apply_content(content, x,
y, z)`.

The first method has the responsibility to provide all custom content of
a new type to the subsystem in the form of an associated list (`["title"
= "Hello, World!", "value" = 1337].

What does that mean?  
In the example of a piece of paper, the method needs to put the papers
title and text content into a an associated list, that list will be
saved by the persistence subsystem.

The second method has the responsibility to apply the previously saved
content when persistent data is initialized at the start of the round:
The associated list needs to be read back and applied to the paper,
additionally coordinates are provided for the object that have been
taken during saving. Note that the coordinates might be null and could
be ignored during setup.

During the round, when a new object is created, like a paper, you need
to call the subsystem and register a track:
`SSpersistence.register_track(your_object_to_track, ckey (optional)`.
The subsystem will be using the methods above when the round ends and
saves the object - and loads it at the start of the round using the
second method. The ckey needs to be given for all persistent that
contains user generated content, e.g. papers.

In case a persistent object gets removed, you need to call
`SSpersistence.deregister_track(your_tracked_object)`. At the end of the
round the object will be removed from the storage.

`/obj` contains the variable (along some other technical vars)
`persistance_initial_expiration_time_days`, which has a default value of
30 days, but can be safely overriden on a per-type basis. This value (in
days) will be used for the persistent data entry expiration date when a
new object of said type is stored in the database.

## PR description changelog

- Added *The goal on how to add new persistent data types*.
- Updates *Estimated scope* format.
- Updates *Inner workings*.
- Final iteration.

*(Grammer and formatting not tracked manually)*
This commit is contained in:
FabianK3
2025-08-17 00:00:12 +02:00
committed by GitHub
parent fb4e695181
commit 36d267d83e
14 changed files with 421 additions and 2 deletions
+2
View File
@@ -62,6 +62,7 @@ GLOBAL_LIST_EMPTY(gamemode_cache)
"log_subsystems_ghostroles" = TRUE, // Ghost Roles
"log_subsystems_law" = TRUE, // Law
"log_subsystems_cargo" = TRUE, // Cargo
"log_subsystems_persistence" = TRUE, // Persistence
"log_subsystems_documents" = TRUE, // Documents
"log_subsystems_fail2topic" = TRUE, // Fail2Topic
"log_subsystems_mapfinalization" = TRUE, // Map Finalization
@@ -120,6 +121,7 @@ GLOBAL_LIST_EMPTY(gamemode_cache)
"world_subsystems_ghostroles_log" = "subsystems/ghostroles.log",
"world_subsystems_law_log" = "subsystems/law.log",
"world_subsystems_cargo_log" = "subsystems/cargo.log",
"world_subsystems_persistence_log" = "subsystems/persistence.log",
"world_subsystems_documents_log" = "subsystems/documents.log",
"world_subsystems_fail2topic_log" = "subsystems/fail2topic.log",
"world_subsystems_mapfinalization_log" = "subsystems/mapfinalization.log",
+310
View File
@@ -0,0 +1,310 @@
SUBSYSTEM_DEF(persistence)
name = "Persistence"
init_order = INIT_ORDER_PERSISTENCE
flags = SS_NO_FIRE
/*#############################################
Internal methods
#############################################*/
/**
* Initialization of the persistence subsystem. Initialization includes loading all persistent data and spawning the related objects.
*/
/datum/controller/subsystem/persistence/Initialize()
. = ..()
try
if(!GLOB.config.sql_enabled)
log_subsystem_persistence("SQL configuration not enabled! Persistence subsystem requires SQL.")
return SS_INIT_SUCCESS
GLOB.persistence_register = list()
if(!SSdbcore.Connect())
log_subsystem_persistence("SQL ERROR during persistence subsystem init. Failed to connect.")
return SS_INIT_FAILURE
else
// Delete all persistent objects in the database that have expired and have passed the cleanup grace period (PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS)
database_clean_entries()
// Retrieve all persistent data that is not expired
var/list/persistent_data = database_get_active_entries()
log_subsystem_persistence("Init: Retrieved [persistent_data.len] entries for instancing this round.")
// Instantiate all remaining entries based of their type
// Assign persistence related vars found in /obj, apply content and add to live tracking list.
for (var/data in persistent_data)
CHECK_TICK
var/typepath = text2path(data["type"])
if (!ispath(typepath)) // Type checking
continue
var/obj/instance = new typepath()
instance.persistence_track_id = data["id"]
track_apply_content(instance, data["content"], data["x"], data["y"], data["z"])
register_track(instance, data["author_ckey"])
return SS_INIT_SUCCESS
catch(var/exception/e)
log_subsystem_persistence("Panic: Exception during subsystem initialize: [e]")
return SS_INIT_FAILURE
/**
* Shutdown of the persistence subsystem. Adds new persistent objects, removes no longer existing persistent objects and updates changed persistent objects in the database.
*/
/datum/controller/subsystem/persistence/Shutdown()
try
if(!GLOB.config.sql_enabled)
log_subsystem_persistence("SQL configuration not enabled! Panic - Cannot save current round track changes!")
return
// Subsystem shutdown:
// Create new persistent records for objects that have been created in the round
// Update tracked objects that have an ID (already existing from previous rounds)
// Delete persistent records that no longer exist in the registry (removed during the round)
var/created = 0
var/updated = 0
var/expired = 0
// Get already stored data before saving new tracks so we can compare what has been updated or removed during the round.
var/list/existing_data = database_get_active_entries()
for (var/obj/track in GLOB.persistence_register)
CHECK_TICK
if (track.persistence_track_id == 0)
// Tracked object has no ID meaning it is new, create a new persistent record for it
database_add_entry(track)
created++
// Find tracks that have been removed during the round by trying to find the track by database ID
// If we find the track, we need to check if it requires an update instead
for (var/record in existing_data)
var/found = FALSE
for (var/obj/track in GLOB.persistence_register)
CHECK_TICK
if (record["id"] == track.persistence_track_id)
// A track with the same ID has been found in the register, it still exists, check if we need to update it instead
found = TRUE // Prevent expiration of track
var/changed = FALSE
var/turf/T = get_turf(track)
if (T && T.x != record["x"])
changed = TRUE
else if (T && T.y != record["y"])
changed = TRUE
else if (T && T.z != record["z"])
changed = TRUE
else if (track_get_content(track) != record["content"])
changed = TRUE
if (changed)
database_update_entry(track)
updated++
break // Track found (and perhaps updated), break off loop search as it won't need to be deleted anyways
if (!found)
// No track with the same ID has been found in the register, remove it from the database (expire)
database_expire_entry(record["id"])
expired++
log_subsystem_persistence("Shutdown: Tried to create [created], update [updated] and expire [expired] tracks.")
catch(var/exception/e)
log_subsystem_persistence("Panic: Exception during subsystem shutdown: [e]")
return
/**
* Generates StatEntry. Returns information about currently tracked objects.
*/
/datum/controller/subsystem/persistence/stat_entry(msg)
msg = ("Global register tracks: [GLOB.persistence_register.len]")
return ..()
/**
* Safely get JSON persistent content of track.
* RETURN: JSON formatted content of track or null if an exception occured.
*/
/datum/controller/subsystem/persistence/proc/track_get_content(var/obj/track)
var/result = null
try
result = json_encode(track.persistence_get_content())
catch(var/exception/e)
log_subsystem_persistence("Track: Failed to get/encode track content: [e]")
return result
/**
* Safely apply persistent content to track.
* PARAMS:
* track = Object to apply content to.
* json = Custom persistent content JSON to be applied.
* x,y,z = x-y-z coordinates of object, can be null.
*/
/datum/controller/subsystem/persistence/proc/track_apply_content(var/obj/track, var/json, var/x, var/y, var/z)
try
track.persistence_apply_content(json_decode(json), x, y, z)
catch(var/exception/e)
log_subsystem_persistence("Track: Failed to apply/decode track content: [e]")
/**
* Run cleanup on the persistence entries in the database.
* Cleanup includes all entries that have expired and have passed the clean up grace period (PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS).
*/
/datum/controller/subsystem/persistence/proc/database_clean_entries()
if(!SSdbcore.Connect())
log_subsystem_persistence("SQL ERROR during persistence database_clean_entries. Failed to connect.")
else
var/datum/db_query/cleanup_query = SSdbcore.NewQuery(
"DELETE FROM ss13_persistent_data WHERE DATE_ADD(expires_at, INTERVAL :grace_period_days DAY) <= NOW()",
list("grace_period_days" = PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS)
)
cleanup_query.SetFailCallback(CALLBACK(PROC_REF(database_clean_entries_callback_failure)))
cleanup_query.SetSuccessCallback(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(qdel)))
cleanup_query.ExecuteNoSleep(TRUE)
/datum/controller/subsystem/persistence/proc/database_clean_entries_callback_failure(var/datum/db_query/cleanup_query)
if (cleanup_query.ErrorMsg())
log_subsystem_persistence("SQL ERROR during persistence database_clean_entries. " + cleanup_query.ErrorMsg())
qdel(cleanup_query)
/**
* Retrieve persistent data entries that haven't expired.
* RETURN: List of JSON, with ID, author_ckey, type, content, x, y, z
*/
/datum/controller/subsystem/persistence/proc/database_get_active_entries()
if(!SSdbcore.Connect())
log_subsystem_persistence("SQL ERROR during persistence database_get_active_entries. Failed to connect.")
else
var/datum/db_query/get_query = SSdbcore.NewQuery(
"SELECT id, author_ckey, type, content, x, y, z FROM ss13_persistent_data WHERE NOW() < expires_at"
)
get_query.Execute()
var/list/results = list()
if (get_query.ErrorMsg())
log_subsystem_persistence("SQL ERROR during persistence database_get_active_entries. " + get_query.ErrorMsg())
return
else
while (get_query.NextRow())
CHECK_TICK
var/list/entry = list(
"id" = get_query.item[1],
"author_ckey" = get_query.item[2],
"type" = get_query.item[3],
"content" = get_query.item[4],
"x" = get_query.item[5],
"y" = get_query.item[6],
"z" = get_query.item[7]
)
results += list(entry)
qdel(get_query)
return results
/**
* Adds a persistent data record to the database.
*/
/datum/controller/subsystem/persistence/proc/database_add_entry(var/obj/track)
if(!SSdbcore.Connect())
log_subsystem_persistence("SQL ERROR during persistence database_add_entry. Failed to connect.")
else
var/content = track_get_content(track)
if (!content)
return
var/turf/T = get_turf(track)
if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support
return
var/datum/db_query/insert_query = SSdbcore.NewQuery(
"INSERT INTO ss13_persistent_data (author_ckey, type, created_at, expires_at, content, x, y, z) \
VALUES (:author_ckey, :type, NOW(), DATE_ADD(NOW(), INTERVAL :expire_in_days DAY), :content, :x, :y, :z)",
list(
"author_ckey" = track.persistence_author_ckey,
"type" = "[track.type]",
"expire_in_days" = track.persistance_expiration_time_days,
"content" = content,
"x" = T.x,
"y" = T.y,
"z" = T.z
)
)
insert_query.Execute()
if (insert_query.ErrorMsg())
log_subsystem_persistence("SQL ERROR during persistence database_add_entry. " + insert_query.ErrorMsg())
qdel(insert_query)
/**
* Updates a persistent data record in the database.
*/
/datum/controller/subsystem/persistence/proc/database_update_entry(var/obj/track)
if(!SSdbcore.Connect())
log_subsystem_persistence("SQL ERROR during persistence database_update_entry. Failed to connect.")
else
var/content = track_get_content(track)
if (!content)
return
var/turf/T = get_turf(track)
if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support
return
var/datum/db_query/update_query = SSdbcore.NewQuery(
"UPDATE ss13_persistent_data SET author_ckey=:author_ckey, expires_at=DATE_ADD(NOW(), INTERVAL :expire_in_days DAY), content=:content, x=:x, y=:y, z=:z WHERE id = :id",
list(
"author_ckey" = track.persistence_author_ckey,
"expire_in_days" = track.persistance_expiration_time_days,
"content" = content,
"x" = T.x,
"y" = T.y,
"z" = T.z,
"id" = track.persistence_track_id
)
)
update_query.Execute()
if (update_query.ErrorMsg())
log_subsystem_persistence("SQL ERROR during persistence database_update_entry. " + update_query.ErrorMsg())
qdel(update_query)
/**
* Expire a persistent data record in the database by setting it's expiration date to now.
*/
/datum/controller/subsystem/persistence/proc/database_expire_entry(var/track_id)
if(!SSdbcore.Connect())
log_subsystem_persistence("SQL ERROR during persistence database_expire_entry. Failed to connect.")
else
var/datum/db_query/expire_query = SSdbcore.NewQuery(
"UPDATE ss13_persistent_data SET expires_at=NOW() WHERE id = :id",
list("id" = track_id)
)
expire_query.Execute()
if (expire_query.ErrorMsg())
log_subsystem_persistence("SQL ERROR during persistence database_expire_entry. " + expire_query.ErrorMsg())
qdel(expire_query)
/*#############################################
Public methods
#############################################*/
/**
* Adds the given object to the list of tracked objects. At shutdown the tracked object will be either created or updated in the database.
* The ckey is an optional argument and is used for tracking user generated content by adding an author to the persistent data.
*/
/datum/controller/subsystem/persistence/proc/register_track(var/obj/new_track, var/ckey)
if(new_track.persistence_track_active) // Prevent multiple registers per object and removes the need to check the register if it's already in there
return
var/turf/T = get_turf(new_track)
if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support
return
new_track.persistence_track_active = TRUE
new_track.persistence_author_ckey = ckey
GLOB.persistence_register += new_track
/**
* Removes the given object from the list of tracked objects. At shutdown the tracked object will be remove from the database.
*/
/datum/controller/subsystem/persistence/proc/deregister_track(var/obj/old_track)
if(!old_track.persistence_track_active) // Prevent multiple deregisters per object and removes the need to check the register if it's not in there
return
old_track.persistence_track_active = FALSE
GLOB.persistence_register -= old_track