-
-
Notifications
You must be signed in to change notification settings - Fork 503
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for streaming replication protocol #322
Changes from 1 commit
e32e1b8
80da76d
44b705f
f14521f
50df864
f7b84ce
453830f
1ac385d
9fc5bf4
35a3262
9ed90b1
e3c3a2c
61e52ce
058db56
318706f
0d731aa
9386653
dab41c6
9c1f2ac
06f1823
eac16d0
26fe1f2
862eda1
f872a2a
937a7a9
95ee218
cac83da
0233620
ea2b87e
6ad2999
5407907
fea2260
a0b42a1
e05b4fd
822d671
e3097ec
28a1a00
9ab38ee
8e518d4
d14fea3
cf4f241
0435320
4ab7cf0
7aea2ce
0bb81fc
23abe4f
b3f8e9a
089e745
22cbfb2
76c7f4a
e69dafb
dd6bcbd
8b79bf4
4b9a6f4
7aba8b3
433fb95
fbcf99a
e61db57
09a4bb7
5d33b39
cb70325
da6e061
1d52f34
2de2ed7
b21c8f7
3f10b4d
a7887fa
d5443c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -141,6 +141,128 @@ Logging cursor | |
|
||
.. autoclass:: MinTimeLoggingCursor | ||
|
||
Replication cursor | ||
^^^^^^^^^^^^^^^^^^ | ||
|
||
.. autoclass:: ReplicationConnection | ||
|
||
This connection factory class can be used to open a special type of | ||
connection that is used for streaming replication. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should note that normal SQL is not permitted or supported on a replication connection. |
||
|
||
Example:: | ||
|
||
from psycopg2.extras import ReplicationConnection, REPLICATION_PHYSICAL, REPLICATION_LOGICAL | ||
conn = psycopg2.connect(dsn, connection_factory=ReplicationConnection) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does the user need to supply If connecting to 9.3 or older, which don't support (I'm intentionally reading docs before code so I see from a user perspective and know what the docs don't cover). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First, thanks for your interest! We add the |
||
cur = conn.cursor() | ||
|
||
.. seealso:: | ||
|
||
- PostgreSQL `Replication protocol`__ | ||
|
||
.. __: http://www.postgresql.org/docs/current/static/protocol-replication.html | ||
|
||
.. autoclass:: ReplicationCursor | ||
|
||
.. method:: identify_system() | ||
|
||
Get information about the cluster status in form of a dict with | ||
``systemid``, ``timeline``, ``xlogpos`` and ``dbname`` as keys. | ||
|
||
Example:: | ||
|
||
>>> print cur.identify_system() | ||
{'timeline': 1, 'systemid': '1234567890123456789', 'dbname': 'test', 'xlogpos': '0/1ABCDEF'} | ||
|
||
.. method:: create_replication_slot(slot_type, slot_name, output_plugin=None) | ||
|
||
Create streaming replication slot. | ||
|
||
:param slot_type: type of replication: either `REPLICATION_PHYSICAL` or | ||
`REPLICATION_LOGICAL` | ||
:param slot_name: name of the replication slot to be created | ||
:param output_plugin: name of the logical decoding output plugin to use | ||
(logical replication only) | ||
|
||
Example:: | ||
|
||
cur.create_replication_slot(REPLICATION_LOGICAL, "testslot", "test_decoding") | ||
|
||
.. method:: drop_replication_slot(slot_name) | ||
|
||
Drop streaming replication slot. | ||
|
||
:param slot_name: name of the replication slot to drop | ||
|
||
Example:: | ||
|
||
cur.drop_replication_slot("testslot") | ||
|
||
.. method:: start_replication(file, slot_type, slot_name=None, start_lsn=None, timeline=0, keepalive_interval=10, options=None) | ||
|
||
Start and consume replication stream. | ||
|
||
:param file: a file-like object to write replication stream messages to | ||
:param slot_type: type of replication: either `REPLICATION_PHYSICAL` or | ||
`REPLICATION_LOGICAL` | ||
:param slot_name: name of the replication slot to use (required for | ||
logical replication) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For "physical" replication, no slot is required; it's permissible to replicate w/o a slot. It'd be nice to expose this in the API, e.g. by passing |
||
:param start_lsn: the point in replication stream (WAL position) to start | ||
from, in the form ``XXX/XXX`` (forward-slash separated | ||
pair of hexadecimals) | ||
:param timeline: WAL history timeline to start streaming from (optional, | ||
can only be used with physical replication) | ||
:param keepalive_interval: interval (in seconds) to send keepalive | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be documented as "synchronous mode only, ignored for asynchronous mode")? Or is it used? If so, how? |
||
messages to the server, in case there was no | ||
communication during that period of time | ||
:param options: an dictionary of options to pass to logical replication | ||
slot | ||
|
||
The ``keepalive_interval`` must be greater than zero. | ||
|
||
This method never returns unless an error message is sent from the | ||
server, or the server closes connection, or there is an exception in the | ||
``write()`` method of the ``file`` object. | ||
|
||
One can even use ``sys.stdout`` as the destination (this is only good for | ||
testing purposes, however):: | ||
|
||
>>> cur.start_replication(sys.stdout, "testslot") | ||
... | ||
|
||
This method acts much like the `~cursor.copy_to()` with an important | ||
distinction that ``write()`` method return value is dirving the | ||
server-side replication cursor. In order to report to the server that | ||
the all the messages up to the current one have been stored reliably, one | ||
should return true value (i.e. something that satisfies ``if retval:`` | ||
conidtion) from the ``write`` callback:: | ||
|
||
class ReplicationStreamWriter(object): | ||
def write(self, msg): | ||
if store_message_reliably(msg): | ||
return True | ||
|
||
cur.start_replication(writer, "testslot") | ||
... | ||
|
||
.. note:: | ||
|
||
One needs to be aware that failure to update the server-side cursor | ||
on any one replication slot properly by constantly consuming and | ||
reporting success to the server can eventually lead to "disk full" | ||
condition on the server, because the server retains all the WAL | ||
segments that might be needed to stream the changes via currently | ||
open replication slots. | ||
|
||
Drop any open replication slots that are no longer being used. The | ||
list of open slots can be obtained by running a query like ``SELECT * | ||
FROM pg_replication_slots``. | ||
|
||
.. data:: REPLICATION_PHYSICAL | ||
|
||
.. data:: REPLICATION_LOGICAL | ||
|
||
.. index:: | ||
pair: Cursor; Replication | ||
|
||
|
||
.. index:: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -437,6 +437,144 @@ def callproc(self, procname, vars=None): | |
return LoggingCursor.callproc(self, procname, vars) | ||
|
||
|
||
class ReplicationConnection(_connection): | ||
"""A connection that uses `ReplicationCursor` automatically.""" | ||
|
||
def __init__(self, *args, **kwargs): | ||
"""Initializes a replication connection, by adding appropriate replication parameter to the provided dsn arguments.""" | ||
|
||
if len(args): | ||
dsn = args[0] | ||
|
||
# FIXME: could really use parse_dsn here | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, needs fixing before merge really |
||
|
||
if dsn.startswith('postgres://') or dsn.startswith('postgresql://'): | ||
# poor man's url parsing | ||
if dsn.rfind('?') > 0: | ||
if not dsn.endswith('?'): | ||
dsn += '&' | ||
else: | ||
dsn += '?' | ||
else: | ||
dsn += ' ' | ||
dsn += 'replication=database' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. replication=database won't work on 9.3 or older, fwiw... but it's useful to have replication=true supported for 9.4+ too, because that lets you connect without knowing a particular database name when you plan to do streaming replication. I suggest appending |
||
args = [dsn] + list(args[1:]) | ||
else: | ||
dbname = kwargs.get('dbname', None) | ||
if dbname is None: | ||
kwargs['dbname'] = 'replication' | ||
|
||
if kwargs.get('replication', None) is None: | ||
kwargs['replication'] = 'database' if dbname else 'true' | ||
|
||
super(ReplicationConnection, self).__init__(*args, **kwargs) | ||
|
||
# prevent auto-issued BEGIN statements | ||
self.autocommit = True | ||
|
||
def cursor(self, *args, **kwargs): | ||
kwargs.setdefault('cursor_factory', ReplicationCursor) | ||
return super(ReplicationConnection, self).cursor(*args, **kwargs) | ||
|
||
|
||
"""Streamging replication types.""" | ||
REPLICATION_PHYSICAL = 0 | ||
REPLICATION_LOGICAL = 1 | ||
|
||
class ReplicationCursor(_cursor): | ||
"""A cursor used for replication commands.""" | ||
|
||
def identify_system(self): | ||
"""Get information about the cluster status.""" | ||
|
||
self.execute("IDENTIFY_SYSTEM") | ||
return dict(zip(['systemid', 'timeline', 'xlogpos', 'dbname'], | ||
self.fetchall()[0])) | ||
|
||
def quote_ident(self, ident): | ||
# FIXME: use PQescapeIdentifier or psycopg_escape_identifier_easy, somehow | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When this will be released we would have PQescapeIdentifier exposed, so yes, it will happen. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why's this exposed on the This is an oft-requested piece of functionality, but it should really be on the base psycopg2 Connection object IMO. |
||
return '"%s"' % ident.replace('"', '""') | ||
|
||
def create_replication_slot(self, slot_type, slot_name, output_plugin=None): | ||
"""Create streaming replication slot.""" | ||
|
||
command = "CREATE_REPLICATION_SLOT %s " % self.quote_ident(slot_name) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like some more validation's needed here. Allowing
which isn't very enlightening. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slot name is optional in case of |
||
|
||
if slot_type == REPLICATION_LOGICAL: | ||
if output_plugin is None: | ||
raise RuntimeError("output_plugin is required for logical replication slot") | ||
|
||
command += "LOGICAL %s" % self.quote_ident(output_plugin) | ||
|
||
elif slot_type == REPLICATION_PHYSICAL: | ||
if output_plugin is not None: | ||
raise RuntimeError("output_plugin is not applicable to physical replication") | ||
|
||
command += "PHYSICAL" | ||
|
||
else: | ||
raise RuntimeError("unrecognized replication slot type") | ||
|
||
return self.execute(command) | ||
|
||
def drop_replication_slot(self, slot_name): | ||
"""Drop streaming replication slot.""" | ||
|
||
command = "DROP_REPLICATION_SLOT %s" % self.quote_ident(slot_name) | ||
return self.execute(command) | ||
|
||
def start_replication(self, o, slot_type, slot_name=None, start_lsn=None, | ||
timeline=0, keepalive_interval=10, options=None): | ||
"""Start and consume replication stream.""" | ||
|
||
if keepalive_interval <= 0: | ||
raise RuntimeError("keepalive_interval must be > 0: %d" % keepalive_interval) | ||
|
||
command = "START_REPLICATION " | ||
|
||
if slot_type == REPLICATION_LOGICAL and slot_name is None: | ||
raise RuntimeError("slot_name is required for logical replication slot") | ||
|
||
if slot_name: | ||
command += "SLOT %s " % self.quote_ident(slot_name) | ||
|
||
if slot_type == REPLICATION_LOGICAL: | ||
command += "LOGICAL " | ||
elif slot_type == REPLICATION_PHYSICAL: | ||
command += "PHYSICAL " | ||
else: | ||
raise RuntimeError("unrecognized replication slot type") | ||
|
||
if start_lsn is None: | ||
start_lsn = '0/0' | ||
|
||
# reparse lsn to catch possible garbage | ||
lsn = start_lsn.split('/') | ||
command += "%X/%X" % (int(lsn[0], 16), int(lsn[1], 16)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO the LSN should internally be a uint64, formatted in Pg's x/y format for output on the wire. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we would store this in an object attribute or pass the arg to C code, yes. But it is so much easier to just construct the replication protocol command in Python, so there's no actual difference: we append it to the string and that's it. |
||
|
||
if timeline != 0: | ||
if slot_type == REPLICATION_LOGICAL: | ||
raise RuntimeError("cannot specify timeline for logical replication") | ||
|
||
if timeline < 0: | ||
raise RuntimeError("timeline must be >= 0: %d" % timeline) | ||
|
||
command += " TIMELINE %d" % timeline | ||
|
||
if options: | ||
if slot_type == REPLICATION_PHYSICAL: | ||
raise RuntimeError("cannot specify plugin options for physical replication") | ||
|
||
command += " (" | ||
for k,v in options.iteritems(): | ||
if not command.endswith('('): | ||
command += ", " | ||
command += "%s %s" % (self.quote_ident(k), _A(str(v)).getquoted()) | ||
command += ")" | ||
|
||
return self.start_replication_expert(o, command, keepalive_interval) | ||
|
||
|
||
# a dbtype and adapter for Python UUID type | ||
|
||
class UUID_adapter(object): | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick start for docs:
You must be using PostgreSQL 9.4 or above to run this quick start.
Make sure that
replication
connections are permitted for userpostgres
fromlocalhost
inpg_hba.conf
thenSELECT pg_reload_conf()
. Create a databasepsycopg2test
.Then run something like the following to quickly try the replication support out. This is not production code - it has no error handling, it sends feedback too often, etc - and it's only intended as a simple demo of logical replication functionality.
This will continue running until cancelled with control-C.
You can now make changes to the
psycopg2test
database using a normalpsycopg2
session,psql
, etc and see the logical decoding stream printed by this demo client.