gitea.watch.state
state
The local cache of issue snapshots a watch run compares the current state against.
The cache is one JSON document holding the snapshots of every scope watched so far, keyed by the scope they were taken from, so watching several repositories and boards - in one invocation or in several - keeps their deltas apart:
{
"version": 2,
"scopes": {
"repo:my-org/my-repo": {"issues": {"1854": {...}}},
"project:my-org/29": {"issues": {"1854": {...}}}
}
}
Four decisions about it are worth stating, because each one is a trade the caller inherits.
A scope is baselined the first time it is seen. Its snapshots are recorded
and nothing is reported, so the first run against a repository does not announce
every issue already open in it. The alternative - reporting everything the first
time - makes the first cron tick the loudest one and the one nobody reads.
scope_snapshots answers None for a scope with no entry, which is what tells a
run apart from a scope whose issues have all gone away.
An unreadable cache is treated as no cache. A missing, empty, truncated or otherwise unparsable file baselines every scope again rather than failing the run, so a watchdog recovers by itself. The cost is real and is the reason it is written down here: changes made between the last good write and the recovery are never reported, because the run they would have been reported against is the one that re-baselines. Losing the cache loses that window, it does not delay it.
Writes are atomic, and touch only the scopes the run watched. The document
is written to a temporary file in the same directory and renamed over the cache,
so a reader - including the next run - never sees a half-written document,
whatever the writer was interrupted by. save_scopes re-reads the document
immediately before writing and replaces only the scopes it is given, so writing
back the document a run started from cannot put back whatever it held then.
That re-read and the write are one critical section, held under a lock on a
.lock file beside the cache, so two runs cannot both read the document and
then both write it - which is the race the re-read alone does not close, and
which a watchdog reaches by itself as soon as one run outlives its own timer.
The lock is an advisory lock taken through the operating system rather than a
file whose existence means "taken", so a run killed mid-write releases it
instead of wedging every run after it. It is not required for the CLI to work:
where the platform or the filesystem will not lock, the failure is logged and
the run proceeds as it did before, which is a narrower race rather than a
broken command. Two runs watching the same scope still end with the later
one's snapshots, which is what watching the same thing twice means.
Reading is lenient forwards and strict backwards. A field the document does not carry reads as its empty value and a field it carries that this version does not know is ignored, so a cache written by a newer version is not fatal to an older one. A cache written by an older version is discarded and its scopes are recorded afresh: the comment digests version 1 wrote were taken over a different identity, so every one of them would compare unequal, and reading them would announce every comment on every watched issue as removed and written again. One silent run costs less than that.
Functions:
gitea.watch.state.default_state_path
Build the path of the cache used when none is named.
Returns:
| Type | Description |
|---|---|
Path
|
The cache file in the user's cache directory for this application. |
Source code in src/gitea/watch/state.py
gitea.watch.state.resolve_state_path
Choose the cache a run reads and writes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state_file
|
str | Path | None
|
The path named on the command line or by |
None
|
Returns:
| Type | Description |
|---|---|
Path
|
The path of the cache. |
Source code in src/gitea/watch/state.py
gitea.watch.state.empty_state
Build the document a run starts from when there is no cache to read.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A document recording no scope at all. |
gitea.watch.state.load_state
Read the cache, treating anything unreadable as an absent one.
A missing file is the ordinary first run and is not reported. A file that exists but cannot be read as a cache document is reported as a warning, because it means the scopes in it are about to be baselined again and the changes since the last good write will never be reported.
A file whose bytes are not UTF-8 at all is one of those, and is caught here
rather than left to the caller: decoding raises UnicodeDecodeError, which
is a ValueError and not an OSError, so catching only the latter would
let a cache truncated mid-character - or a wholly unrelated binary file
named as one - end the run instead of re-baselining it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The cache document, or an empty one when there is nothing to read. |
Source code in src/gitea/watch/state.py
gitea.watch.state.save_state
Write the cache, so that no reader ever sees a partial document.
The document is written to a temporary file in the directory the cache lives in and renamed over it, which is atomic on every platform this runs on, and is flushed to disk first so the rename cannot publish an empty file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache. |
required |
state
|
dict[str, Any]
|
The cache document to write. |
required |
Raises:
| Type | Description |
|---|---|
OSError
|
If the cache directory, the temporary file or the rename cannot be written. The cache is left as it was. |
Source code in src/gitea/watch/state.py
gitea.watch.state.scope_snapshots
Read the snapshots recorded for one scope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
dict[str, Any]
|
The cache document. |
required |
scope
|
str
|
Key of the scope. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, dict[str, Any]] | None
|
The snapshot of each issue, keyed as the cache keys them, or None when |
dict[str, dict[str, Any]] | None
|
the scope has never been recorded - which is what baselines it rather |
dict[str, dict[str, Any]] | None
|
than reporting every issue in it as new. |
Source code in src/gitea/watch/state.py
gitea.watch.state.record_scope
Replace what the cache records for one scope.
Only that scope's entry is touched, so the scopes a run did not watch - and any key of the document this version does not know about - survive the write.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
dict[str, Any]
|
The cache document, modified in place. |
required |
scope
|
str
|
Key of the scope. |
required |
snapshots
|
dict[str, dict[str, Any]]
|
The snapshot of each issue currently in the scope. |
required |
Source code in src/gitea/watch/state.py
gitea.watch.state.lock_path_for
Build the path of the lock file guarding one cache.
The lock is taken on a file of its own rather than on the cache, because the cache is replaced by a rename: a lock held on the file that was there is not held on the file that replaces it, and two runs would end up locking two different files while believing they had the same one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
Path of the lock file beside it. |
Source code in src/gitea/watch/state.py
gitea.watch.state.cache_lock
Hold the lock guarding one cache for the duration of the block.
The lock is advisory and taken through the operating system, so a run that is killed while holding it releases it rather than leaving a file behind that every later run waits on. The lock file itself is never removed, for the same reason a lock is not the file's existence: unlinking it while another run holds it open would let the next run lock a different file.
Failing to lock is not failing to run. A platform with neither locking call, a filesystem that refuses the lock, and a wait that runs out are all logged and yield anyway, leaving the caller in the read/modify/write race it was in before there was a lock rather than leaving it unable to watch anything.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache to guard. |
required |
Yields:
| Type | Description |
|---|---|
bool
|
Whether the lock is actually held for the duration of the block. |
Source code in src/gitea/watch/state.py
gitea.watch.state.save_scopes
Record the scopes a run watched, leaving every other scope as it is.
A run is authoritative only for the scopes it was asked to watch, so the document is re-read here - immediately before it is written, rather than at the start of the run - and only those scopes are replaced in it. Writing the document the run started from would put back whatever it held then, erasing the scopes a concurrent run recorded while this one was fetching.
Re-reading is not enough on its own: two runs reaching this point together
would both read the same document, and the second rename would still drop
what the first recorded. The read, the change and the write are therefore
one critical section held under cache_lock, so the second run reads what
the first wrote. A cache that cannot be locked is still written, and is back
to being racy rather than unusable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Path of the cache. |
required |
scopes
|
dict[str, dict[str, dict[str, Any]]]
|
The snapshots to record, keyed by scope. |
required |
Raises:
| Type | Description |
|---|---|
OSError
|
If the cache cannot be written. It is left as it was. |