Merge "Implement heartbeat response for COPY request"

This commit is contained in:
Zuul 2025-05-09 17:52:33 +00:00 committed by Gerrit Code Review
commit 2c5bcfdf0a
6 changed files with 292 additions and 66 deletions

View File

@ -194,9 +194,8 @@ swift.source set and the content length will reflect the size of the
payload sent to the proxy (the list of objects/containers to be deleted). payload sent to the proxy (the list of objects/containers to be deleted).
""" """
import json from swift.common.request_helpers import get_heartbeat_response_body
import tarfile import tarfile
from xml.sax.saxutils import escape # nosec B406
from time import time from time import time
from eventlet import sleep from eventlet import sleep
import zlib import zlib
@ -224,50 +223,6 @@ ACCEPTABLE_FORMATS = ['text/plain', 'application/json', 'application/xml',
'text/xml'] 'text/xml']
def get_response_body(data_format, data_dict, error_list, root_tag):
"""
Returns a properly formatted response body according to format.
Handles json and xml, otherwise will return text/plain.
Note: xml response does not include xml declaration.
:params data_format: resulting format
:params data_dict: generated data about results.
:params error_list: list of quoted filenames that failed
:params root_tag: the tag name to use for root elements when returning XML;
e.g. 'extract' or 'delete'
"""
if data_format == 'application/json':
data_dict['Errors'] = error_list
return json.dumps(data_dict).encode('ascii')
if data_format and data_format.endswith('/xml'):
output = ['<', root_tag, '>\n']
for key in sorted(data_dict):
xml_key = key.replace(' ', '_').lower()
output.extend([
'<', xml_key, '>',
escape(str(data_dict[key])),
'</', xml_key, '>\n',
])
output.append('<errors>\n')
for name, status in error_list:
output.extend([
'<object><name>', escape(name), '</name><status>',
escape(status), '</status></object>\n',
])
output.extend(['</errors>\n</', root_tag, '>\n'])
return ''.join(output).encode('utf-8')
output = []
for key in sorted(data_dict):
output.append('%s: %s\n' % (key, data_dict[key]))
output.append('Errors:\n')
output.extend(
'%s, %s\n' % (name, status)
for name, status in error_list)
return ''.join(output).encode('utf-8')
def pax_key_to_swift_header(pax_key): def pax_key_to_swift_header(pax_key):
if (pax_key == u"SCHILY.xattr.user.mime_type" or if (pax_key == u"SCHILY.xattr.user.mime_type" or
pax_key == u"LIBARCHIVE.xattr.user.mime_type"): pax_key == u"LIBARCHIVE.xattr.user.mime_type"):
@ -506,8 +461,9 @@ class Bulk(object):
self.logger.exception('Error in bulk delete.') self.logger.exception('Error in bulk delete.')
resp_dict['Response Status'] = HTTPServerError().status resp_dict['Response Status'] = HTTPServerError().status
yield separator + get_response_body(out_content_type, yield separator + get_heartbeat_response_body(out_content_type,
resp_dict, failed_files, 'delete') resp_dict, failed_files,
'delete')
def handle_extract_iter(self, req, compress_type, def handle_extract_iter(self, req, compress_type,
out_content_type='text/plain'): out_content_type='text/plain'):
@ -671,7 +627,7 @@ class Bulk(object):
self.logger.exception('Error in extract archive.') self.logger.exception('Error in extract archive.')
resp_dict['Response Status'] = HTTPServerError().status resp_dict['Response Status'] = HTTPServerError().status
yield separator + get_response_body( yield separator + get_heartbeat_response_body(
out_content_type, resp_dict, failed_files, 'extract') out_content_type, resp_dict, failed_files, 'extract')
def _process_delete(self, resp, pile, obj_name, resp_dict, def _process_delete(self, resp, pile, obj_name, resp_dict,

View File

@ -125,6 +125,8 @@ from swift.common.request_helpers import copy_header_subset, remove_items, \
is_sys_meta, is_sys_or_user_meta, is_object_transient_sysmeta, \ is_sys_meta, is_sys_or_user_meta, is_object_transient_sysmeta, \
check_path_header, OBJECT_SYSMETA_CONTAINER_UPDATE_OVERRIDE_PREFIX check_path_header, OBJECT_SYSMETA_CONTAINER_UPDATE_OVERRIDE_PREFIX
from swift.common.wsgi import WSGIContext, make_subrequest from swift.common.wsgi import WSGIContext, make_subrequest
import eventlet
from swift.common.request_helpers import get_heartbeat_response_body
def _check_copy_from_header(req): def _check_copy_from_header(req):
@ -175,10 +177,11 @@ def _copy_headers(src, dest):
class ServerSideCopyWebContext(WSGIContext): class ServerSideCopyWebContext(WSGIContext):
def __init__(self, app, logger): def __init__(self, app, logger, yield_frequency=10):
super(ServerSideCopyWebContext, self).__init__(app) super(ServerSideCopyWebContext, self).__init__(app)
self.app = app self.app = app
self.logger = logger self.logger = logger
self.yield_frequency = yield_frequency
def get_source_resp(self, req): def get_source_resp(self, req):
sub_req = make_subrequest( sub_req = make_subrequest(
@ -187,12 +190,73 @@ class ServerSideCopyWebContext(WSGIContext):
return sub_req.get_response(self.app) return sub_req.get_response(self.app)
def send_put_req(self, req, additional_resp_headers, start_response): def send_put_req(self, req, additional_resp_headers, start_response):
app_resp = self._app_call(req.environ) heartbeat = config_true_value(req.params.get('heartbeat'))
self._adjust_put_response(req, additional_resp_headers) ACCEPTABLE_FORMATS = ['text/plain', 'application/json']
start_response(self._response_status,
self._response_headers, try:
self._response_exc_info) out_content_type = req.accept.best_match(ACCEPTABLE_FORMATS)
return app_resp except ValueError:
out_content_type = 'text/plain'
if not out_content_type:
out_content_type = 'text/plain'
if heartbeat:
gt = eventlet.spawn(self._app_call,
req.environ)
start_response('202 Accepted',
[('Content-Type', out_content_type)])
def resp_iter():
# Send an initial heartbeat
yield b' '
app_iter = [b'']
try:
while not gt.dead:
try:
with eventlet.Timeout(self.yield_frequency):
app_iter = gt.wait()
except eventlet.Timeout:
yield b' '
except Exception as e:
# Send back the status to the client if error
self._response_status = '500 Internal Error'
app_iter = [str(e).encode('utf8')]
finally:
response_body = b''.join(app_iter).decode('utf8')
resp_dict = {'Response Status': self._response_status,
'Response Body': response_body}
errors = []
if not is_success(self._get_status_int()):
src_path = additional_resp_headers['X-Copied-From']
errors.append((
wsgi_quote(src_path),
self._response_status,
))
for k, v in additional_resp_headers.items():
if not k.lower().startswith(('x-object-sysmeta-',
'x-backend')):
resp_dict[k] = v
for k, v in self._response_headers:
if not k.lower().startswith(('x-object-sysmeta-',
'x-backend')):
resp_dict[k] = v
yield get_heartbeat_response_body(out_content_type,
resp_dict,
errors, 'copy')
close_if_possible(gt)
return resp_iter()
else:
app_resp = self._app_call(req.environ)
self._adjust_put_response(req, additional_resp_headers)
start_response(self._response_status,
self._response_headers,
self._response_exc_info)
return app_resp
def _adjust_put_response(self, req, additional_resp_headers): def _adjust_put_response(self, req, additional_resp_headers):
if is_success(self._get_status_int()): if is_success(self._get_status_int()):
@ -220,6 +284,7 @@ class ServerSideCopyMiddleware(object):
def __init__(self, app, conf): def __init__(self, app, conf):
self.app = app self.app = app
self.logger = get_logger(conf, log_route="copy") self.logger = get_logger(conf, log_route="copy")
self.yield_frequency = int(conf.get('yield_frequency', 10))
def __call__(self, env, start_response): def __call__(self, env, start_response):
req = Request(env) req = Request(env)
@ -333,6 +398,15 @@ class ServerSideCopyMiddleware(object):
'body', request=req, 'body', request=req,
content_type='text/plain')(req.environ, content_type='text/plain')(req.environ,
start_response) start_response)
# If heartbeat is enabled, set minimum_write_chunk_size directly
# in the original client request before making subrequests
if config_true_value(req.params.get('heartbeat')):
wsgi_input = req.environ.get('wsgi.input')
if hasattr(wsgi_input, 'environ'):
wsgi_input.environ['eventlet.minimum_write_chunk_size'] = 0
# Not sure if we also need to set it in
# the current request's environ
req.environ['eventlet.minimum_write_chunk_size'] = 0
# Form the path of source object to be fetched # Form the path of source object to be fetched
ver, acct, _rest = req.split_path(2, 3, True) ver, acct, _rest = req.split_path(2, 3, True)
@ -347,7 +421,8 @@ class ServerSideCopyMiddleware(object):
src_container_name, src_obj_name) src_container_name, src_obj_name)
# GET the source object, bail out on error # GET the source object, bail out on error
ssc_ctx = ServerSideCopyWebContext(self.app, self.logger) ssc_ctx = ServerSideCopyWebContext(self.app, self.logger,
self.yield_frequency)
source_resp = self._get_source_object(ssc_ctx, source_path, req) source_resp = self._get_source_object(ssc_ctx, source_path, req)
if source_resp.status_int >= HTTP_MULTIPLE_CHOICES: if source_resp.status_int >= HTTP_MULTIPLE_CHOICES:
return source_resp(source_resp.environ, start_response) return source_resp(source_resp.environ, start_response)
@ -434,6 +509,17 @@ class ServerSideCopyMiddleware(object):
source_resp, sink_req) source_resp, sink_req)
put_resp = ssc_ctx.send_put_req(sink_req, resp_headers, start_response) put_resp = ssc_ctx.send_put_req(sink_req, resp_headers, start_response)
# For heartbeat=on, we need to cleanup the resp iter
if config_true_value(req.params.get('heartbeat')):
def clean_iter(app_iter):
try:
for chunk in app_iter:
yield chunk
finally:
close_if_possible(source_resp.app_iter)
return clean_iter(put_resp)
close_if_possible(source_resp.app_iter) close_if_possible(source_resp.app_iter)
return put_resp return put_resp

