Skip to content

Commit 6d81243

Browse files
authored
Add ref watches and validators (#627)
* Add ref watches and validators * Bust CircleCI cache * Atom validator and watch tests * Test CAS with validators too * Ignore Pip safety check * Var ROOT tests * change the log change the world * Thread-local var tests
1 parent bacd516 commit 6d81243

File tree

10 files changed

+409
-53
lines changed

10 files changed

+409
-53
lines changed

.circleci/config.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ commands:
1515
- run: sudo chown -R circleci:circleci /usr/local/bin
1616
- run: sudo chown -R circleci:circleci /usr/local/lib/python<< parameters.python_version >>/site-packages
1717
- restore_cache:
18-
key: << parameters.cache_key_prefix >>1-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
18+
key: << parameters.cache_key_prefix >>2-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
1919
- run:
2020
name: Install Poetry and Tox
2121
shell: /bin/bash -leo pipefail
@@ -35,7 +35,7 @@ commands:
3535
type: string
3636
steps:
3737
- save_cache:
38-
key: << parameters.cache_key_prefix >>1-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
38+
key: << parameters.cache_key_prefix >>2-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
3939
paths:
4040
- "/home/circleci/project/.tox"
4141
- "/usr/local/bin"
@@ -85,7 +85,7 @@ commands:
8585
steps:
8686
- checkout
8787
- restore_cache:
88-
key: pypy-deps2-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
88+
key: pypy-deps3-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
8989
- run:
9090
name: Install Poetry and Tox
9191
command: |
@@ -103,7 +103,7 @@ commands:
103103
tox -e pypy3 -- $CCI_NODE_TESTS
104104
no_output_timeout: 30m
105105
- save_cache:
106-
key: pypy-deps2-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
106+
key: pypy-deps3-{{ checksum "tox.ini" }}-{{ checksum "pyproject.toml" }}
107107
paths:
108108
- "/root/project/.tox"
109109
- "/usr/local/bin"

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added
99
* Added a bootstrapping function for easily bootstrapping Basilisp projects from Python (#620)
10+
* Added support for watchers and validator functions on Atoms and Vars (#627)
1011

1112
### Changed
1213
* PyTest is now an optional extra dependency, rather than a required dependency (#622)

src/basilisp/core.lpy

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -877,8 +877,10 @@
877877
may be returned by deref'ing it. The value of an atom may be reset using
878878
reset! and may be swapped using swap!. All operations on an atom occur
879879
atomically."
880-
[v]
881-
(basilisp.lang.atom/Atom v))
880+
([v]
881+
(basilisp.lang.atom/Atom v))
882+
([v & kwargs]
883+
(apply-kw basilisp.lang.atom/Atom v (apply hash-map kwargs))))
882884

883885
(defn realized?
884886
"Return true if the delay, future, lazy sequence, or promise has been
@@ -4029,9 +4031,9 @@
40294031
ctx
40304032
namespace))))
40314033

4032-
;;;;;;;;;;;;;;;;;;;
4033-
;; Ref Utilities ;;
4034-
;;;;;;;;;;;;;;;;;;;
4034+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
4035+
;; Reference (Atom/Namespace/Var) Utilities ;;
4036+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
40354037

40364038
(defn alter-meta!
40374039
"Atomically swap the metadata on reference o to the result of (apply f m args) where
@@ -4046,6 +4048,56 @@
40464048
[o meta]
40474049
(.reset-meta o meta))
40484050

4051+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
4052+
;; Ref (Atom/Var) Utilities ;;
4053+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
4054+
4055+
(defn add-watch
4056+
"Add a watch function `wf` identified by the key `k` to the ref (Atom or Var).
4057+
4058+
Watch functions should be functions of four arguments: the key associated with the
4059+
watch function, the ref object, the old value, and the new value.
4060+
4061+
Watch functions will be called synchronously. Note that the value of a ref may have
4062+
changed by the time a watch function is called, so watch functions should use the
4063+
old and new state arguments rather than attempting to `deref` the ref.
4064+
4065+
Watch functions cannot prevent state changes to a ref, regardless of the result of
4066+
the watch function.
4067+
4068+
Watch function keys are used to identify watches and may be used to remove a watch
4069+
from a ref (using `remove-watch`), but are otherwise unused by the watch feature.
4070+
4071+
Watches for a Var are only notified for Var root changes, not thread-local bindings."
4072+
[ref k wf]
4073+
(.add-watch ref k wf))
4074+
4075+
(defn remove-watch
4076+
"Remove the watch function identified by `k` from the ref (Atom or Var), if it exists.
4077+
Returns the ref."
4078+
[ref k]
4079+
(.remove-watch ref k))
4080+
4081+
(defn get-validator
4082+
"Return the validator defined for the ref (Atom or Var), or `nil` if no validator is
4083+
defined."
4084+
[ref]
4085+
(.get-validator ref))
4086+
4087+
(defn set-validator!
4088+
"Set the validator function for the ref (Atom or Var).
4089+
4090+
Validator functions should be side-effect free functions of one argument: the proposed
4091+
new value of the ref. The validator should either return false or throw an error if
4092+
the ref value is invalid.
4093+
4094+
`nil` may be passed to remove the validator for a ref.
4095+
4096+
If the existing ref value is not valid according to `vf`, an exception will be thrown
4097+
and the new validator function will not be applied."
4098+
[ref vf]
4099+
(.set-validator ref vf))
4100+
40494101
;;;;;;;;;;;;;;;;;;;
40504102
;; Var Utilities ;;
40514103
;;;;;;;;;;;;;;;;;;;

