Add support for task expiration (#3546)

This commit is contained in:
Dustin J. Mitchell 2024-07-09 16:39:39 -04:00 committed by GitHub
parent 2bd609afe3
commit 213b9d3aee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 180 additions and 7 deletions

View file

@ -216,6 +216,12 @@ Note that this should be used in the form of a command line override (task
rc.gc=0 ...), and not permanently used in the .taskrc file, as this
significantly affects performance in the long term.
.TP
.B expiration.on-sync=0
If set, old tasks will be deleted automatically after each synchronization.
Tasks are identified as "old" when they have status "Deleted" and have not
been modified for 180 days.
.TP
.B hooks=1
This master control switch enables hook script processing. The default value

View file

@ -132,6 +132,7 @@ syn match taskrcGoodKey '^\s*\Vexpressions='he=e-1
syn match taskrcGoodKey '^\s*\Vextensions='he=e-1
syn match taskrcGoodKey '^\s*\Vfontunderline='he=e-1
syn match taskrcGoodKey '^\s*\Vgc='he=e-1
syn match taskrcGoodKey '^\s*\Vexpiration.on-sync='he=e-1
syn match taskrcGoodKey '^\s*\Vhooks='he=e-1
syn match taskrcGoodKey '^\s*\Vhooks.location='he=e-1
syn match taskrcGoodKey '^\s*\Vhyphenate='he=e-1

View file

@ -118,6 +118,7 @@ std::string configurationDefaults =
"json.array=1 # Enclose JSON output in [ ]\n"
"abbreviation.minimum=2 # Shortest allowed abbreviation\n"
"news.version= # Latest version highlights read by the user\n"
"expiration.on-sync=0 # Expire old tasks on sync\n"
"\n"
"# Dates\n"
"dateformat=Y-M-D # Preferred input and display date format\n"
@ -853,13 +854,8 @@ int Context::dispatch (std::string &out)
// The command know whether they need a GC.
if (c->needs_gc ())
{
run_gc = config.getBoolean ("gc");
tdb2.gc ();
}
else
{
run_gc = false;
}
// This is something that is only needed for write commands with no other
// filter processing.

View file

@ -98,7 +98,6 @@ public:
Hooks hooks {};
bool determine_color_use {true};
bool use_color {true};
bool run_gc {true};
bool verbosity_legacy {false};
std::set <std::string> verbosity {};
std::vector <std::string> headers {};

View file

@ -303,6 +303,7 @@ void TDB2::show_diff (
}
}
////////////////////////////////////////////////////////////////////////////////
void TDB2::gc ()
{
Timer timer;
@ -316,6 +317,12 @@ void TDB2::gc ()
Context::getContext ().time_gc_us += timer.total_us ();
}
////////////////////////////////////////////////////////////////////////////////
void TDB2::expire_tasks ()
{
replica.expire_tasks ();
}
////////////////////////////////////////////////////////////////////////////////
// Latest ID is that of the last pending task.
int TDB2::latest_id ()

View file

@ -56,6 +56,7 @@ public:
void get_changes (std::vector <Task>&);
void revert ();
void gc ();
void expire_tasks ();
int latest_id ();
// Generalized task accessors.

View file