View File

@ -366,13 +366,12 @@ from swift.common.registry import register_swift_info
from swift.common.request_helpers import SegmentedIterable, \ from swift.common.request_helpers import SegmentedIterable, \
get_sys_meta_prefix, update_etag_is_at_header, resolve_etag_is_at_header, \ get_sys_meta_prefix, update_etag_is_at_header, resolve_etag_is_at_header, \
get_container_update_override_key, update_ignore_range_header, \ get_container_update_override_key, update_ignore_range_header, \
get_param, get_valid_part_num get_param, get_valid_part_num, get_heartbeat_response_body
from swift.common.constraints import check_utf8 from swift.common.constraints import check_utf8
from swift.common.http import HTTP_NOT_FOUND, HTTP_UNAUTHORIZED from swift.common.http import HTTP_NOT_FOUND, HTTP_UNAUTHORIZED
from swift.common.wsgi import WSGIContext, make_subrequest, make_env, \ from swift.common.wsgi import WSGIContext, make_subrequest, make_env, \
make_pre_authed_request make_pre_authed_request
from swift.common.middleware.bulk import get_response_body, \ from swift.common.middleware.bulk import ACCEPTABLE_FORMATS, Bulk
ACCEPTABLE_FORMATS, Bulk
from swift.obj import expirer from swift.obj import expirer
from swift.proxy.controllers.base import get_container_info from swift.proxy.controllers.base import get_container_info
@ -1527,7 +1526,7 @@ class StaticLargeObject(object):
start_response(err.status, start_response(err.status,
[(h, v) for h, v in err.headers.items() [(h, v) for h, v in err.headers.items()
if h.lower() != 'content-length']) if h.lower() != 'content-length'])
yield separator + get_response_body( yield separator + get_heartbeat_response_body(
out_content_type, resp_dict, problem_segments, 'upload') out_content_type, resp_dict, problem_segments, 'upload')
return return
@ -1554,7 +1553,7 @@ class StaticLargeObject(object):
err_body = err_body.decode('utf-8', errors='replace') err_body = err_body.decode('utf-8', errors='replace')
resp_dict['Response Body'] = err_body or '\n'.join( resp_dict['Response Body'] = err_body or '\n'.join(
RESPONSE_REASONS.get(err.status_int, [''])) RESPONSE_REASONS.get(err.status_int, ['']))
yield separator + get_response_body( yield separator + get_heartbeat_response_body(
out_content_type, resp_dict, problem_segments, out_content_type, resp_dict, problem_segments,
'upload') 'upload')
else: else:
@ -1600,7 +1599,7 @@ class StaticLargeObject(object):
if isinstance(resp_body, bytes): if isinstance(resp_body, bytes):
resp_body = resp_body.decode('utf-8') resp_body = resp_body.decode('utf-8')
resp_dict['Response Body'] = resp_body resp_dict['Response Body'] = resp_body
yield separator + get_response_body( yield separator + get_heartbeat_response_body(
out_content_type, resp_dict, [], 'upload') out_content_type, resp_dict, [], 'upload')
else: else:
for chunk in resp(req.environ, start_response): for chunk in resp(req.environ, start_response):