src/basilisp/lang/atom.py

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,71 @@
22

33
from readerwriterlock.rwlock import RWLockFair
44

5-
from basilisp.lang.interfaces import IDeref, IPersistentMap
6-
from basilisp.lang.reference import ReferenceBase
5+
from basilisp.lang.interfaces import IPersistentMap, RefValidator
6+
from basilisp.lang.map import PersistentMap
7+
from basilisp.lang.reference import RefBase
78

89
T = TypeVar("T")
910

1011

11-
class Atom(IDeref[T], ReferenceBase, Generic[T]):
12-
__slots__ = ("_meta", "_state", "_rlock", "_wlock")
12+
class Atom(RefBase[T], Generic[T]):
13+
__slots__ = ("_meta", "_state", "_rlock", "_wlock", "_watches", "_validator")
1314

14-
# pylint: disable=assigning-non-slot
15-
def __init__(self, state: T) -> None:
16-
self._meta: Optional[IPersistentMap] = None
15+
def __init__(
16+
self,
17+
state: T,
18+
meta: Optional[IPersistentMap] = None,
19+
validator: Optional[RefValidator] = None,
20+
) -> None:
21+
self._meta: Optional[IPersistentMap] = meta
1722
self._state = state
1823
lock = RWLockFair()
1924
self._rlock = lock.gen_rlock()
2025
self._wlock = lock.gen_wlock()
26+
self._watches = PersistentMap.empty()
27+
self._validator = validator
2128

22-
def compare_and_set(self, old: T, new: T) -> bool:
23-
"""Compare the current state of the Atom to `old`. If the value is the same,
24-
atomically set the value of the state of Atom to `new`. Return True if the
25-
value was swapped. Return False otherwise."""
29+
if validator is not None:
30+
self._validate(state)
31+
32+
def _compare_and_set(self, old: T, new: T) -> bool:
2633
with self._wlock:
2734
if self._state != old:
2835
return False
2936
self._state = new
3037
return True
3138

39+
def compare_and_set(self, old: T, new: T) -> bool:
40+
"""Compare the current state of the Atom to `old`. If the value is the same,
41+
atomically set the value of the state of Atom to `new`. Return True if the
42+
value was swapped. Return False otherwise."""
43+
self._validate(new)
44+
if self._compare_and_set(old, new):
45+
self._notify_watches(old, new)
46+
return True
47+
return False
48+
3249
def deref(self) -> T:
3350
"""Return the state stored within the Atom."""
3451
with self._rlock:
3552
return self._state
3653

3754
def reset(self, v: T) -> T:
3855
"""Reset the state of the Atom to `v` without regard to the current value."""
39-
with self._wlock:
40-
self._state = v
41-
return v
56+
while True:
57+
oldval = self._state
58+
self._validate(v)
59+
if self._compare_and_set(oldval, v):
60+
self._notify_watches(oldval, v)
61+
return v
4262

4363
def swap(self, f: Callable[..., T], *args, **kwargs) -> T:
4464
"""Atomically swap the state of the Atom to the return value of
4565
`f(old, *args, **kwargs)`, returning the new value."""
46-
with self._wlock:
47-
newval = f(self._state, *args, **kwargs)
48-
self._state = newval
49-
return newval
66+
while True:
67+
oldval = self._state
68+
newval = f(oldval, *args, **kwargs)
69+
self._validate(newval)
70+
if self._compare_and_set(oldval, newval):
71+
self._notify_watches(oldval, newval)
72+
return newval