@ -172,6 +172,7 @@ std::vector<NewsItem> NewsItem::all () {
std::vector<NewsItem> items;
version2_6_0(items);
version3_0_0(items);
version3_1_0(items);
return items;
}
@ -537,6 +538,27 @@ void NewsItem::version3_0_0 (std::vector<NewsItem>& items) {
items.push_back(sync);
}
void NewsItem::version3_1_0 (std::vector<NewsItem>& items) {
Version version("3.1.0");
NewsItem sync {
version,
/*title=*/"Purging and Expiring Tasks",
/*bg_title=*/"",
/*background=*/"",
/*punchline=*/
"Support for `task purge` has been restored, and new support added for automatically expiring\n"
"old tasks.\n\n"
/*update=*/
"The `task purge` command removes tasks entirely, in contrast to `task delete` which merely sets\n"
"the task status to 'Deleted'. This functionality existed in versions 2.x but was temporarily\n"
"removed in 3.0.\n\n"
"The new `expiration.on-sync` configuration parameter controls automatic expiration of old tasks.\n"
"An old task is one with status 'Deleted' that has not been modified in 180 days. This\n"
"functionality is optional and not enabled by default."
};
items.push_back(sync);
}
////////////////////////////////////////////////////////////////////////////////
int CmdNews::execute (std::string& output)
{

View file

@ -49,6 +49,7 @@ public:
static std::vector<NewsItem> all();
static void version2_6_0 (std::vector<NewsItem>&);
static void version3_0_0 (std::vector<NewsItem>&);
static void version3_1_0 (std::vector<NewsItem>&);
private:
NewsItem (

View file

@ -160,6 +160,7 @@ int CmdShow::execute (std::string& output)
" due"
" editor"
" exit.on.missing.db"
" expiration.on-sync"
" expressions"
" fontunderline"
" gc"

View file

@ -106,7 +106,12 @@ int CmdSync::execute (std::string& output)
<< '\n';
}
Context::getContext ().tdb2.sync(std::move(server), false);
Context &context = Context::getContext ();
context.tdb2.sync(std::move(server), false);
if (context.config.getBoolean ("expiration.on-sync")) {
context.tdb2.expire_tasks ();
}
output = out.str ();
return status;

View file

@ -144,6 +144,15 @@ tc::Task tc::Replica::import_task_with_uuid (const std::string &uuid)
return Task (tctask);
}
////////////////////////////////////////////////////////////////////////////////
void tc::Replica::expire_tasks ()
{
auto res = tc_replica_expire_tasks (&*inner);
if (res != TC_RESULT_OK) {
throw replica_error ();
}
}
////////////////////////////////////////////////////////////////////////////////
void tc::Replica::sync (Server server, bool avoid_snapshots)
{

View file

@ -92,6 +92,7 @@ namespace tc {
tc::Task new_task (Status status, const std::string &description);
tc::Task import_task_with_uuid (const std::string &uuid);
// TODO: struct TCTask *tc_replica_import_task_with_uuid(struct TCReplica *rep, struct TCUuid tcuuid);
void expire_tasks();
void sync(Server server, bool avoid_snapshots);
tc::ffi::TCReplicaOpList get_undo_ops ();
void commit_undo_ops (tc::ffi::TCReplicaOpList tc_undo_ops, int32_t *undone_out);

View file

@ -533,6 +533,31 @@ pub unsafe extern "C" fn tc_replica_sync(
)
}
#[ffizz_header::item]
#[ffizz(order = 902)]
/// Expire old, deleted tasks.
///
/// Expiration entails removal of tasks from the replica. Any modifications that occur after
/// the deletion (such as operations synchronized from other replicas) will do nothing.
///
/// Tasks are eligible for expiration when they have status Deleted and have not been modified
/// for 180 days (about six months). Note that completed tasks are not eligible.
///
/// ```c
/// EXTERN_C TCResult tc_replica_expire_tasks(struct TCReplica *rep);
/// ```
#[no_mangle]
pub unsafe extern "C" fn tc_replica_expire_tasks(rep: *mut TCReplica) -> TCResult {
wrap(
rep,
|rep| {
rep.expire_tasks()?;
Ok(TCResult::Ok)
},
TCResult::Error,
)
}
#[ffizz_header::item]
#[ffizz(order = 902)]
/// Return undo local operations until the most recent UndoPoint.

View file

@ -552,6 +552,15 @@ EXTERN_C TCResult tc_replica_commit_undo_ops(struct TCReplica *rep, TCReplicaOpL
// free the returned string.
EXTERN_C struct TCString tc_replica_error(struct TCReplica *rep);
// Expire old, deleted tasks.
//
// Expiration entails removal of tasks from the replica. Any modifications that occur after
// the deletion (such as operations synchronized from other replicas) will do nothing.
//
// Tasks are eligible for expiration when they have status Deleted and have not been modified
// for 180 days (about six months). Note that completed tasks are not eligible.
EXTERN_C TCResult tc_replica_expire_tasks(struct TCReplica *rep);
// Get an existing task by its UUID.
//
// Returns NULL when the task does not exist, and on error. Consult tc_replica_error

View file

@ -111,6 +111,7 @@ set (pythonTests
encoding.test.py
enpassant.test.py
exec.test.py
expiration.test.py
export.test.py
feature.559.test.py
feature.default.project.test.py

89
test/expiration.test.py Executable file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
###############################################################################
#
# Copyright 2006 - 2024, Tomas Babej, Paul Beckingham, Federico Hernandez.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# https://www.opensource.org/licenses/mit-license.php
#
###############################################################################
import sys
import os
import unittest
import time
# Ensure python finds the local simpletap module
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from basetest import Task, TestCase
from basetest.utils import mkstemp
class TestImport(TestCase):
def setUp(self):
self.t = Task()
# Set up local sync within the TASKDATA directory, so that it will be
# deleted properly.
self.t.config("sync.local.server_dir", self.t.datadir)
def exists(self, uuid):
code, out, err = self.t(f"_get {uuid}.status")
return out.strip() != ""
def test_expiration(self):
"""Only tasks that are deleted and have a modification in the past are expired."""
yesterday = int(time.time()) - 3600 * 24
last_year = int(time.time()) - 265 * 3600 * 24
old_pending = "a1111111-a111-a111-a111-a11111111111"
old_completed = "a2222222-a222-a222-a222-a22222222222"
new_deleted = "a3333333-a333-a333-a333-a33333333333"
old_deleted = "a4444444-a444-a444-a444-a44444444444"
task_data = f"""[
{{"uuid":"{old_pending}","status":"pending","modified":"{last_year}","description":"x"}},
{{"uuid":"{old_completed}","status":"completed","modified":"{last_year}","description":"x"}},
{{"uuid":"{new_deleted}","status":"deleted","modified":"{yesterday}","description":"x"}},
{{"uuid":"{old_deleted}","status":"deleted","modified":"{last_year}","description":"x"}}
]
"""
code, out, err = self.t("import -", input=task_data)
self.assertIn("Imported 4 tasks", err)
# By default, expiration does not occur.
code, out, err = self.t("sync")
self.assertTrue(self.exists(old_pending))
self.assertTrue(self.exists(old_completed))
self.assertTrue(self.exists(new_deleted))
self.assertTrue(self.exists(old_deleted))
# Configure expiration on sync. The old_deleted task
# should be removed.
self.t.config("expiration.on-sync", "1")
code, out, err = self.t("sync")
self.assertTrue(self.exists(old_pending))
self.assertTrue(self.exists(old_completed))
self.assertTrue(self.exists(new_deleted))
self.assertFalse(self.exists(old_deleted))
if __name__ == "__main__":
from simpletap import TAPTestRunner
unittest.main(testRunner=TAPTestRunner())
# vim: ai sts=4 et sw=4 ft=python