Skip to content

Commit fdc6bc8

Browse files
Fix COPY cleanup when a records generator raises (#1358)
Use a fixed short CopyFail reason instead of formatting the application exception. PostgreSQL limits this message to 10000 bytes, including its length field and trailing NUL. An oversized reason loses protocol synchronisation and can leave transaction rollback waiting indefinitely. Keep the original application exception intact, including when formatting or encoding it would fail. Cover encoded-byte boundaries, multibyte text, sync and async generators, COPY atomicity, rollback and connection reuse. Fixes #1354 Co-authored-by: Elvis Pranskevichus <el@prans.net>
1 parent 6c8f1a7 commit fdc6bc8

2 files changed

Lines changed: 63 additions & 2 deletions

File tree

‎asyncpg/protocol/protocol.pyx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,8 +510,13 @@ cdef class BaseProtocol(CoreProtocol):
510510
else:
511511
raise apg_exc.InternalClientError('TimoutError was not raised')
512512

513-
except (Exception, asyncio.CancelledError) as e:
514-
self._write_copy_fail_msg(str(e))
513+
except (Exception, asyncio.CancelledError):
514+
# CopyFail is limited to 10000 bytes, including its length and
515+
# trailing NUL. Use a fixed reason so formatting or encoding the
516+
# application exception cannot break COPY cleanup. The original
517+
# exception is re-raised below.
518+
self._write_copy_fail_msg(
519+
'COPY aborted due to an exception in input generator')
515520
self._request_cancel()
516521
# Make asyncio shut up about unretrieved QueryCanceledError
517522
waiter.add_done_callback(lambda f: f.exception())

‎tests/test_copy.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,62 @@ async def record_generator():
700700
finally:
701701
await self.con.execute('DROP TABLE copytab_async')
702702

703+
async def test_copy_records_to_table_generator_error(self):
704+
# A 9996-byte reason exceeds PostgreSQL's CopyFail limit.
705+
await self._test_copy_records_generator_error(
706+
ValueError('a' * 9996), asynchronous=False, transaction=True)
707+
708+
async def test_copy_records_to_table_unprintable_error(self):
709+
class UnprintableError(ValueError):
710+
def __str__(self):
711+
raise AssertionError('cannot format exception')
712+
713+
await self._test_copy_records_generator_error(
714+
UnprintableError(), asynchronous=True, transaction=False)
715+
716+
async def _test_copy_records_generator_error(
717+
self, error, asynchronous, transaction):
718+
con = await self.connect()
719+
720+
def records():
721+
# Flush a CopyData message before the generator fails.
722+
yield (b'x' * 524288,)
723+
raise error
724+
725+
async def async_records():
726+
for row in records():
727+
yield row
728+
729+
async def copy():
730+
await con.copy_records_to_table(
731+
'copytab',
732+
records=async_records() if asynchronous else records())
733+
734+
async def run():
735+
await con.execute('CREATE TEMP TABLE copytab (a bytea)')
736+
with self.assertRaises(ValueError) as caught:
737+
if transaction:
738+
async with con.transaction():
739+
await copy()
740+
else:
741+
await copy()
742+
self.assertIs(caught.exception, error)
743+
self.assertFalse(con.is_in_transaction())
744+
self.assertEqual(
745+
await con.fetchval('SELECT count(*) FROM copytab'), 0)
746+
self.assertEqual(await con.copy_records_to_table(
747+
'copytab', records=[(b'recovered',)]), 'COPY 1')
748+
self.assertEqual(
749+
await con.fetchval('SELECT a FROM copytab'), b'recovered')
750+
await con.close()
751+
752+
try:
753+
# Bound recovery and close as well as COPY itself: cancellation
754+
# completion used to hang after an oversized CopyFail message.
755+
await asyncio.wait_for(run(), timeout=5)
756+
finally:
757+
con.terminate()
758+
703759
async def test_copy_records_to_table_stmt_cache(self):
704760
# The introspection statement must be taken from the statement
705761
# cache, otherwise every call pays for an extra round-trip.

0 commit comments

Comments
 (0)