added OpenSimSceneSnapshot Module

This commit is contained in:
Lordfox
2025-10-05 14:33:57 +02:00
parent 1b063c297b
commit 7481b7b6f0
5 changed files with 563 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
# OpenSim SceneSnapshot Module
## What is it?
SceneSnapshot is a lightweight OpenSim region module that creates automatic region snapshots (OAR files), keeps them tidy with a retention policy, and lets you restore them quickly from the OpenSim console.
It integrates directly with the running simulator:
- no extra cron/supervisor scripts
- no racy console automation
- consistent snapshots via the Archiver API
## Why this module (vs. cron/supervisor)?
While cron jobs or external scripts can call “save oar”, this module offers:
- In-sim consistency: Uses the archiver API in-process, reducing timing issues while content is being modified.
- Simple retention: Keeps the latest hourly snapshots and one per day automatically.
- One command surface: Create, list, prune, restore directly from the region console.
- Atomic writes and cleanup: Writes to a temp file first and cleans up stale files on start.
## Features
- Auto snapshots at configurable intervals
- Retention policy: keep N hourly, plus one per day for D days
- Console commands: create, list, prune, restore
- Restore modes:
- Replace (default): restore entire OAR
- Merge: merge objects without typically overwriting terrain/parcels
## Installation
- Place the module in your OpenSim addon-modules (or build it as part of your solution).
- Enable it in OpenSim.ini (or your region config):
## Usage (Console)
| Command |Description|
|-----------------------------|---|
| ss create |Create a snapshot immediately.|
| ss list |List available snapshots (most recent first).|
| ss restore [file] or latest |Restore a snapshot by file name or the latest snapshot. Uses RestoreMode from config (Replace or Merge).|
| ss prune |Delete snapshots according to the retention policy.|
| ss status |Show current settings and counts.|
Snapshot filenames:
- RegionName_yyyyMMdd-HHmmss.oar, stored under SnapshotDirectory.
Example:
- ss restore latest
## Configuration
Enable the module:
add this part to OpenSim.ini [Modules] section
~~~
[Modules]
SceneSnapshot = enabled
Include-SceneSnapshot = "config-include/SceneSnapshot.ini"
~~~
Example settings (config-include/SceneSnapshot.ini):
~~~
[SceneSnapshot]
SnapshotDirectory = snapshots
IntervalMinutes = 60
KeepHourly = 24
KeepDaily = 7
AutoAtStartup = false
AutoAtShutdown = true
; Replace: OAR replaces the scene (default)
; Merge: OAR objects are merged; terrain/parcels are typically preserved
RestoreMode = Replace
~~~
## Who is it for?
- Grid/region operators wanting automated, reliable, and self-contained backups without external orchestration.
- Operators who value in-sim retention policies and easy restore flows during maintenance or testing.
- Anyone who prefers a minimal and robust solution that just works with the built-in archiver.
## Notes
- The module saves full OARs (including assets).
- Selective restore of terrain or parcels only is not supported by the underlying archiver. Use Merge to avoid overwriting terrain/parcels.
- Atomic writes ensure that incomplete files wont appear; stale .tmp files are cleaned up on startup.
@@ -0,0 +1,438 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Timers;
using log4net;
using Mono.Addins;
using Nini.Config;
using OpenSim.Framework;
using OpenSim.Framework.Console;
using OpenSim.Region.Framework.Interfaces;
using OpenSim.Region.Framework.Scenes;
[assembly: Addin("SceneSnapshot", "1.0.0")]
[assembly: AddinDependency("OpenSim.Region.Framework", OpenSim.VersionInfo.VersionNumber)]
[assembly: AddinDescription("Lightweight scene snapshot module (auto OAR snapshots + list/prune/restore).")]
[assembly: AddinAuthor("Christopher Händler")]
namespace OpenSimSceneSnapshot.Modules
{
[Extension(Path = "/OpenSim/RegionModules", NodeName = "RegionModule", Id = "SceneSnapshot")]
public class SceneSnapshotModule : INonSharedRegionModule
{
private static readonly ILog Log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
private Scene _scene;
private bool _enabled;
// Config
private string _snapshotDir = "snapshots";
private int _intervalMinutes = 60;
private int _keepHourly = 24;
private int _keepDaily = 7;
// Restore mode: Replace (default) or Merge (protects existing terrain/parcels typically)
private string _restoreMode = "Replace";
private bool _autoAtStartup = false;
private bool _autoAtShutdown = false;
private Timer _timer;
private int _autoOpGuard; // guard for timer-triggered operations
private int _opInProgress; // guard for any snapshot/restore operation
public string Name => "SceneSnapshot";
public Type ReplaceableInterface => null;
public void Initialise(IConfigSource source)
{
var modules = source.Configs["Modules"];
if (modules == null)
return;
var status = modules.GetString("SceneSnapshot", string.Empty);
if (!string.Equals(status, "enabled", StringComparison.OrdinalIgnoreCase))
return;
var cfg = source.Configs["SceneSnapshot"] ?? source.AddConfig("SceneSnapshot");
_snapshotDir = Path.GetFullPath(cfg.GetString("SnapshotDirectory", _snapshotDir));
_intervalMinutes = Math.Max(5, cfg.GetInt("IntervalMinutes", _intervalMinutes));
_keepHourly = Math.Max(0, cfg.GetInt("KeepHourly", _keepHourly));
_keepDaily = Math.Max(0, cfg.GetInt("KeepDaily", _keepDaily));
_restoreMode = cfg.GetString("RestoreMode", _restoreMode); // Replace | Merge
_autoAtStartup = cfg.GetBoolean("AutoAtStartup", _autoAtStartup);
_autoAtShutdown = cfg.GetBoolean("AutoAtShutdown", _autoAtShutdown);
Directory.CreateDirectory(_snapshotDir);
_enabled = true;
Log.Info("[SCENESNAPSHOT] Module enabled.");
}
public void AddRegion(Scene scene)
{
if (!_enabled) return;
_scene = scene;
_scene.RegisterModuleInterface(this);
}
public void RegionLoaded(Scene scene)
{
if (!_enabled) return;
MainConsole.Instance.Commands.AddCommand(
"Region",
false,
"ss",
"ss",
"SceneSnapshot commands. Usage: ss create|list|restore <file>|prune|status",
HandleConsole);
TryCleanupTmp();
_timer = new Timer(_intervalMinutes * 60_000) { AutoReset = true };
_timer.Elapsed += (_, __) => SafeAutoSnapshot();
_timer.Start();
if (_autoAtStartup)
SafeAutoSnapshot();
Log.Info($"[SCENESNAPSHOT] Ready for region '{_scene.RegionInfo.RegionName}'. Saving to '{_snapshotDir}'.");
}
public void RemoveRegion(Scene scene)
{
if (!_enabled) return;
if (_autoAtShutdown)
SafeAutoSnapshot();
_timer?.Stop();
_timer?.Dispose();
_timer = null;
_scene.UnregisterModuleInterface(this);
_scene = null;
}
public void Close()
{
_timer?.Stop();
_timer?.Dispose();
_timer = null;
}
private void HandleConsole(string module, string[] args)
{
if (_scene == null)
{
MainConsole.Instance.Output("[SCENESNAPSHOT] No active scene.");
return;
}
if (args.Length < 2)
{
PrintHelp();
return;
}
string sub = args[1].ToLowerInvariant();
try
{
switch (sub)
{
case "create":
{
if (!BeginOperation()) { MainConsole.Instance.Output("[SCENESNAPSHOT] Another operation is in progress."); return; }
try
{
string path = CreateSnapshot();
MainConsole.Instance.Output($"[SCENESNAPSHOT] Snapshot created: {path}");
}
finally { EndOperation(); }
break;
}
case "list":
{
var files = ListSnapshots();
if (files.Length == 0)
MainConsole.Instance.Output("[SCENESNAPSHOT] No snapshots found.");
else
foreach (var f in files) MainConsole.Instance.Output(f);
break;
}
case "restore":
{
if (args.Length < 3)
{
MainConsole.Instance.Output("Usage: ss restore <fileOrLatest>");
return;
}
string target = args[2];
string file = ResolveSnapshotPath(target);
if (string.IsNullOrEmpty(file) || !File.Exists(file))
{
MainConsole.Instance.Output($"[SCENESNAPSHOT] File not found: {target}");
return;
}
if (!BeginOperation()) { MainConsole.Instance.Output("[SCENESNAPSHOT] Another operation is in progress."); return; }
try
{
RestoreSnapshot(file);
MainConsole.Instance.Output($"[SCENESNAPSHOT] Restored from: {file}");
}
finally { EndOperation(); }
break;
}
case "prune":
{
if (!BeginOperation()) { MainConsole.Instance.Output("[SCENESNAPSHOT] Another operation is in progress."); return; }
try
{
int removed = PruneSnapshots();
MainConsole.Instance.Output($"[SCENESNAPSHOT] Pruned {removed} files.");
}
finally { EndOperation(); }
break;
}
case "status":
{
MainConsole.Instance.Output($"[SCENESNAPSHOT] Dir={_snapshotDir}");
MainConsole.Instance.Output($"Interval={_intervalMinutes}m KeepHourly={_keepHourly} KeepDaily={_keepDaily}");
MainConsole.Instance.Output($"RestoreMode={_restoreMode}");
var files = ListSnapshots();
MainConsole.Instance.Output($"Snapshots count={files.Length}");
break;
}
default:
PrintHelp();
break;
}
}
catch (Exception ex)
{
MainConsole.Instance.Output($"[SCENESNAPSHOT] Error: {ex.Message}");
}
}
private void PrintHelp()
{
MainConsole.Instance.Output("ss create - Create a snapshot now");
MainConsole.Instance.Output("ss list - List snapshots");
MainConsole.Instance.Output("ss restore <file|latest> - Restore snapshot");
MainConsole.Instance.Output("ss prune - Prune snapshots by retention policy");
MainConsole.Instance.Output("ss status - Show config/status");
MainConsole.Instance.Output("Config keys (SceneSnapshot):");
MainConsole.Instance.Output(" SnapshotDirectory, IntervalMinutes, KeepHourly, KeepDaily");
MainConsole.Instance.Output(" RestoreMode = Replace | Merge");
}
private void SafeAutoSnapshot()
{
try
{
if (System.Threading.Interlocked.Exchange(ref _autoOpGuard, 1) == 1)
return;
if (!BeginOperation()) return;
try
{
CreateSnapshot();
PruneSnapshots();
}
finally { EndOperation(); }
}
catch (Exception e)
{
Log.Warn($"[SCENESNAPSHOT] Auto snapshot failed: {e.Message}");
}
finally
{
System.Threading.Interlocked.Exchange(ref _autoOpGuard, 0);
}
}
private bool BeginOperation()
{
return System.Threading.Interlocked.Exchange(ref _opInProgress, 1) == 0;
}
private void EndOperation()
{
System.Threading.Interlocked.Exchange(ref _opInProgress, 0);
}
private string CreateSnapshot()
{
string region = SanitizeName(_scene.RegionInfo.RegionName);
string ts = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
string fileName = $"{region}_{ts}.oar";
string tmpFile = Path.Combine(_snapshotDir, fileName + ".tmp");
string finalFile = Path.Combine(_snapshotDir, fileName);
IRegionArchiverModule archiver = _scene.RequestModuleInterface<IRegionArchiverModule>();
if (archiver == null)
throw new InvalidOperationException("RegionArchiver not available.");
try
{
using (var fs = new FileStream(tmpFile, FileMode.Create, FileAccess.Write, FileShare.None))
{
var options = new System.Collections.Generic.Dictionary<string, object>
{
["all"] = true // full OAR incl. assets
};
archiver.ArchiveRegion(fs, Guid.Empty, options);
}
if (File.Exists(finalFile)) File.Delete(finalFile);
File.Move(tmpFile, finalFile);
Log.Info($"[SCENESNAPSHOT] Created snapshot {finalFile}");
return finalFile;
}
catch
{
try { if (File.Exists(tmpFile)) File.Delete(tmpFile); } catch { }
throw;
}
}
private void RestoreSnapshot(string filePath)
{
IRegionArchiverModule archiver = _scene.RequestModuleInterface<IRegionArchiverModule>();
if (archiver == null)
throw new InvalidOperationException("RegionArchiver not available.");
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
var options = new System.Collections.Generic.Dictionary<string, object>();
if (string.Equals(_restoreMode, "Merge", StringComparison.OrdinalIgnoreCase))
options["merge"] = null;
archiver.DearchiveRegion(fs, Guid.Empty, options);
}
Log.Info($"[SCENESNAPSHOT] Restored snapshot {filePath}");
}
private int PruneSnapshots()
{
var files = ListSnapshots();
int removed = 0;
var entries = files
.Select(f => new { Path = f, Time = ParseTimestamp(f) })
.Where(x => x.Time != DateTime.MinValue)
.OrderByDescending(x => x.Time)
.ToArray();
var keep = entries.Take(_keepHourly).Select(x => x.Path)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (_keepDaily > 0)
{
var byDate = entries
.GroupBy(x => x.Time.Date)
.OrderByDescending(g => g.Key)
.Take(_keepDaily)
.Select(g => g.First().Path);
foreach (var p in byDate)
keep.Add(p);
}
foreach (var f in files)
{
if (!keep.Contains(f))
{
try { File.Delete(f); removed++; } catch { }
}
}
if (removed > 0)
Log.Info($"[SCENESNAPSHOT] Pruned {removed} snapshots.");
return removed;
}
private string[] ListSnapshots()
{
try
{
if (!Directory.Exists(_snapshotDir))
return Array.Empty<string>();
string region = SanitizeName(_scene.RegionInfo.RegionName);
return Directory.GetFiles(_snapshotDir, $"{region}_*.oar")
.Select(p => new { Path = p, Time = ParseTimestamp(p) })
.OrderByDescending(x => x.Time)
.ThenByDescending(x => x.Path, StringComparer.OrdinalIgnoreCase)
.Select(x => x.Path)
.ToArray();
}
catch
{
return Array.Empty<string>();
}
}
private string ResolveSnapshotPath(string arg)
{
if (string.IsNullOrWhiteSpace(arg))
return null;
if (string.Equals(arg, "latest", StringComparison.OrdinalIgnoreCase))
return ListSnapshots().FirstOrDefault();
string path = Path.IsPathRooted(arg) ? arg : Path.Combine(_snapshotDir, arg);
if (!path.EndsWith(".oar", StringComparison.OrdinalIgnoreCase))
path += ".oar";
return path;
}
private void TryCleanupTmp()
{
try
{
if (!Directory.Exists(_snapshotDir)) return;
foreach (var f in Directory.EnumerateFiles(_snapshotDir, "*.oar.tmp"))
{
try { File.Delete(f); } catch { }
}
}
catch { }
}
private static string SanitizeName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(name.Length);
foreach (var c in name)
sb.Append(invalid.Contains(c) ? '_' : c);
return sb.ToString();
}
private static DateTime ParseTimestamp(string path)
{
try
{
string file = Path.GetFileNameWithoutExtension(path);
int us = file.LastIndexOf('_');
if (us < 0 || us + 1 >= file.Length) return DateTime.MinValue;
string ts = file.Substring(us + 1);
if (DateTime.TryParseExact(ts, "yyyyMMdd-HHmmss", null,
System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
return dt;
}
catch { }
return DateTime.MinValue;
}
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0"?>
<Project frameworkVersion="v8_0" name="OpenSimSceneSnapshot.Modules" path="addon-modules/OpenSimSceneSnapshot/Modules" type="Library">
<Configuration name="Debug">
<Options>
<OutputPath>../../../bin/</OutputPath>
</Options>
</Configuration>
<Configuration name="Release">
<Options>
<OutputPath>../../../bin/</OutputPath>
</Options>
</Configuration>
<ReferencePath>../../../bin/</ReferencePath>
<Reference name="OpenMetaverseTypes"/>
<Reference name="OpenMetaverse"/>
<Reference name="OpenSim.Framework"/>
<Reference name="OpenSim.Region.Framework"/>
<Reference name="OpenSim.Region.Framework.Interfaces"/>
<Reference name="OpenSim.Region.Framework.Scenes"/>
<Reference name="OpenSim.Server.Base"/>
<Reference name="OpenSim.Services.Interfaces"/>
<Reference name="Nini"/>
<Reference name="log4net"/>
<Reference name="Mono.Addins"/>
</Project>
@@ -0,0 +1,3 @@
[Modules]
SceneSnapshot = enabled
Include-SceneSnapshot = "config-include/SceneSnapshot.ini"
@@ -0,0 +1,12 @@
[SceneSnapshot]
SnapshotDirectory = snapshots
IntervalMinutes = 60
KeepHourly = 24
KeepDaily = 7
AutoAtStartup = false
AutoAtShutdown = true
RestoreMode = Replace