src/basilisp/lang/interfaces.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
from abc import ABC, abstractmethod
33
from typing import (
44
AbstractSet,
5+
Any,
56
Callable,
67
Generic,
8+
Hashable,
79
Iterable,
810
Iterator,
911
Mapping,
@@ -122,6 +124,31 @@ def reset_meta(self, meta: "IPersistentMap") -> "IPersistentMap":
122124
raise NotImplementedError()
123125

124126

127+
RefValidator = Callable[[Any], Any]
128+
RefWatchKey = Hashable
129+
RefWatcher = Callable[[RefWatchKey, "IRef", Any, Any], None]
130+
131+
132+
class IRef(IReference):
133+
__slots__ = ()
134+
135+
@abstractmethod
136+
def add_watch(self, k: RefWatchKey, wf: RefWatcher) -> "IReference":
137+
raise NotImplementedError()
138+
139+
@abstractmethod
140+
def remove_watch(self, k: RefWatchKey) -> "IReference":
141+
raise NotImplementedError()
142+
143+
@abstractmethod
144+
def get_validator(self) -> Optional[RefValidator]:
145+
raise NotImplementedError()
146+
147+
@abstractmethod
148+
def set_validator(self, vf: Optional[RefValidator] = None) -> None:
149+
raise NotImplementedError()
150+
151+
125152
class IReversible(Generic[T]):
126153
"""IReversible types can produce a sequences of their elements in reverse in
127154
constant time.

src/basilisp/lang/reference.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1-
from typing import Callable, Optional
1+
from typing import Any, Callable, Optional, TypeVar
22

33
from readerwriterlock.rwlock import Lockable
44

5-
from basilisp.lang.interfaces import IPersistentMap, IReference
5+
from basilisp.lang import keyword as kw
6+
from basilisp.lang import map as lmap
7+
from basilisp.lang.exception import ExceptionInfo
8+
from basilisp.lang.interfaces import (
9+
IDeref,
10+
IPersistentMap,
11+
IRef,
12+
IReference,
13+
RefValidator,
14+
RefWatcher,
15+
RefWatchKey,
16+
)
617

718
try:
819
from typing import Protocol
@@ -20,7 +31,10 @@ def __call__(
2031
class ReferenceBase(IReference):
2132
"""Mixin for IReference classes to define the full IReference interface.
2233
23-
Consumers must have a `_lock` and `_meta` property defined."""
34+
`basilisp.lang.runtime.Namespace` objects are the only objects which are
35+
`IReference` objects without also being `IRef` objects.
36+
37+
Implementers must have the `_rlock`, `_wlock`, and `_meta` properties defined."""
2438

2539
_rlock: Lockable
2640
_wlock: Lockable
@@ -40,3 +54,60 @@ def reset_meta(self, meta: IPersistentMap) -> IPersistentMap:
4054
with self._wlock:
4155
self._meta = meta
4256
return meta
57+
58+
59+
T = TypeVar("T")
60+
61+
62+
class RefBase(IDeref[T], IRef, ReferenceBase):
63+
"""
64+
Mixin for IRef classes to define the full IRef interface.
65+
66+
`IRef` objects are generally shared, mutable state objects such as Atoms and
67+
Vars.
68+
69+
Implementers must have the `_validators` and `_watches` properties defined.
70+
"""
71+
72+
_validator: Optional[RefValidator]
73+
_watches: IPersistentMap
74+
75+
def add_watch(self, k: RefWatchKey, wf: RefWatcher) -> "RefBase[T]":
76+
with self._wlock:
77+
self._watches = self._watches.assoc(k, wf)
78+
return self
79+
80+
def _notify_watches(self, old: Any, new: Any):
81+
for k, wf in self._watches.items():
82+
wf(k, self, old, new)
83+
84+
def remove_watch(self, k: RefWatchKey) -> "RefBase[T]":
85+
with self._wlock:
86+
self._watches = self._watches.dissoc(k)
87+
return self
88+
89+
def get_validator(self) -> Optional[RefValidator]:
90+
return self._validator
91+
92+
def set_validator(self, vf: Optional[RefValidator] = None) -> None:
93+
# We cannot use a write lock here since we're calling `self.deref()` which
94+
# attempts to acquire the read lock for the Ref and will deadlock if the
95+
# lock is not reentrant.
96+
#
97+
# There are no guarantees that the Ref lock is reentrant and the default
98+
# locks for Atoms and Vars are not).
99+
#
100+
# This is probably ok for most cases since we expect contention is low or
101+
# non-existent while setting a validator function.
102+
if vf is not None:
103+
self._validate(self.deref(), vf=vf)
104+
self._validator = vf
105+
106+
def _validate(self, val: Any, vf: Optional[RefValidator] = None):
107+
vf = vf or self._validator
108+
if vf is not None:
109+
if not vf(val):
110+
raise ExceptionInfo(
111+
"Invalid reference state",
112+
lmap.map({kw.keyword("data"): val, kw.keyword("validator"): vf}),
113+
)

0 commit comments

Comments
 (0)