View File

@ -22,6 +22,8 @@ from swob in here without creating circular imports.
import itertools import itertools
import time import time
import json
from xml.sax.saxutils import escape # nosec B406
from swift.common.header_key_dict import HeaderKeyDict from swift.common.header_key_dict import HeaderKeyDict
@ -1005,3 +1007,47 @@ def append_log_info(environ, log_info):
def get_log_info(environ): def get_log_info(environ):
return ','.join(environ.get('swift.log_info', [])) return ','.join(environ.get('swift.log_info', []))
def get_heartbeat_response_body(data_format, data_dict, error_list, root_tag):
"""
Returns a response body for heartbeat according to format.
Handles json and xml, otherwise will return text/plain.
Note: xml response does not include xml declaration.
:params data_format: resulting format
:params data_dict: generated data about results.
:params error_list: list of quoted filenames that failed
:params root_tag: the tag name to use for root elements when returning XML;
e.g. 'extract' or 'delete'
"""
if data_format == 'application/json':
data_dict['Errors'] = error_list
return json.dumps(data_dict).encode('ascii')
if data_format and data_format.endswith('/xml'):
output = ['<', root_tag, '>\n']
for key in sorted(data_dict):
xml_key = key.replace(' ', '_').lower()
output.extend([
'<', xml_key, '>',
escape(str(data_dict[key])),
'</', xml_key, '>\n',
])
output.append('<errors>\n')
for name, status in error_list:
output.extend([
'<object><name>', escape(name), '</name><status>',
escape(status), '</status></object>\n',
])
output.extend(['</errors>\n</', root_tag, '>\n'])
return ''.join(output).encode('utf-8')
output = []
for key in sorted(data_dict):
output.append('%s: %s\n' % (key, data_dict[key]))
output.append('Errors:\n')
output.extend(
'%s, %s\n' % (name, status)
for name, status in error_list)
return ''.join(output).encode('utf-8')

