[Checkins] SVN: zc.twist/trunk/src/zc/twist/ Initial checkin
Gary Poster
gary at zope.com
Tue Aug 15 16:31:29 EDT 2006
Log message for revision 69534:
Initial checkin
Changed:
A zc.twist/trunk/src/zc/twist/README.txt
A zc.twist/trunk/src/zc/twist/__init__.py
A zc.twist/trunk/src/zc/twist/tests.py
-=-
Added: zc.twist/trunk/src/zc/twist/README.txt
===================================================================
--- zc.twist/trunk/src/zc/twist/README.txt 2006-08-15 20:30:24 UTC (rev 69533)
+++ zc.twist/trunk/src/zc/twist/README.txt 2006-08-15 20:31:28 UTC (rev 69534)
@@ -0,0 +1,443 @@
+===================================================
+Twist: Talking to the ZODB in Twisted Reactor Calls
+===================================================
+
+The twist package contains a few functions and classes, but primarily a
+helper for having a deferred call on a callable persistent object, or on
+a method on a persistent object. This lets you have a Twisted reactor
+call or a Twisted deferred callback affect the ZODB. Everything can be
+done within the main thread, so it can be full-bore Twisted usage,
+without threads. There are a few important "gotchas": see the Gotchas_
+section below for details.
+
+The main API is `Partial`. You can pass it a callable persistent object,
+a method of a persistent object, or a normal non-persistent callable,
+and any arguments or keyword arguments of the same sort. DO NOT
+use non-persistent data structures (such as lists) of persistent objects
+with a database connection as arguments. This is your responsibility.
+
+If nothing is persistent, the partial will not bother to get a connection,
+and will behave normally.
+
+ >>> from zc.twist import Partial
+ >>> def demo():
+ ... return 42
+ ...
+ >>> Partial(demo)()
+ 42
+
+Now let's imagine a demo object that is persistent and part of a
+database connection. It has a `count` attribute that starts at 0, a
+`__call__` method that increments count by an `amount` that defaults to
+1, and an `decrement` method that reduces count by an `amount` that
+defaults to 1 [#set_up]_. Everything returns the current value of count.
+
+ >>> demo.count
+ 0
+ >>> demo()
+ 1
+ >>> demo(2)
+ 3
+ >>> demo.decrement()
+ 2
+ >>> demo.decrement(2)
+ 0
+ >>> import transaction
+ >>> transaction.commit()
+
+Now we can make some deferred calls with these examples. We will use
+`transaction.begin()` to sync our connection with what happened in the
+deferred call. Note that we need to have some adapters set up for this
+to work. The twist module includes implementations of them that we
+will also assume have been installed [#adapters]_.
+
+ >>> call = Partial(demo)
+ >>> demo.count # hasn't been called yet
+ 0
+ >>> deferred = call()
+ >>> demo.count # we haven't synced yet
+ 0
+ >>> t = transaction.begin() # sync the connection
+ >>> demo.count # ah-ha!
+ 1
+
+We can use the deferred returned from the call to do somethin with the
+return value. In this case, the deferred is already completed, so
+adding a callback gets instant execution.
+
+ >>> def show_value(res):
+ ... print res
+ ...
+ >>> ignore = deferred.addCallback(show_value)
+ 1
+
+We can also pass the method.
+
+ >>> call = Partial(demo.decrement)
+ >>> deferred = call()
+ >>> demo.count
+ 1
+ >>> t = transaction.begin()
+ >>> demo.count
+ 0
+
+Arguments are passed through.
+
+ >>> call = Partial(demo)
+ >>> deferred = call(2)
+ >>> t = transaction.begin()
+ >>> demo.count
+ 2
+ >>> call = Partial(demo.decrement)
+ >>> deferred = call(amount=2)
+ >>> t = transaction.begin()
+ >>> demo.count
+ 0
+
+They can also be set during instantiation.
+
+ >>> call = Partial(demo, 3)
+ >>> deferred = call()
+ >>> t = transaction.begin()
+ >>> demo.count
+ 3
+ >>> call = Partial(demo.decrement, amount=3)
+ >>> deferred = call()
+ >>> t = transaction.begin()
+ >>> demo.count
+ 0
+
+Arguments themselves can be persistent objects. Let's assume a new demo2
+object as well.
+
+ >>> demo2.count
+ 0
+ >>> def mass_increment(d1, d2, value=1):
+ ... d1(value)
+ ... d2(value)
+ ...
+ >>> call = Partial(mass_increment, demo, demo2, value=4)
+ >>> deferred = call()
+ >>> t = transaction.begin()
+ >>> demo.count
+ 4
+ >>> demo2.count
+ 4
+ >>> demo.count = demo2.count = 0 # cleanup
+ >>> transaction.commit()
+
+ConflictErrors make it retry.
+
+In order to have a chance to simulate a ConflictError, this time imagine
+we have a runner that can switch execution from the call to our code
+using `pause`, `retry` and `resume` (this is just for tests--remember,
+calls used in non-threaded Twisted should be non-blocking!)
+[#conflict_error_setup]_.
+
+ >>> demo.count
+ 0
+ >>> call = Partial(demo)
+ >>> runner = Runner(call) # it starts paused in the middle of an attempt
+ >>> call.attempt_count
+ 1
+ >>> demo.count = 5 # now we will make a conflicting transaction...
+ >>> transaction.commit()
+ >>> runner.retry()
+ >>> call.attempt_count # so it has to retry
+ 2
+ >>> t = transaction.begin()
+ >>> demo.count # our value hasn't changed...
+ 5
+ >>> runner.resume() # but now call will be successful on the second attempt
+ >>> call.attempt_count
+ 2
+ >>> t = transaction.begin()
+ >>> demo.count
+ 6
+
+After five retries (currently hard-coded), the retry fails, raising the
+last ConflictError. This is returned to the deferred. The failure put
+on the deferred will have a sanitized traceback. Here, imagine we have
+a deferred (named `deferred`) created from such a an event
+[#conflict_error_failure]_.
+
+ >>> res = None
+ >>> def get_result(r):
+ ... global res
+ ... res = r # we return None to quiet Twisted down on the command line
+ ...
+ >>> d = deferred.addErrback(get_result)
+ >>> print res.getTraceback() # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
+ Traceback (most recent call last):
+ ...
+ ZODB.POSException.ConflictError: database conflict error...
+
+Other errors are returned to the deferred as well, as sanitized failures
+[#use_original_demo]_.
+
+ >>> call = Partial(demo)
+ >>> d = call('I do not add well with integers')
+ >>> d = d.addErrback(get_result)
+ >>> print res.getTraceback() # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
+ Traceback (most recent call last):
+ ...
+ ...TypeError: unsupported operand type(s) for +=: 'int' and 'str'
+
+The call tries to be a good connection citizen, waiting for a connection
+if the pool is at its maximum size. This code relies on the twisted
+reactor; we'll use a `time_flies` function, which takes seconds to move
+ahead, to simulate time passing in the reactor
+[#relies_on_twisted_reactor]_.
+
+ >>> db.setPoolSize(1)
+ >>> db.getPoolSize()
+ 1
+ >>> demo.count = 0
+ >>> transaction.commit()
+ >>> call = Partial(demo)
+ >>> res = None
+ >>> deferred = call()
+ >>> d = deferred.addCallback(get_result)
+ >>> call.attempt_count
+ 0
+ >>> time_flies(.1) >= 1 # returns number of connection attempts
+ True
+ >>> call.attempt_count
+ 0
+ >>> res # None
+ >>> db.setPoolSize(2)
+ >>> db.getPoolSize()
+ 2
+ >>> time_flies(.2) >= 1
+ True
+ >>> call.attempt_count > 0
+ True
+ >>> res
+ 1
+ >>> t = transaction.begin()
+ >>> demo.count
+ 1
+
+If it takes more than a second or two, it will eventually just decide to grab
+one. This behavior may change.
+
+ >>> db.setPoolSize(1)
+ >>> db.getPoolSize()
+ 1
+ >>> call = Partial(demo)
+ >>> res = None
+ >>> deferred = call()
+ >>> d = deferred.addCallback(get_result)
+ >>> call.attempt_count
+ 0
+ >>> time_flies(.1) >= 1
+ True
+ >>> call.attempt_count
+ 0
+ >>> res # None
+ >>> time_flies(1.9) >= 2 # for a total of at least 3
+ True
+ >>> res
+ 2
+ >>> t = transaction.begin()
+ >>> demo.count
+ 2
+
+Without a running reactor, this functionality will not work
+[#teardown_monkeypatch]_. Also, it relies on an undocumented, protected
+attribute on the ZODB.DB, so is fragile across ZODB versions.
+
+Gotchas
+-------
+
+For a certain class of jobs, you won't have to think much about using
+the twist Partial. For instance, if you are putting a result gathered by
+work done by deferreds into the ZODB, and that's it, everything should be
+pretty simple. However, unfortunately, you have to think a bit harder for
+other common use cases.
+
+* As already mentioned, do not use arguments that are non-persistent
+ collections (or even persistent objects without a connection) that hold
+ any persistent objects with connections.
+
+* Using persistent objects with connections but that have not been
+ committed to the database will cause problems when used (as callable
+ or argument), perhaps intermittently (if a commit happens before the
+ partial is called, it will work). Don't do this.
+
+* Do not return values that are persistent objects tied to a connection.
+
+* If you plan on firing off another reactor call on the basis of your
+ work in the callable, realize that the work hasn't really "happened"
+ until you commit the transaction. The partial typically handles commits
+ for you, committing if you return any result and aborting if you raise
+ an error. But if you want to send off a reactor call on the basis of a
+ successful transaction, you'll want to (a) do the work, then (b)
+ commit, then (c) send off the reactor call. If the commit fails,
+ you'll get the standard abort and retry.
+
+* If you want to handle your own transactions, do not use the thread
+ transaction manager that you get from importing transaction. This
+ will cause intermittent, hard-to-debug, unexpected problems. Instead,
+ adapt any persistent object you get to
+ transaction.interfaces.ITransactionManager, and use that manager for
+ commits and aborts.
+
+=========
+Footnotes
+=========
+
+.. [#set_up] We'll actually create the state that the text describes here.
+
+ >>> import persistent
+ >>> class Demo(persistent.Persistent):
+ ... count = 0
+ ... def __call__(self, amount=1):
+ ... self.count += amount
+ ... return self.count
+ ... def decrement(self, amount=1):
+ ... self.count -= amount
+ ... return self.count
+ ...
+ >>> from ZODB.tests.util import DB
+ >>> db = DB()
+ >>> conn = db.open()
+ >>> root = conn.root()
+ >>> demo = root['demo'] = Demo()
+ >>> demo2 = root['demo2'] = Demo()
+ >>> import transaction
+ >>> transaction.commit()
+
+.. [#adapters] You must have two adapter registrations: IConnection to
+ ITransactionManager, and IPersistent to IConnection. We will also
+ register IPersistent to ITransactionManager because the adapter is
+ designed for it.
+
+ >>> from zc.twist import transactionManager, connection
+ >>> import zope.component
+ >>> zope.component.provideAdapter(transactionManager)
+ >>> zope.component.provideAdapter(connection)
+ >>> import ZODB.interfaces
+ >>> zope.component.provideAdapter(
+ ... transactionManager, adapts=(ZODB.interfaces.IConnection,))
+
+ This quickly tests the adapters:
+
+ >>> ZODB.interfaces.IConnection(demo) is conn
+ True
+ >>> import transaction.interfaces
+ >>> transaction.interfaces.ITransactionManager(demo) is transaction.manager
+ True
+ >>> transaction.interfaces.ITransactionManager(conn) is transaction.manager
+ True
+
+.. [#conflict_error_setup] We also use this runner in the footnote below.
+
+ >>> import threading
+ >>> _main = threading.Lock()
+ >>> _thread = threading.Lock()
+ >>> def safe_release(lock):
+ ... while not lock.locked():
+ ... pass
+ ... lock.release()
+ ...
+ >>> class AltDemo(persistent.Persistent):
+ ... count = 0
+ ... def __call__(self, amount=1):
+ ... self.count += amount
+ ... safe_release(_main)
+ ... _thread.acquire()
+ ... return self.count
+ ...
+ >>> demo = root['altdemo'] = AltDemo()
+ >>> transaction.commit()
+ >>> class Runner(object):
+ ... def __init__(self, call):
+ ... self.call = call
+ ... self.thread = threading.Thread(target=self.run)
+ ... _thread.acquire()
+ ... _main.acquire()
+ ... self.thread.start()
+ ... _main.acquire()
+ ... def run(self):
+ ... self.result = self.call()
+ ... assert _main.locked()
+ ... safe_release(_main)
+ ... def retry(self):
+ ... assert _thread.locked()
+ ... safe_release(_thread)
+ ... _main.acquire()
+ ... def resume(self, retry=True):
+ ... if retry:
+ ... while self.thread.isAlive():
+ ... self.retry()
+ ... else:
+ ... while self.thread.isAlive():
+ ... pass
+ ... assert _thread.locked()
+ ... assert _main.locked()
+ ... safe_release(_thread)
+ ... safe_release(_main)
+ ... assert not self.thread.isAlive()
+ ... assert not _thread.locked()
+ ... assert not _main.locked()
+
+.. [#conflict_error_failure] Here we create five consecutive conflict errors,
+ which causes the call to give up.
+
+ >>> call = Partial(demo)
+ >>> runner = Runner(call)
+ >>> for i in range(5):
+ ... demo.count = i
+ ... transaction.commit()
+ ... runner.retry()
+ ...
+ >>> runner.resume(retry=False)
+ >>> _thread.locked()
+ False
+ >>> _main.locked()
+ False
+ >>> demo.count
+ 4
+ >>> call.attempt_count
+ 5
+ >>> runner.thread.isAlive()
+ False
+ >>> deferred = runner.result
+
+.. [#use_original_demo] The second demo has too much thread code in it:
+ we'll use the old demo for the rest of the discussion.
+
+ >>> demo = root['demo']
+
+.. [#relies_on_twisted_reactor] We monkeypatch twisted.internet.reactor
+ (and revert it in another footnote below).
+
+ >>> import twisted.internet.reactor
+ >>> oldCallLater = twisted.internet.reactor.callLater
+ >>> import bisect
+ >>> class FauxReactor(object):
+ ... def __init__(self):
+ ... self.time = 0
+ ... self.calls = []
+ ... def callLater(self, delay, callable, *args, **kw):
+ ... res = (delay + self.time, callable, args, kw)
+ ... bisect.insort(self.calls, res)
+ ... # normally we're supposed to return something but not needed
+ ... def time_flies(self, time):
+ ... end = self.time + time
+ ... ct = 0
+ ... while self.calls and self.calls[0][0] <= end:
+ ... self.time, callable, args, kw = self.calls.pop(0)
+ ... callable(*args, **kw) # normally this would get try...except
+ ... ct += 1
+ ... self.time = end
+ ... return ct
+ ...
+ >>> faux = FauxReactor()
+ >>> twisted.internet.reactor.callLater = faux.callLater
+ >>> time_flies = faux.time_flies
+
+.. [#teardown_monkeypatch]
+
+ >>> twisted.internet.reactor.callLater = oldCallLater
Property changes on: zc.twist/trunk/src/zc/twist/README.txt
___________________________________________________________________
Name: svn:eol-style
+ native
Added: zc.twist/trunk/src/zc/twist/__init__.py
===================================================================
--- zc.twist/trunk/src/zc/twist/__init__.py 2006-08-15 20:30:24 UTC (rev 69533)
+++ zc.twist/trunk/src/zc/twist/__init__.py 2006-08-15 20:31:28 UTC (rev 69534)
@@ -0,0 +1,240 @@
+import random
+import types
+
+import ZODB.interfaces
+import ZODB.POSException
+import transaction
+import transaction.interfaces
+import persistent
+import persistent.interfaces
+
+import twisted.internet.defer
+import twisted.internet.reactor
+
+import zope.component
+import zope.interface
+
+EXPLOSIVE_ERRORS = [SystemExit, KeyboardInterrupt,
+ ZODB.POSException.POSError]
+
+# this is currently internal, though maybe we'll expose it later
+class IDeferredReference(zope.interface.Interface):
+ def __call__(self, connection):
+ """return the actual object to be used."""
+
+ db = zope.interface.Attribute("""
+ The associated database, or None""")
+
+class DeferredReferenceToPersistent(object):
+ zope.interface.implements(IDeferredReference)
+
+ name = None
+
+ def __init__(self, obj):
+ if isinstance(obj, types.MethodType):
+ self.name = obj.__name__
+ obj = obj.im_self
+ conn = ZODB.interfaces.IConnection(obj)
+ self.db = conn.db()
+ self.id = obj._p_oid
+
+ def __call__(self, conn):
+ if conn.db().database_name != self.db.database_name:
+ conn = conn.get_connection(self.db.database_name)
+ obj = conn.get(self.id)
+ if self.name is not None:
+ obj = getattr(obj, self.name)
+ return obj
+
+def Reference(obj):
+ if isinstance(obj, types.MethodType):
+ if (persistent.interfaces.IPersistent.providedBy(obj.im_self) and
+ obj.im_self._p_jar is not None):
+ return DeferredReferenceToPersistent(obj)
+ else:
+ return obj
+ if (persistent.interfaces.IPersistent.providedBy(obj)
+ and obj._p_jar is not None):
+ return DeferredReferenceToPersistent(obj)
+ return obj
+
+def availableConnectionCount(db, version=''):
+ # we're entering into protected name land :-( It would be nice to
+ # have APIs to get the current pool size, and available pool size in
+ # addition to the target pool size
+ try:
+ pools = db._pools
+ except AttributeError:
+ return True # TODO: log this
+ else:
+ pool = pools.get(version)
+ if pool is None:
+ return True
+ size = db.getPoolSize()
+ all = len(pool.all)
+ available = len(pool.available) + (size - all)
+ return available
+
+def get_connection(db, mvcc=True, version='', synch=True,
+ deferred=None, backoff=None):
+ if deferred is None:
+ deferred = twisted.internet.defer.Deferred()
+ if backoff is None:
+ backoff = random.random() / 10 # max of 1/10 of a second
+ else:
+ backoff *= 2
+ # if this is taking too long (i.e., the cumulative backoff is taking
+ # about a second) then we'll just take one. This might be a bad idea:
+ # we'll have to see in practice. Otherwise, if the backoff isn't too
+ # long and we don't have a connection within our limit, try again
+ # later.
+ if backoff < .5 and not availableConnectionCount(db):
+ twisted.internet.reactor.callLater(
+ backoff, get_connection, db, mvcc, version, synch,
+ deferred, backoff)
+ return deferred
+ deferred.callback(
+ db.open(version=version, mvcc=mvcc,
+ transaction_manager=transaction.TransactionManager(),
+ synch=synch))
+ return deferred
+
+def sanitize(failure):
+ # failures may have some bad things in the traceback frames. This
+ # converts everything to strings
+ state = failure.__getstate__()
+ failure.__dict__.update(state)
+ return failure
+
+class Partial(object):
+
+ attempt_count = 0
+ mvcc = True
+ version = ''
+ synch = True
+
+ def __init__(self, call, *args, **kwargs):
+ self.call = Reference(call)
+ self.args = list(Reference(a) for a in args)
+ self.kwargs = dict((k, Reference(v)) for k, v in kwargs.iteritems())
+
+ def __call__(self, *args, **kwargs):
+ self.args.extend(args)
+ self.kwargs.update(kwargs)
+ db = None
+ for src in ((self.call,), self.args, self.kwargs.itervalues()):
+ for item in src:
+ if IDeferredReference.providedBy(item) and item.db is not None:
+ db = item.db
+ break
+ else:
+ continue
+ break
+ else:
+ call, args, kwargs = self._resolve(None)
+ return call(*args, **kwargs)
+ self.attempt_count = 0
+ d = twisted.internet.defer.Deferred()
+ get_connection(db, self.mvcc, self.version, self.synch
+ ).addCallback(self._call, d)
+ return d
+
+ def _resolve(self, conn):
+ if IDeferredReference.providedBy(self.call):
+ call = self.call(conn)
+ else:
+ call = self.call
+ args = []
+ for a in self.args:
+ if IDeferredReference.providedBy(a):
+ a = a(conn)
+ args.append(a)
+ kwargs = {}
+ for k, v in self.kwargs.items():
+ if IDeferredReference.providedBy(v):
+ v = v(conn)
+ kwargs[k] = v
+ return call, args, kwargs
+
+ def _call(self, conn, d):
+ self.attempt_count += 1
+ tm = transaction.interfaces.ITransactionManager(conn)
+ tm.begin() # syncs
+ try:
+ call, args, kwargs = self._resolve(conn)
+ res = call(*args, **kwargs)
+ tm.commit()
+ except ZODB.POSException.TransactionError:
+ tm.abort()
+ db = conn.db()
+ conn.close()
+ if self.attempt_count >= 5: # TODO configurable
+ res = sanitize(twisted.python.failure.Failure())
+ d.errback(res)
+ else:
+ get_connection(db).addCallback(self._call, d)
+ except EXPLOSIVE_ERRORS:
+ tm.abort()
+ conn.close()
+ res = sanitize(twisted.python.failure.Failure())
+ d.errback(res)
+ raise
+ except:
+ tm.abort()
+ conn.close()
+ res = sanitize(twisted.python.failure.Failure())
+ d.errback(res)
+ else:
+ conn.close()
+ if isinstance(res, twisted.python.failure.Failure):
+ d.errback(sanitize(res))
+ elif isinstance(res, twisted.internet.defer.Deferred):
+ res.chainDeferred(d)
+ else: # the caller must not return any persistent objects!
+ d.callback(res)
+
+# also register this for adapting from IConnection
+ at zope.component.adapter(persistent.interfaces.IPersistent)
+ at zope.interface.implementer(transaction.interfaces.ITransactionManager)
+def transactionManager(obj):
+ conn = ZODB.interfaces.IConnection(obj) # typically this will be
+ # zope.app.keyreference.persistent.connectionOfPersistent
+ try:
+ return conn.transaction_manager
+ except AttributeError:
+ return conn._txn_mgr
+ # or else we give up; who knows. transaction_manager is the more
+ # recent spelling.
+
+# very slightly modified from
+# zope.app.keyreference.persistent.connectionOfPersistent; included to
+# reduce dependencies
+ at zope.component.adapter(persistent.interfaces.IPersistent)
+ at zope.interface.implementer(ZODB.interfaces.IConnection)
+def connection(ob):
+ """An adapter which gets a ZODB connection of a persistent object.
+
+ We are assuming the object has a parent if it has been created in
+ this transaction.
+
+ Returns None if it is impossible to get a connection.
+ """
+ cur = ob
+ while getattr(cur, '_p_jar', None) is None:
+ cur = getattr(cur, '__parent__', None)
+ if cur is None:
+ return None
+ return cur._p_jar
+
+# The Twisted Failure __getstate__, which we use in our sanitize function
+# arguably out of paranoia, does a repr of globals and locals. If the repr
+# raises an error, they handle it gracefully. However, if the repr has side
+# effects, they can't know. xmlrpclib unfortunately has this problem as of
+# this writing. This is a monkey patch to turn off this behavior, graciously
+# provided by Florent Guillaume of Nuxeo.
+# XXX see if this can be submitted somewhere as a bug/patch for xmlrpclib
+import xmlrpclib
+def xmlrpc_method_repr(self):
+ return '<xmlrpc._Method %s>' % self._Method__name
+xmlrpclib._Method.__repr__ = xmlrpc_method_repr
+del xmlrpclib
Property changes on: zc.twist/trunk/src/zc/twist/__init__.py
___________________________________________________________________
Name: svn:eol-style
+ native
Added: zc.twist/trunk/src/zc/twist/tests.py
===================================================================
--- zc.twist/trunk/src/zc/twist/tests.py 2006-08-15 20:30:24 UTC (rev 69533)
+++ zc.twist/trunk/src/zc/twist/tests.py 2006-08-15 20:31:28 UTC (rev 69534)
@@ -0,0 +1,24 @@
+import unittest
+
+from zope.testing import doctest, module
+import zope.component.testing
+
+def modSetUp(test):
+ zope.component.testing.setUp(test)
+ module.setUp(test, 'zc.twist.README')
+
+def modTearDown(test):
+ module.tearDown(test)
+ zope.component.testing.tearDown(test)
+
+def test_suite():
+ return unittest.TestSuite((
+ doctest.DocFileSuite(
+ 'README.txt',
+ setUp=modSetUp, tearDown=modTearDown,
+ optionflags=doctest.INTERPRET_FOOTNOTES),
+ ))
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='test_suite')
Property changes on: zc.twist/trunk/src/zc/twist/tests.py
___________________________________________________________________
Name: svn:eol-style
+ native
More information about the Checkins
mailing list