View File

@ -645,11 +645,11 @@ class TestUntar(unittest.TestCase):
]) ])
def test_get_response_body(self): def test_get_response_body(self):
txt_body = bulk.get_response_body( txt_body = bulk.get_heartbeat_response_body(
'bad_formay', {'hey': 'there'}, [['json > xml', '202 Accepted']], 'bad_formay', {'hey': 'there'}, [['json > xml', '202 Accepted']],
"doesn't matter for text") "doesn't matter for text")
self.assertIn(b'hey: there', txt_body) self.assertIn(b'hey: there', txt_body)
xml_body = bulk.get_response_body( xml_body = bulk.get_heartbeat_response_body(
'text/xml', {'hey': 'there'}, [['json > xml', '202 Accepted']], 'text/xml', {'hey': 'there'}, [['json > xml', '202 Accepted']],
'root_tag') 'root_tag')
self.assertIn(b'&gt', xml_body) self.assertIn(b'&gt', xml_body)

View File

@ -17,12 +17,13 @@
from unittest import mock from unittest import mock
import unittest import unittest
import urllib.parse import urllib.parse
import eventlet
from swift.common import swob from swift.common import swob
from swift.common.middleware import copy from swift.common.middleware import copy
from swift.common.storage_policy import POLICIES from swift.common.storage_policy import POLICIES
from swift.common.swob import Request, HTTPException from swift.common.swob import Request, HTTPException
from swift.common.utils import closing_if_possible, md5 from swift.common.utils import close_if_possible, closing_if_possible, md5
from test.debug_logger import debug_logger from test.debug_logger import debug_logger
from test.unit import patch_policies, FakeRing from test.unit import patch_policies, FakeRing
from test.unit.common.middleware.helpers import FakeSwift from test.unit.common.middleware.helpers import FakeSwift
@ -1426,3 +1427,141 @@ class TestServerSideCopyMiddlewareWithEC(unittest.TestCase):
self.assertEqual(resp.body, range_not_satisfiable_body) self.assertEqual(resp.body, range_not_satisfiable_body)
self.assertEqual(resp.etag, body_etag) self.assertEqual(resp.etag, body_etag)
self.assertEqual(resp.headers['Accept-Ranges'], 'bytes') self.assertEqual(resp.headers['Accept-Ranges'], 'bytes')
class TestServerSideCopyHeartbeat(unittest.TestCase):
def setUp(self):
self.app = FakeSwift()
self.ssc = copy.filter_factory({'yield_frequency': '1'})(self.app)
def tearDown(self):
pass
def call_app(self, req, app=None):
if app is None:
app = self.app
self.authorized = []
def authorize(req):
self.authorized.append(req)
if 'swift.authorize' not in req.environ:
req.environ['swift.authorize'] = authorize
req.headers.setdefault("User-Agent", "Test User Agent")
status = [None]
headers = [None]
def start_response(s, h, ei=None):
status[0] = s
headers[0] = h
body_iter = app(req.environ, start_response)
body = b''
try:
for chunk in body_iter:
body += chunk
finally:
close_if_possible(body_iter)
return status[0], headers[0], body
def test_copy_with_heartbeat_success(self):
original_spawn = eventlet.spawn
self.app.register('GET', '/v1/a/c/o?heartbeat=true', swob.HTTPOk,
{'Content-Length': '10'}, b'X' * 10)
self.app.register('PUT', '/v1/a/c/o2?heartbeat=true',
swob.HTTPCreated, {})
heartbeats = []
def mock_spawn(func, *args, **kwargs):
def delayed_func(*a, **kw):
eventlet.sleep(2.5)
return func(*a, **kw)
return original_spawn(delayed_func, *args, **kwargs)
req = swob.Request.blank(
'/v1/a/c/o2?heartbeat=true',
environ={'REQUEST_METHOD': 'PUT'},
headers={'Content-Length': '0', 'X-Copy-From': 'c/o'})
with mock.patch('eventlet.spawn', mock_spawn):
status = [None]
headers_list = [None]
def start_response(s, h, ei=None):
status[0] = s
headers_list[0] = h
body_iter = self.ssc(req.environ, start_response)
self.assertEqual('202 Accepted', status[0])
try:
for chunk in body_iter:
heartbeats.append(chunk)
finally:
close_if_possible(body_iter)
self.assertTrue(len(heartbeats) >= 3,
f"Expected 3 heartbeats, got {len(heartbeats)}")
self.assertEqual(heartbeats[0], b' ')
for i in range(1, len(heartbeats) - 1):
self.assertEqual(heartbeats[i], b' ')
self.assertIn(b'201 Created', heartbeats[-1])
self.assertEqual(req.environ.get('eventlet.minimum_write_chunk_size'),
0)
self.assertEqual(2, len(self.app.calls))
self.assertEqual(('GET', '/v1/a/c/o?heartbeat=true'),
self.app.calls[0])
self.assertEqual(('PUT', '/v1/a/c/o2?heartbeat=true'),
self.app.calls[1])
def test_copy_with_heartbeat_failure(self):
original_spawn = eventlet.spawn
self.app.register('GET', '/v1/a/c/o?heartbeat=true', swob.HTTPOk,
{'Content-Length': '10'}, b'X' * 10)
self.app.register('PUT', '/v1/a/c/o2?heartbeat=true',
swob.HTTPServiceUnavailable, {})
heartbeats = []
def mock_spawn(func, *args, **kwargs):
def delayed_func(*a, **kw):
eventlet.sleep(2.5)
return func(*a, **kw)
return original_spawn(delayed_func, *args, **kwargs)
req = swob.Request.blank(
'/v1/a/c/o2?heartbeat=true',
environ={'REQUEST_METHOD': 'PUT'},
headers={'Content-Length': '0', 'X-Copy-From': 'c/o'})
with mock.patch('eventlet.spawn', mock_spawn):
status = [None]
headers_list = [None]
def start_response(s, h, ei=None):
status[0] = s
headers_list[0] = h
body_iter = self.ssc(req.environ, start_response)
self.assertEqual('202 Accepted', status[0])
try:
for chunk in body_iter:
heartbeats.append(chunk)
finally:
close_if_possible(body_iter)
self.assertTrue(len(heartbeats) >= 3,
f"Expected 3 heartbeats, got {len(heartbeats)}")
self.assertEqual(heartbeats[0], b' ')
for i in range(1, len(heartbeats) - 1):
self.assertEqual(heartbeats[i], b' ')
self.assertIn(b'503 Service Unavailable', heartbeats[-1])
self.assertEqual(req.environ.get('eventlet.minimum_write_chunk_size'),
0)
self.assertEqual(2, len(self.app.calls))
self.assertEqual(('GET', '/v1/a/c/o?heartbeat=true'),
self.app.calls[0])
self.assertEqual(('PUT', '/v1/a/c/o2?heartbeat=true'),
self.app.calls[1])