init
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
__title__ = 'kafka'
|
||||
from kafka.version import __version__
|
||||
__author__ = 'Dana Powers'
|
||||
__license__ = 'Apache License 2.0'
|
||||
__copyright__ = 'Copyright 2025 Dana Powers, David Arthur, and Contributors'
|
||||
|
||||
# Set default logging handler to avoid "No handler found" warnings.
|
||||
import logging
|
||||
try: # Python 2.7+
|
||||
from logging import NullHandler
|
||||
except ImportError:
|
||||
class NullHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
pass
|
||||
|
||||
logging.getLogger(__name__).addHandler(NullHandler())
|
||||
|
||||
|
||||
from kafka.admin import KafkaAdminClient
|
||||
from kafka.client_async import KafkaClient
|
||||
from kafka.consumer import KafkaConsumer
|
||||
from kafka.consumer.subscription_state import ConsumerRebalanceListener
|
||||
from kafka.producer import KafkaProducer
|
||||
from kafka.conn import BrokerConnection
|
||||
from kafka.serializer import Serializer, Deserializer
|
||||
from kafka.structs import TopicPartition, OffsetAndMetadata
|
||||
|
||||
|
||||
__all__ = [
|
||||
'BrokerConnection', 'ConsumerRebalanceListener', 'KafkaAdminClient',
|
||||
'KafkaClient', 'KafkaConsumer', 'KafkaProducer',
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.admin.config_resource import ConfigResource, ConfigResourceType
|
||||
from kafka.admin.client import KafkaAdminClient
|
||||
from kafka.admin.acl_resource import (ACL, ACLFilter, ResourcePattern, ResourcePatternFilter, ACLOperation,
|
||||
ResourceType, ACLPermissionType, ACLResourcePatternType)
|
||||
from kafka.admin.new_topic import NewTopic
|
||||
from kafka.admin.new_partitions import NewPartitions
|
||||
|
||||
__all__ = [
|
||||
'ConfigResource', 'ConfigResourceType', 'KafkaAdminClient', 'NewTopic', 'NewPartitions', 'ACL', 'ACLFilter',
|
||||
'ResourcePattern', 'ResourcePatternFilter', 'ACLOperation', 'ResourceType', 'ACLPermissionType',
|
||||
'ACLResourcePatternType'
|
||||
]
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import absolute_import
|
||||
from kafka.errors import IllegalArgumentError
|
||||
|
||||
# enum in stdlib as of py3.4
|
||||
try:
|
||||
from enum import IntEnum # pylint: disable=import-error
|
||||
except ImportError:
|
||||
# vendored backport module
|
||||
from kafka.vendor.enum34 import IntEnum
|
||||
|
||||
|
||||
class ResourceType(IntEnum):
|
||||
"""Type of kafka resource to set ACL for
|
||||
|
||||
The ANY value is only valid in a filter context
|
||||
"""
|
||||
|
||||
UNKNOWN = 0,
|
||||
ANY = 1,
|
||||
CLUSTER = 4,
|
||||
DELEGATION_TOKEN = 6,
|
||||
GROUP = 3,
|
||||
TOPIC = 2,
|
||||
TRANSACTIONAL_ID = 5
|
||||
|
||||
|
||||
class ACLOperation(IntEnum):
|
||||
"""Type of operation
|
||||
|
||||
The ANY value is only valid in a filter context
|
||||
"""
|
||||
|
||||
ANY = 1,
|
||||
ALL = 2,
|
||||
READ = 3,
|
||||
WRITE = 4,
|
||||
CREATE = 5,
|
||||
DELETE = 6,
|
||||
ALTER = 7,
|
||||
DESCRIBE = 8,
|
||||
CLUSTER_ACTION = 9,
|
||||
DESCRIBE_CONFIGS = 10,
|
||||
ALTER_CONFIGS = 11,
|
||||
IDEMPOTENT_WRITE = 12
|
||||
|
||||
|
||||
class ACLPermissionType(IntEnum):
|
||||
"""An enumerated type of permissions
|
||||
|
||||
The ANY value is only valid in a filter context
|
||||
"""
|
||||
|
||||
ANY = 1,
|
||||
DENY = 2,
|
||||
ALLOW = 3
|
||||
|
||||
|
||||
class ACLResourcePatternType(IntEnum):
|
||||
"""An enumerated type of resource patterns
|
||||
|
||||
More details on the pattern types and how they work
|
||||
can be found in KIP-290 (Support for prefixed ACLs)
|
||||
https://cwiki.apache.org/confluence/display/KAFKA/KIP-290%3A+Support+for+Prefixed+ACLs
|
||||
"""
|
||||
|
||||
ANY = 1,
|
||||
MATCH = 2,
|
||||
LITERAL = 3,
|
||||
PREFIXED = 4
|
||||
|
||||
|
||||
class ACLFilter(object):
|
||||
"""Represents a filter to use with describing and deleting ACLs
|
||||
|
||||
The difference between this class and the ACL class is mainly that
|
||||
we allow using ANY with the operation, permission, and resource type objects
|
||||
to fetch ALCs matching any of the properties.
|
||||
|
||||
To make a filter matching any principal, set principal to None
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
principal,
|
||||
host,
|
||||
operation,
|
||||
permission_type,
|
||||
resource_pattern
|
||||
):
|
||||
self.principal = principal
|
||||
self.host = host
|
||||
self.operation = operation
|
||||
self.permission_type = permission_type
|
||||
self.resource_pattern = resource_pattern
|
||||
|
||||
self.validate()
|
||||
|
||||
def validate(self):
|
||||
if not isinstance(self.operation, ACLOperation):
|
||||
raise IllegalArgumentError("operation must be an ACLOperation object, and cannot be ANY")
|
||||
if not isinstance(self.permission_type, ACLPermissionType):
|
||||
raise IllegalArgumentError("permission_type must be an ACLPermissionType object, and cannot be ANY")
|
||||
if not isinstance(self.resource_pattern, ResourcePatternFilter):
|
||||
raise IllegalArgumentError("resource_pattern must be a ResourcePatternFilter object")
|
||||
|
||||
def __repr__(self):
|
||||
return "<ACL principal={principal}, resource={resource}, operation={operation}, type={type}, host={host}>".format(
|
||||
principal=self.principal,
|
||||
host=self.host,
|
||||
operation=self.operation.name,
|
||||
type=self.permission_type.name,
|
||||
resource=self.resource_pattern
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
return all((
|
||||
self.principal == other.principal,
|
||||
self.host == other.host,
|
||||
self.operation == other.operation,
|
||||
self.permission_type == other.permission_type,
|
||||
self.resource_pattern == other.resource_pattern
|
||||
))
|
||||
|
||||
def __hash__(self):
|
||||
return hash((
|
||||
self.principal,
|
||||
self.host,
|
||||
self.operation,
|
||||
self.permission_type,
|
||||
self.resource_pattern,
|
||||
))
|
||||
|
||||
|
||||
class ACL(ACLFilter):
|
||||
"""Represents a concrete ACL for a specific ResourcePattern
|
||||
|
||||
In kafka an ACL is a 4-tuple of (principal, host, operation, permission_type)
|
||||
that limits who can do what on a specific resource (or since KIP-290 a resource pattern)
|
||||
|
||||
Terminology:
|
||||
Principal -> This is the identifier for the user. Depending on the authorization method used (SSL, SASL etc)
|
||||
the principal will look different. See http://kafka.apache.org/documentation/#security_authz for details.
|
||||
The principal must be on the format "User:<name>" or kafka will treat it as invalid. It's possible to use
|
||||
other principal types than "User" if using a custom authorizer for the cluster.
|
||||
Host -> This must currently be an IP address. It cannot be a range, and it cannot be a domain name.
|
||||
It can be set to "*", which is special cased in kafka to mean "any host"
|
||||
Operation -> Which client operation this ACL refers to. Has different meaning depending
|
||||
on the resource type the ACL refers to. See https://docs.confluent.io/current/kafka/authorization.html#acl-format
|
||||
for a list of which combinations of resource/operation that unlocks which kafka APIs
|
||||
Permission Type: Whether this ACL is allowing or denying access
|
||||
Resource Pattern -> This is a representation of the resource or resource pattern that the ACL
|
||||
refers to. See the ResourcePattern class for details.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
principal,
|
||||
host,
|
||||
operation,
|
||||
permission_type,
|
||||
resource_pattern
|
||||
):
|
||||
super(ACL, self).__init__(principal, host, operation, permission_type, resource_pattern)
|
||||
self.validate()
|
||||
|
||||
def validate(self):
|
||||
if self.operation == ACLOperation.ANY:
|
||||
raise IllegalArgumentError("operation cannot be ANY")
|
||||
if self.permission_type == ACLPermissionType.ANY:
|
||||
raise IllegalArgumentError("permission_type cannot be ANY")
|
||||
if not isinstance(self.resource_pattern, ResourcePattern):
|
||||
raise IllegalArgumentError("resource_pattern must be a ResourcePattern object")
|
||||
|
||||
|
||||
class ResourcePatternFilter(object):
|
||||
def __init__(
|
||||
self,
|
||||
resource_type,
|
||||
resource_name,
|
||||
pattern_type
|
||||
):
|
||||
self.resource_type = resource_type
|
||||
self.resource_name = resource_name
|
||||
self.pattern_type = pattern_type
|
||||
|
||||
self.validate()
|
||||
|
||||
def validate(self):
|
||||
if not isinstance(self.resource_type, ResourceType):
|
||||
raise IllegalArgumentError("resource_type must be a ResourceType object")
|
||||
if not isinstance(self.pattern_type, ACLResourcePatternType):
|
||||
raise IllegalArgumentError("pattern_type must be an ACLResourcePatternType object")
|
||||
|
||||
def __repr__(self):
|
||||
return "<ResourcePattern type={}, name={}, pattern={}>".format(
|
||||
self.resource_type.name,
|
||||
self.resource_name,
|
||||
self.pattern_type.name
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
return all((
|
||||
self.resource_type == other.resource_type,
|
||||
self.resource_name == other.resource_name,
|
||||
self.pattern_type == other.pattern_type,
|
||||
))
|
||||
|
||||
def __hash__(self):
|
||||
return hash((
|
||||
self.resource_type,
|
||||
self.resource_name,
|
||||
self.pattern_type
|
||||
))
|
||||
|
||||
|
||||
class ResourcePattern(ResourcePatternFilter):
|
||||
"""A resource pattern to apply the ACL to
|
||||
|
||||
Resource patterns are used to be able to specify which resources an ACL
|
||||
describes in a more flexible way than just pointing to a literal topic name for example.
|
||||
Since KIP-290 (kafka 2.0) it's possible to set an ACL for a prefixed resource name, which
|
||||
can cut down considerably on the number of ACLs needed when the number of topics and
|
||||
consumer groups start to grow.
|
||||
The default pattern_type is LITERAL, and it describes a specific resource. This is also how
|
||||
ACLs worked before the introduction of prefixed ACLs
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_type,
|
||||
resource_name,
|
||||
pattern_type=ACLResourcePatternType.LITERAL
|
||||
):
|
||||
super(ResourcePattern, self).__init__(resource_type, resource_name, pattern_type)
|
||||
self.validate()
|
||||
|
||||
def validate(self):
|
||||
if self.resource_type == ResourceType.ANY:
|
||||
raise IllegalArgumentError("resource_type cannot be ANY")
|
||||
if self.pattern_type in [ACLResourcePatternType.ANY, ACLResourcePatternType.MATCH]:
|
||||
raise IllegalArgumentError(
|
||||
"pattern_type cannot be {} on a concrete ResourcePattern".format(self.pattern_type.name)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
# enum in stdlib as of py3.4
|
||||
try:
|
||||
from enum import IntEnum # pylint: disable=import-error
|
||||
except ImportError:
|
||||
# vendored backport module
|
||||
from kafka.vendor.enum34 import IntEnum
|
||||
|
||||
|
||||
class ConfigResourceType(IntEnum):
|
||||
"""An enumerated type of config resources"""
|
||||
|
||||
BROKER = 4,
|
||||
TOPIC = 2
|
||||
|
||||
|
||||
class ConfigResource(object):
|
||||
"""A class for specifying config resources.
|
||||
Arguments:
|
||||
resource_type (ConfigResourceType): the type of kafka resource
|
||||
name (string): The name of the kafka resource
|
||||
configs ({key : value}): A maps of config keys to values.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_type,
|
||||
name,
|
||||
configs=None
|
||||
):
|
||||
if not isinstance(resource_type, (ConfigResourceType)):
|
||||
resource_type = ConfigResourceType[str(resource_type).upper()] # pylint: disable-msg=unsubscriptable-object
|
||||
self.resource_type = resource_type
|
||||
self.name = name
|
||||
self.configs = configs
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
class NewPartitions(object):
|
||||
"""A class for new partition creation on existing topics. Note that the length of new_assignments, if specified,
|
||||
must be the difference between the new total number of partitions and the existing number of partitions.
|
||||
Arguments:
|
||||
total_count (int): the total number of partitions that should exist on the topic
|
||||
new_assignments ([[int]]): an array of arrays of replica assignments for new partitions.
|
||||
If not set, broker assigns replicas per an internal algorithm.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
total_count,
|
||||
new_assignments=None
|
||||
):
|
||||
self.total_count = total_count
|
||||
self.new_assignments = new_assignments
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.errors import IllegalArgumentError
|
||||
|
||||
|
||||
class NewTopic(object):
|
||||
""" A class for new topic creation
|
||||
Arguments:
|
||||
name (string): name of the topic
|
||||
num_partitions (int): number of partitions
|
||||
or -1 if replica_assignment has been specified
|
||||
replication_factor (int): replication factor or -1 if
|
||||
replica assignment is specified
|
||||
replica_assignment (dict of int: [int]): A mapping containing
|
||||
partition id and replicas to assign to it.
|
||||
topic_configs (dict of str: str): A mapping of config key
|
||||
and value for the topic.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
num_partitions,
|
||||
replication_factor,
|
||||
replica_assignments=None,
|
||||
topic_configs=None,
|
||||
):
|
||||
if not (num_partitions == -1 or replication_factor == -1) ^ (replica_assignments is None):
|
||||
raise IllegalArgumentError('either num_partitions/replication_factor or replica_assignment must be specified')
|
||||
self.name = name
|
||||
self.num_partitions = num_partitions
|
||||
self.replication_factor = replication_factor
|
||||
self.replica_assignments = replica_assignments or {}
|
||||
self.topic_configs = topic_configs or {}
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python
|
||||
# Adapted from https://github.com/mrafayaleem/kafka-jython
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import argparse
|
||||
import pprint
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from kafka import KafkaConsumer
|
||||
|
||||
|
||||
class ConsumerPerformance(object):
|
||||
@staticmethod
|
||||
def run(args):
|
||||
try:
|
||||
props = {}
|
||||
for prop in args.consumer_config:
|
||||
k, v = prop.split('=')
|
||||
try:
|
||||
v = int(v)
|
||||
except ValueError:
|
||||
pass
|
||||
if v == 'None':
|
||||
v = None
|
||||
elif v == 'False':
|
||||
v = False
|
||||
elif v == 'True':
|
||||
v = True
|
||||
props[k] = v
|
||||
|
||||
print('Initializing Consumer...')
|
||||
props['bootstrap_servers'] = args.bootstrap_servers
|
||||
props['auto_offset_reset'] = 'earliest'
|
||||
if 'group_id' not in props:
|
||||
props['group_id'] = 'kafka-consumer-benchmark'
|
||||
if 'consumer_timeout_ms' not in props:
|
||||
props['consumer_timeout_ms'] = 10000
|
||||
props['metrics_sample_window_ms'] = args.stats_interval * 1000
|
||||
for k, v in props.items():
|
||||
print('---> {0}={1}'.format(k, v))
|
||||
consumer = KafkaConsumer(args.topic, **props)
|
||||
print('---> group_id={0}'.format(consumer.config['group_id']))
|
||||
print('---> report stats every {0} secs'.format(args.stats_interval))
|
||||
print('---> raw metrics? {0}'.format(args.raw_metrics))
|
||||
timer_stop = threading.Event()
|
||||
timer = StatsReporter(args.stats_interval, consumer,
|
||||
event=timer_stop,
|
||||
raw_metrics=args.raw_metrics)
|
||||
timer.start()
|
||||
print('-> OK!')
|
||||
print()
|
||||
|
||||
start_time = time.time()
|
||||
records = 0
|
||||
for msg in consumer:
|
||||
records += 1
|
||||
if records >= args.num_records:
|
||||
break
|
||||
|
||||
end_time = time.time()
|
||||
timer_stop.set()
|
||||
timer.join()
|
||||
print('Consumed {0} records'.format(records))
|
||||
print('Execution time:', end_time - start_time, 'secs')
|
||||
|
||||
except Exception:
|
||||
exc_info = sys.exc_info()
|
||||
traceback.print_exception(*exc_info)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class StatsReporter(threading.Thread):
|
||||
def __init__(self, interval, consumer, event=None, raw_metrics=False):
|
||||
super(StatsReporter, self).__init__()
|
||||
self.interval = interval
|
||||
self.consumer = consumer
|
||||
self.event = event
|
||||
self.raw_metrics = raw_metrics
|
||||
|
||||
def print_stats(self):
|
||||
metrics = self.consumer.metrics()
|
||||
if self.raw_metrics:
|
||||
pprint.pprint(metrics)
|
||||
else:
|
||||
print('{records-consumed-rate} records/sec ({bytes-consumed-rate} B/sec),'
|
||||
' {fetch-latency-avg} latency,'
|
||||
' {fetch-rate} fetch/s,'
|
||||
' {fetch-size-avg} fetch size,'
|
||||
' {records-lag-max} max record lag,'
|
||||
' {records-per-request-avg} records/req'
|
||||
.format(**metrics['consumer-fetch-manager-metrics']))
|
||||
|
||||
|
||||
def print_final(self):
|
||||
self.print_stats()
|
||||
|
||||
def run(self):
|
||||
while self.event and not self.event.wait(self.interval):
|
||||
self.print_stats()
|
||||
else:
|
||||
self.print_final()
|
||||
|
||||
|
||||
def get_args_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='This tool is used to verify the consumer performance.')
|
||||
|
||||
parser.add_argument(
|
||||
'--bootstrap-servers', type=str, nargs='+', default=(),
|
||||
help='host:port for cluster bootstrap servers')
|
||||
parser.add_argument(
|
||||
'--topic', type=str,
|
||||
help='Topic for consumer test (default: kafka-python-benchmark-test)',
|
||||
default='kafka-python-benchmark-test')
|
||||
parser.add_argument(
|
||||
'--num-records', type=int,
|
||||
help='number of messages to consume (default: 1000000)',
|
||||
default=1000000)
|
||||
parser.add_argument(
|
||||
'--consumer-config', type=str, nargs='+', default=(),
|
||||
help='kafka consumer related configuration properties like '
|
||||
'bootstrap_servers,client_id etc..')
|
||||
parser.add_argument(
|
||||
'--fixture-compression', type=str,
|
||||
help='specify a compression type for use with broker fixtures / producer')
|
||||
parser.add_argument(
|
||||
'--stats-interval', type=int,
|
||||
help='Interval in seconds for stats reporting to console (default: 5)',
|
||||
default=5)
|
||||
parser.add_argument(
|
||||
'--raw-metrics', action='store_true',
|
||||
help='Enable this flag to print full metrics dict on each interval')
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = get_args_parser().parse_args()
|
||||
ConsumerPerformance.run(args)
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kafka import KafkaConsumer, KafkaProducer
|
||||
|
||||
|
||||
class Producer(threading.Thread):
|
||||
|
||||
def __init__(self, bootstrap_servers, topic, stop_event, msg_size):
|
||||
super(Producer, self).__init__()
|
||||
self.bootstrap_servers = bootstrap_servers
|
||||
self.topic = topic
|
||||
self.stop_event = stop_event
|
||||
self.big_msg = b'1' * msg_size
|
||||
|
||||
def run(self):
|
||||
producer = KafkaProducer(bootstrap_servers=self.bootstrap_servers)
|
||||
self.sent = 0
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
producer.send(self.topic, self.big_msg)
|
||||
self.sent += 1
|
||||
producer.flush()
|
||||
producer.close()
|
||||
|
||||
|
||||
class Consumer(threading.Thread):
|
||||
def __init__(self, bootstrap_servers, topic, stop_event, msg_size):
|
||||
super(Consumer, self).__init__()
|
||||
self.bootstrap_servers = bootstrap_servers
|
||||
self.topic = topic
|
||||
self.stop_event = stop_event
|
||||
self.msg_size = msg_size
|
||||
|
||||
def run(self):
|
||||
consumer = KafkaConsumer(bootstrap_servers=self.bootstrap_servers,
|
||||
auto_offset_reset='earliest')
|
||||
consumer.subscribe([self.topic])
|
||||
self.valid = 0
|
||||
self.invalid = 0
|
||||
|
||||
for message in consumer:
|
||||
if len(message.value) == self.msg_size:
|
||||
self.valid += 1
|
||||
else:
|
||||
print('Invalid message:', len(message.value), self.msg_size)
|
||||
self.invalid += 1
|
||||
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
consumer.close()
|
||||
|
||||
|
||||
def get_args_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='This tool is used to demonstrate consumer and producer load.')
|
||||
|
||||
parser.add_argument(
|
||||
'--bootstrap-servers', type=str, nargs='+', default=('localhost:9092'),
|
||||
help='host:port for cluster bootstrap servers (default: localhost:9092)')
|
||||
parser.add_argument(
|
||||
'--topic', type=str,
|
||||
help='Topic for load test (default: kafka-python-benchmark-load-example)',
|
||||
default='kafka-python-benchmark-load-example')
|
||||
parser.add_argument(
|
||||
'--msg-size', type=int,
|
||||
help='Message size, in bytes, for load test (default: 524288)',
|
||||
default=524288)
|
||||
parser.add_argument(
|
||||
'--load-time', type=int,
|
||||
help='number of seconds to run load test (default: 10)',
|
||||
default=10)
|
||||
parser.add_argument(
|
||||
'--log-level', type=str,
|
||||
help='Optional logging level for load test: ERROR|INFO|DEBUG etc',
|
||||
default=None)
|
||||
return parser
|
||||
|
||||
|
||||
def main(args):
|
||||
if args.log_level:
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s.%(msecs)s:%(name)s:%(thread)d:%(levelname)s:%(process)d:%(message)s',
|
||||
level=getattr(logging, args.log_level))
|
||||
producer_stop = threading.Event()
|
||||
consumer_stop = threading.Event()
|
||||
threads = [
|
||||
Producer(args.bootstrap_servers, args.topic, producer_stop, args.msg_size),
|
||||
Consumer(args.bootstrap_servers, args.topic, consumer_stop, args.msg_size)
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
time.sleep(args.load_time)
|
||||
producer_stop.set()
|
||||
consumer_stop.set()
|
||||
print('Messages sent: %d' % threads[0].sent)
|
||||
print('Messages recvd: %d' % threads[1].valid)
|
||||
print('Messages invalid: %d' % threads[1].invalid)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = get_args_parser().parse_args()
|
||||
main(args)
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python
|
||||
# Adapted from https://github.com/mrafayaleem/kafka-jython
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import argparse
|
||||
import pprint
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from kafka.vendor.six.moves import range
|
||||
|
||||
from kafka import KafkaProducer
|
||||
|
||||
|
||||
class ProducerPerformance(object):
|
||||
@staticmethod
|
||||
def run(args):
|
||||
try:
|
||||
props = {}
|
||||
for prop in args.producer_config:
|
||||
k, v = prop.split('=')
|
||||
try:
|
||||
v = int(v)
|
||||
except ValueError:
|
||||
pass
|
||||
if v == 'None':
|
||||
v = None
|
||||
elif v == 'False':
|
||||
v = False
|
||||
elif v == 'True':
|
||||
v = True
|
||||
props[k] = v
|
||||
|
||||
print('Initializing producer...')
|
||||
props['bootstrap_servers'] = args.bootstrap_servers
|
||||
record = bytes(bytearray(args.record_size))
|
||||
props['metrics_sample_window_ms'] = args.stats_interval * 1000
|
||||
|
||||
producer = KafkaProducer(**props)
|
||||
for k, v in props.items():
|
||||
print('---> {0}={1}'.format(k, v))
|
||||
print('---> send {0} byte records'.format(args.record_size))
|
||||
print('---> report stats every {0} secs'.format(args.stats_interval))
|
||||
print('---> raw metrics? {0}'.format(args.raw_metrics))
|
||||
timer_stop = threading.Event()
|
||||
timer = StatsReporter(args.stats_interval, producer,
|
||||
event=timer_stop,
|
||||
raw_metrics=args.raw_metrics)
|
||||
timer.start()
|
||||
print('-> OK!')
|
||||
print()
|
||||
|
||||
def _benchmark():
|
||||
results = []
|
||||
for i in range(args.num_records):
|
||||
results.append(producer.send(topic=args.topic, value=record))
|
||||
print("Send complete...")
|
||||
producer.flush()
|
||||
producer.close()
|
||||
count_success, count_failure = 0, 0
|
||||
for r in results:
|
||||
if r.succeeded():
|
||||
count_success += 1
|
||||
elif r.failed():
|
||||
count_failure += 1
|
||||
else:
|
||||
raise ValueError(r)
|
||||
print("%d suceeded, %d failed" % (count_success, count_failure))
|
||||
|
||||
start_time = time.time()
|
||||
_benchmark()
|
||||
end_time = time.time()
|
||||
timer_stop.set()
|
||||
timer.join()
|
||||
print('Execution time:', end_time - start_time, 'secs')
|
||||
|
||||
except Exception:
|
||||
exc_info = sys.exc_info()
|
||||
traceback.print_exception(*exc_info)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class StatsReporter(threading.Thread):
|
||||
def __init__(self, interval, producer, event=None, raw_metrics=False):
|
||||
super(StatsReporter, self).__init__()
|
||||
self.interval = interval
|
||||
self.producer = producer
|
||||
self.event = event
|
||||
self.raw_metrics = raw_metrics
|
||||
|
||||
def print_stats(self):
|
||||
metrics = self.producer.metrics()
|
||||
if not metrics:
|
||||
return
|
||||
if self.raw_metrics:
|
||||
pprint.pprint(metrics)
|
||||
else:
|
||||
print('{record-send-rate} records/sec ({byte-rate} B/sec),'
|
||||
' {request-latency-avg} latency,'
|
||||
' {record-size-avg} record size,'
|
||||
' {batch-size-avg} batch size,'
|
||||
' {records-per-request-avg} records/req'
|
||||
.format(**metrics['producer-metrics']))
|
||||
|
||||
def print_final(self):
|
||||
self.print_stats()
|
||||
|
||||
def run(self):
|
||||
while self.event and not self.event.wait(self.interval):
|
||||
self.print_stats()
|
||||
else:
|
||||
self.print_final()
|
||||
|
||||
|
||||
def get_args_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='This tool is used to verify the producer performance.')
|
||||
|
||||
parser.add_argument(
|
||||
'--bootstrap-servers', type=str, nargs='+', default=(),
|
||||
help='host:port for cluster bootstrap server')
|
||||
parser.add_argument(
|
||||
'--topic', type=str,
|
||||
help='Topic name for test (default: kafka-python-benchmark-test)',
|
||||
default='kafka-python-benchmark-test')
|
||||
parser.add_argument(
|
||||
'--num-records', type=int,
|
||||
help='number of messages to produce (default: 1000000)',
|
||||
default=1000000)
|
||||
parser.add_argument(
|
||||
'--record-size', type=int,
|
||||
help='message size in bytes (default: 100)',
|
||||
default=100)
|
||||
parser.add_argument(
|
||||
'--producer-config', type=str, nargs='+', default=(),
|
||||
help='kafka producer related configuaration properties like '
|
||||
'bootstrap_servers,client_id etc..')
|
||||
parser.add_argument(
|
||||
'--stats-interval', type=int,
|
||||
help='Interval in seconds for stats reporting to console (default: 5)',
|
||||
default=5)
|
||||
parser.add_argument(
|
||||
'--raw-metrics', action='store_true',
|
||||
help='Enable this flag to print full metrics dict on each interval')
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = get_args_parser().parse_args()
|
||||
ProducerPerformance.run(args)
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import print_function
|
||||
import hashlib
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
|
||||
import pyperf
|
||||
|
||||
from kafka.record.memory_records import MemoryRecordsBuilder
|
||||
|
||||
|
||||
DEFAULT_BATCH_SIZE = 1600 * 1024
|
||||
KEY_SIZE = 6
|
||||
VALUE_SIZE = 60
|
||||
TIMESTAMP_RANGE = [1505824130000, 1505824140000]
|
||||
|
||||
# With values above v1 record is 100 bytes, so 10 000 bytes for 100 messages
|
||||
MESSAGES_PER_BATCH = 100
|
||||
|
||||
|
||||
def random_bytes(length):
|
||||
buffer = bytearray(length)
|
||||
for i in range(length):
|
||||
buffer[i] = random.randint(0, 255)
|
||||
return bytes(buffer)
|
||||
|
||||
|
||||
def prepare():
|
||||
return iter(itertools.cycle([
|
||||
(random_bytes(KEY_SIZE),
|
||||
random_bytes(VALUE_SIZE),
|
||||
random.randint(*TIMESTAMP_RANGE)
|
||||
)
|
||||
for _ in range(int(MESSAGES_PER_BATCH * 1.94))
|
||||
]))
|
||||
|
||||
|
||||
def finalize(results):
|
||||
# Just some strange code to make sure PyPy does execute the main code
|
||||
# properly, without optimizing it away
|
||||
hash_val = hashlib.md5()
|
||||
for buf in results:
|
||||
hash_val.update(buf)
|
||||
print(hash_val, file=open(os.devnull, "w"))
|
||||
|
||||
|
||||
def func(loops, magic):
|
||||
# Jit can optimize out the whole function if the result is the same each
|
||||
# time, so we need some randomized input data )
|
||||
precomputed_samples = prepare()
|
||||
results = []
|
||||
|
||||
# Main benchmark code.
|
||||
t0 = pyperf.perf_counter()
|
||||
for _ in range(loops):
|
||||
batch = MemoryRecordsBuilder(
|
||||
magic, batch_size=DEFAULT_BATCH_SIZE, compression_type=0)
|
||||
for _ in range(MESSAGES_PER_BATCH):
|
||||
key, value, timestamp = next(precomputed_samples)
|
||||
size = batch.append(
|
||||
timestamp=timestamp, key=key, value=value)
|
||||
assert size
|
||||
batch.close()
|
||||
results.append(batch.buffer())
|
||||
|
||||
res = pyperf.perf_counter() - t0
|
||||
|
||||
finalize(results)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
runner = pyperf.Runner()
|
||||
runner.bench_time_func('batch_append_v0', func, 0)
|
||||
runner.bench_time_func('batch_append_v1', func, 1)
|
||||
runner.bench_time_func('batch_append_v2', func, 2)
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import print_function
|
||||
import hashlib
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
|
||||
import pyperf
|
||||
|
||||
from kafka.record.memory_records import MemoryRecords, MemoryRecordsBuilder
|
||||
|
||||
|
||||
DEFAULT_BATCH_SIZE = 1600 * 1024
|
||||
KEY_SIZE = 6
|
||||
VALUE_SIZE = 60
|
||||
TIMESTAMP_RANGE = [1505824130000, 1505824140000]
|
||||
|
||||
BATCH_SAMPLES = 5
|
||||
MESSAGES_PER_BATCH = 100
|
||||
|
||||
|
||||
def random_bytes(length):
|
||||
buffer = bytearray(length)
|
||||
for i in range(length):
|
||||
buffer[i] = random.randint(0, 255)
|
||||
return bytes(buffer)
|
||||
|
||||
|
||||
def prepare(magic):
|
||||
samples = []
|
||||
for _ in range(BATCH_SAMPLES):
|
||||
batch = MemoryRecordsBuilder(
|
||||
magic, batch_size=DEFAULT_BATCH_SIZE, compression_type=0)
|
||||
for _ in range(MESSAGES_PER_BATCH):
|
||||
size = batch.append(
|
||||
random.randint(*TIMESTAMP_RANGE),
|
||||
random_bytes(KEY_SIZE),
|
||||
random_bytes(VALUE_SIZE),
|
||||
headers=[])
|
||||
assert size
|
||||
batch.close()
|
||||
samples.append(bytes(batch.buffer()))
|
||||
|
||||
return iter(itertools.cycle(samples))
|
||||
|
||||
|
||||
def finalize(results):
|
||||
# Just some strange code to make sure PyPy does execute the code above
|
||||
# properly
|
||||
hash_val = hashlib.md5()
|
||||
for buf in results:
|
||||
hash_val.update(buf)
|
||||
print(hash_val, file=open(os.devnull, "w"))
|
||||
|
||||
|
||||
def func(loops, magic):
|
||||
# Jit can optimize out the whole function if the result is the same each
|
||||
# time, so we need some randomized input data )
|
||||
precomputed_samples = prepare(magic)
|
||||
results = []
|
||||
|
||||
# Main benchmark code.
|
||||
batch_data = next(precomputed_samples)
|
||||
t0 = pyperf.perf_counter()
|
||||
for _ in range(loops):
|
||||
records = MemoryRecords(batch_data)
|
||||
while records.has_next():
|
||||
batch = records.next_batch()
|
||||
batch.validate_crc()
|
||||
for record in batch:
|
||||
results.append(record.value)
|
||||
|
||||
res = pyperf.perf_counter() - t0
|
||||
finalize(results)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
runner = pyperf.Runner()
|
||||
runner.bench_time_func('batch_read_v0', func, 0)
|
||||
runner.bench_time_func('batch_read_v1', func, 1)
|
||||
runner.bench_time_func('batch_read_v2', func, 2)
|
||||
@@ -0,0 +1,434 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import print_function
|
||||
import pyperf
|
||||
from kafka.vendor import six
|
||||
|
||||
|
||||
test_data = [
|
||||
(b"\x00", 0),
|
||||
(b"\x01", -1),
|
||||
(b"\x02", 1),
|
||||
(b"\x7E", 63),
|
||||
(b"\x7F", -64),
|
||||
(b"\x80\x01", 64),
|
||||
(b"\x81\x01", -65),
|
||||
(b"\xFE\x7F", 8191),
|
||||
(b"\xFF\x7F", -8192),
|
||||
(b"\x80\x80\x01", 8192),
|
||||
(b"\x81\x80\x01", -8193),
|
||||
(b"\xFE\xFF\x7F", 1048575),
|
||||
(b"\xFF\xFF\x7F", -1048576),
|
||||
(b"\x80\x80\x80\x01", 1048576),
|
||||
(b"\x81\x80\x80\x01", -1048577),
|
||||
(b"\xFE\xFF\xFF\x7F", 134217727),
|
||||
(b"\xFF\xFF\xFF\x7F", -134217728),
|
||||
(b"\x80\x80\x80\x80\x01", 134217728),
|
||||
(b"\x81\x80\x80\x80\x01", -134217729),
|
||||
(b"\xFE\xFF\xFF\xFF\x7F", 17179869183),
|
||||
(b"\xFF\xFF\xFF\xFF\x7F", -17179869184),
|
||||
(b"\x80\x80\x80\x80\x80\x01", 17179869184),
|
||||
(b"\x81\x80\x80\x80\x80\x01", -17179869185),
|
||||
(b"\xFE\xFF\xFF\xFF\xFF\x7F", 2199023255551),
|
||||
(b"\xFF\xFF\xFF\xFF\xFF\x7F", -2199023255552),
|
||||
(b"\x80\x80\x80\x80\x80\x80\x01", 2199023255552),
|
||||
(b"\x81\x80\x80\x80\x80\x80\x01", -2199023255553),
|
||||
(b"\xFE\xFF\xFF\xFF\xFF\xFF\x7F", 281474976710655),
|
||||
(b"\xFF\xFF\xFF\xFF\xFF\xFF\x7F", -281474976710656),
|
||||
(b"\x80\x80\x80\x80\x80\x80\x80\x01", 281474976710656),
|
||||
(b"\x81\x80\x80\x80\x80\x80\x80\x01", -281474976710657),
|
||||
(b"\xFE\xFF\xFF\xFF\xFF\xFF\xFF\x7F", 36028797018963967),
|
||||
(b"\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x7F", -36028797018963968),
|
||||
(b"\x80\x80\x80\x80\x80\x80\x80\x80\x01", 36028797018963968),
|
||||
(b"\x81\x80\x80\x80\x80\x80\x80\x80\x01", -36028797018963969),
|
||||
(b"\xFE\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x7F", 4611686018427387903),
|
||||
(b"\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x7F", -4611686018427387904),
|
||||
(b"\x80\x80\x80\x80\x80\x80\x80\x80\x80\x01", 4611686018427387904),
|
||||
(b"\x81\x80\x80\x80\x80\x80\x80\x80\x80\x01", -4611686018427387905),
|
||||
]
|
||||
|
||||
|
||||
BENCH_VALUES_ENC = [
|
||||
60, # 1 byte
|
||||
-8192, # 2 bytes
|
||||
1048575, # 3 bytes
|
||||
134217727, # 4 bytes
|
||||
-17179869184, # 5 bytes
|
||||
2199023255551, # 6 bytes
|
||||
]
|
||||
|
||||
BENCH_VALUES_DEC = [
|
||||
b"\x7E", # 1 byte
|
||||
b"\xFF\x7F", # 2 bytes
|
||||
b"\xFE\xFF\x7F", # 3 bytes
|
||||
b"\xFF\xFF\xFF\x7F", # 4 bytes
|
||||
b"\x80\x80\x80\x80\x01", # 5 bytes
|
||||
b"\xFE\xFF\xFF\xFF\xFF\x7F", # 6 bytes
|
||||
]
|
||||
BENCH_VALUES_DEC = list(map(bytearray, BENCH_VALUES_DEC))
|
||||
|
||||
|
||||
def _assert_valid_enc(enc_func):
|
||||
for encoded, decoded in test_data:
|
||||
assert enc_func(decoded) == encoded, decoded
|
||||
|
||||
|
||||
def _assert_valid_dec(dec_func):
|
||||
for encoded, decoded in test_data:
|
||||
res, pos = dec_func(bytearray(encoded))
|
||||
assert res == decoded, (decoded, res)
|
||||
assert pos == len(encoded), (decoded, pos)
|
||||
|
||||
|
||||
def _assert_valid_size(size_func):
|
||||
for encoded, decoded in test_data:
|
||||
assert size_func(decoded) == len(encoded), decoded
|
||||
|
||||
|
||||
def encode_varint_1(num):
|
||||
""" Encode an integer to a varint presentation. See
|
||||
https://developers.google.com/protocol-buffers/docs/encoding?csw=1#varints
|
||||
on how those can be produced.
|
||||
|
||||
Arguments:
|
||||
num (int): Value to encode
|
||||
|
||||
Returns:
|
||||
bytearray: Encoded presentation of integer with length from 1 to 10
|
||||
bytes
|
||||
"""
|
||||
# Shift sign to the end of number
|
||||
num = (num << 1) ^ (num >> 63)
|
||||
# Max 10 bytes. We assert those are allocated
|
||||
buf = bytearray(10)
|
||||
|
||||
for i in range(10):
|
||||
# 7 lowest bits from the number and set 8th if we still have pending
|
||||
# bits left to encode
|
||||
buf[i] = num & 0x7f | (0x80 if num > 0x7f else 0)
|
||||
num = num >> 7
|
||||
if num == 0:
|
||||
break
|
||||
else:
|
||||
# Max size of endcoded double is 10 bytes for unsigned values
|
||||
raise ValueError("Out of double range")
|
||||
return buf[:i + 1]
|
||||
|
||||
|
||||
def encode_varint_2(value, int2byte=six.int2byte):
|
||||
value = (value << 1) ^ (value >> 63)
|
||||
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
res = b""
|
||||
while value:
|
||||
res += int2byte(0x80 | bits)
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
return res + int2byte(bits)
|
||||
|
||||
|
||||
def encode_varint_3(value, buf):
|
||||
append = buf.append
|
||||
value = (value << 1) ^ (value >> 63)
|
||||
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
while value:
|
||||
append(0x80 | bits)
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
append(bits)
|
||||
return value
|
||||
|
||||
|
||||
def encode_varint_4(value, int2byte=six.int2byte):
|
||||
value = (value << 1) ^ (value >> 63)
|
||||
|
||||
if value <= 0x7f: # 1 byte
|
||||
return int2byte(value)
|
||||
if value <= 0x3fff: # 2 bytes
|
||||
return int2byte(0x80 | (value & 0x7f)) + int2byte(value >> 7)
|
||||
if value <= 0x1fffff: # 3 bytes
|
||||
return int2byte(0x80 | (value & 0x7f)) + \
|
||||
int2byte(0x80 | ((value >> 7) & 0x7f)) + \
|
||||
int2byte(value >> 14)
|
||||
if value <= 0xfffffff: # 4 bytes
|
||||
return int2byte(0x80 | (value & 0x7f)) + \
|
||||
int2byte(0x80 | ((value >> 7) & 0x7f)) + \
|
||||
int2byte(0x80 | ((value >> 14) & 0x7f)) + \
|
||||
int2byte(value >> 21)
|
||||
if value <= 0x7ffffffff: # 5 bytes
|
||||
return int2byte(0x80 | (value & 0x7f)) + \
|
||||
int2byte(0x80 | ((value >> 7) & 0x7f)) + \
|
||||
int2byte(0x80 | ((value >> 14) & 0x7f)) + \
|
||||
int2byte(0x80 | ((value >> 21) & 0x7f)) + \
|
||||
int2byte(value >> 28)
|
||||
else:
|
||||
# Return to general algorithm
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
res = b""
|
||||
while value:
|
||||
res += int2byte(0x80 | bits)
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
return res + int2byte(bits)
|
||||
|
||||
|
||||
def encode_varint_5(value, buf, pos=0):
|
||||
value = (value << 1) ^ (value >> 63)
|
||||
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
while value:
|
||||
buf[pos] = 0x80 | bits
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
pos += 1
|
||||
buf[pos] = bits
|
||||
return pos + 1
|
||||
|
||||
def encode_varint_6(value, buf):
|
||||
append = buf.append
|
||||
value = (value << 1) ^ (value >> 63)
|
||||
|
||||
if value <= 0x7f: # 1 byte
|
||||
append(value)
|
||||
return 1
|
||||
if value <= 0x3fff: # 2 bytes
|
||||
append(0x80 | (value & 0x7f))
|
||||
append(value >> 7)
|
||||
return 2
|
||||
if value <= 0x1fffff: # 3 bytes
|
||||
append(0x80 | (value & 0x7f))
|
||||
append(0x80 | ((value >> 7) & 0x7f))
|
||||
append(value >> 14)
|
||||
return 3
|
||||
if value <= 0xfffffff: # 4 bytes
|
||||
append(0x80 | (value & 0x7f))
|
||||
append(0x80 | ((value >> 7) & 0x7f))
|
||||
append(0x80 | ((value >> 14) & 0x7f))
|
||||
append(value >> 21)
|
||||
return 4
|
||||
if value <= 0x7ffffffff: # 5 bytes
|
||||
append(0x80 | (value & 0x7f))
|
||||
append(0x80 | ((value >> 7) & 0x7f))
|
||||
append(0x80 | ((value >> 14) & 0x7f))
|
||||
append(0x80 | ((value >> 21) & 0x7f))
|
||||
append(value >> 28)
|
||||
return 5
|
||||
else:
|
||||
# Return to general algorithm
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
i = 0
|
||||
while value:
|
||||
append(0x80 | bits)
|
||||
bits = value & 0x7f
|
||||
value >>= 7
|
||||
i += 1
|
||||
append(bits)
|
||||
return i
|
||||
|
||||
|
||||
def size_of_varint_1(value):
|
||||
""" Number of bytes needed to encode an integer in variable-length format.
|
||||
"""
|
||||
value = (value << 1) ^ (value >> 63)
|
||||
res = 0
|
||||
while True:
|
||||
res += 1
|
||||
value = value >> 7
|
||||
if value == 0:
|
||||
break
|
||||
return res
|
||||
|
||||
|
||||
def size_of_varint_2(value):
|
||||
""" Number of bytes needed to encode an integer in variable-length format.
|
||||
"""
|
||||
value = (value << 1) ^ (value >> 63)
|
||||
if value <= 0x7f:
|
||||
return 1
|
||||
if value <= 0x3fff:
|
||||
return 2
|
||||
if value <= 0x1fffff:
|
||||
return 3
|
||||
if value <= 0xfffffff:
|
||||
return 4
|
||||
if value <= 0x7ffffffff:
|
||||
return 5
|
||||
if value <= 0x3ffffffffff:
|
||||
return 6
|
||||
if value <= 0x1ffffffffffff:
|
||||
return 7
|
||||
if value <= 0xffffffffffffff:
|
||||
return 8
|
||||
if value <= 0x7fffffffffffffff:
|
||||
return 9
|
||||
return 10
|
||||
|
||||
|
||||
if six.PY3:
|
||||
def _read_byte(memview, pos):
|
||||
""" Read a byte from memoryview as an integer
|
||||
|
||||
Raises:
|
||||
IndexError: if position is out of bounds
|
||||
"""
|
||||
return memview[pos]
|
||||
else:
|
||||
def _read_byte(memview, pos):
|
||||
""" Read a byte from memoryview as an integer
|
||||
|
||||
Raises:
|
||||
IndexError: if position is out of bounds
|
||||
"""
|
||||
return ord(memview[pos])
|
||||
|
||||
|
||||
def decode_varint_1(buffer, pos=0):
|
||||
""" Decode an integer from a varint presentation. See
|
||||
https://developers.google.com/protocol-buffers/docs/encoding?csw=1#varints
|
||||
on how those can be produced.
|
||||
|
||||
Arguments:
|
||||
buffer (bytes-like): any object acceptable by ``memoryview``
|
||||
pos (int): optional position to read from
|
||||
|
||||
Returns:
|
||||
(int, int): Decoded int value and next read position
|
||||
"""
|
||||
value = 0
|
||||
shift = 0
|
||||
memview = memoryview(buffer)
|
||||
for i in range(pos, pos + 10):
|
||||
try:
|
||||
byte = _read_byte(memview, i)
|
||||
except IndexError:
|
||||
raise ValueError("End of byte stream")
|
||||
if byte & 0x80 != 0:
|
||||
value |= (byte & 0x7f) << shift
|
||||
shift += 7
|
||||
else:
|
||||
value |= byte << shift
|
||||
break
|
||||
else:
|
||||
# Max size of endcoded double is 10 bytes for unsigned values
|
||||
raise ValueError("Out of double range")
|
||||
# Normalize sign
|
||||
return (value >> 1) ^ -(value & 1), i + 1
|
||||
|
||||
|
||||
def decode_varint_2(buffer, pos=0):
|
||||
result = 0
|
||||
shift = 0
|
||||
while 1:
|
||||
b = buffer[pos]
|
||||
result |= ((b & 0x7f) << shift)
|
||||
pos += 1
|
||||
if not (b & 0x80):
|
||||
# result = result_type(() & mask)
|
||||
return ((result >> 1) ^ -(result & 1), pos)
|
||||
shift += 7
|
||||
if shift >= 64:
|
||||
raise ValueError("Out of int64 range")
|
||||
|
||||
|
||||
def decode_varint_3(buffer, pos=0):
|
||||
result = buffer[pos]
|
||||
if not (result & 0x81):
|
||||
return (result >> 1), pos + 1
|
||||
if not (result & 0x80):
|
||||
return (result >> 1) ^ (~0), pos + 1
|
||||
|
||||
result &= 0x7f
|
||||
pos += 1
|
||||
shift = 7
|
||||
while 1:
|
||||
b = buffer[pos]
|
||||
result |= ((b & 0x7f) << shift)
|
||||
pos += 1
|
||||
if not (b & 0x80):
|
||||
return ((result >> 1) ^ -(result & 1), pos)
|
||||
shift += 7
|
||||
if shift >= 64:
|
||||
raise ValueError("Out of int64 range")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_assert_valid_enc(encode_varint_1)
|
||||
_assert_valid_enc(encode_varint_2)
|
||||
|
||||
for encoded, decoded in test_data:
|
||||
res = bytearray()
|
||||
encode_varint_3(decoded, res)
|
||||
assert res == encoded
|
||||
|
||||
_assert_valid_enc(encode_varint_4)
|
||||
|
||||
# import dis
|
||||
# dis.dis(encode_varint_4)
|
||||
|
||||
for encoded, decoded in test_data:
|
||||
res = bytearray(10)
|
||||
written = encode_varint_5(decoded, res)
|
||||
assert res[:written] == encoded
|
||||
|
||||
for encoded, decoded in test_data:
|
||||
res = bytearray()
|
||||
encode_varint_6(decoded, res)
|
||||
assert res == encoded
|
||||
|
||||
_assert_valid_size(size_of_varint_1)
|
||||
_assert_valid_size(size_of_varint_2)
|
||||
_assert_valid_dec(decode_varint_1)
|
||||
_assert_valid_dec(decode_varint_2)
|
||||
_assert_valid_dec(decode_varint_3)
|
||||
|
||||
# import dis
|
||||
# dis.dis(decode_varint_3)
|
||||
|
||||
runner = pyperf.Runner()
|
||||
# Encode algorithms returning a bytes result
|
||||
for bench_func in [
|
||||
encode_varint_1,
|
||||
encode_varint_2,
|
||||
encode_varint_4]:
|
||||
for i, value in enumerate(BENCH_VALUES_ENC):
|
||||
runner.bench_func(
|
||||
'{}_{}byte'.format(bench_func.__name__, i + 1),
|
||||
bench_func, value)
|
||||
|
||||
# Encode algorithms writing to the buffer
|
||||
for bench_func in [
|
||||
encode_varint_3,
|
||||
encode_varint_5,
|
||||
encode_varint_6]:
|
||||
for i, value in enumerate(BENCH_VALUES_ENC):
|
||||
fname = bench_func.__name__
|
||||
runner.timeit(
|
||||
'{}_{}byte'.format(fname, i + 1),
|
||||
stmt="{}({}, buffer)".format(fname, value),
|
||||
setup="from __main__ import {}; buffer = bytearray(10)".format(
|
||||
fname)
|
||||
)
|
||||
|
||||
# Size algorithms
|
||||
for bench_func in [
|
||||
size_of_varint_1,
|
||||
size_of_varint_2]:
|
||||
for i, value in enumerate(BENCH_VALUES_ENC):
|
||||
runner.bench_func(
|
||||
'{}_{}byte'.format(bench_func.__name__, i + 1),
|
||||
bench_func, value)
|
||||
|
||||
# Decode algorithms
|
||||
for bench_func in [
|
||||
decode_varint_1,
|
||||
decode_varint_2,
|
||||
decode_varint_3]:
|
||||
for i, value in enumerate(BENCH_VALUES_DEC):
|
||||
runner.bench_func(
|
||||
'{}_{}byte'.format(bench_func.__name__, i + 1),
|
||||
bench_func, value)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
from kafka import errors as Errors
|
||||
from kafka.conn import get_ip_port_afi
|
||||
from kafka.future import Future
|
||||
from kafka.structs import BrokerMetadata, PartitionMetadata, TopicPartition
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClusterMetadata(object):
|
||||
"""
|
||||
A class to manage kafka cluster metadata.
|
||||
|
||||
This class does not perform any IO. It simply updates internal state
|
||||
given API responses (MetadataResponse, FindCoordinatorResponse).
|
||||
|
||||
Keyword Arguments:
|
||||
retry_backoff_ms (int): Milliseconds to backoff when retrying on
|
||||
errors. Default: 100.
|
||||
metadata_max_age_ms (int): The period of time in milliseconds after
|
||||
which we force a refresh of metadata even if we haven't seen any
|
||||
partition leadership changes to proactively discover any new
|
||||
brokers or partitions. Default: 300000
|
||||
bootstrap_servers: 'host[:port]' string (or list of 'host[:port]'
|
||||
strings) that the client should contact to bootstrap initial
|
||||
cluster metadata. This does not have to be the full node list.
|
||||
It just needs to have at least one broker that will respond to a
|
||||
Metadata API Request. Default port is 9092. If no servers are
|
||||
specified, will default to localhost:9092.
|
||||
"""
|
||||
DEFAULT_CONFIG = {
|
||||
'retry_backoff_ms': 100,
|
||||
'metadata_max_age_ms': 300000,
|
||||
'bootstrap_servers': [],
|
||||
}
|
||||
|
||||
def __init__(self, **configs):
|
||||
self._brokers = {} # node_id -> BrokerMetadata
|
||||
self._partitions = {} # topic -> partition -> PartitionMetadata
|
||||
self._broker_partitions = collections.defaultdict(set) # node_id -> {TopicPartition...}
|
||||
self._coordinators = {} # (coord_type, coord_key) -> node_id
|
||||
self._last_refresh_ms = 0
|
||||
self._last_successful_refresh_ms = 0
|
||||
self._need_update = True
|
||||
self._future = None
|
||||
self._listeners = set()
|
||||
self._lock = threading.Lock()
|
||||
self.need_all_topic_metadata = False
|
||||
self.unauthorized_topics = set()
|
||||
self.internal_topics = set()
|
||||
self.controller = None
|
||||
self.cluster_id = None
|
||||
|
||||
self.config = copy.copy(self.DEFAULT_CONFIG)
|
||||
for key in self.config:
|
||||
if key in configs:
|
||||
self.config[key] = configs[key]
|
||||
|
||||
self._bootstrap_brokers = self._generate_bootstrap_brokers()
|
||||
self._coordinator_brokers = {}
|
||||
|
||||
def _generate_bootstrap_brokers(self):
|
||||
# collect_hosts does not perform DNS, so we should be fine to re-use
|
||||
bootstrap_hosts = collect_hosts(self.config['bootstrap_servers'])
|
||||
|
||||
brokers = {}
|
||||
for i, (host, port, _) in enumerate(bootstrap_hosts):
|
||||
node_id = 'bootstrap-%s' % i
|
||||
brokers[node_id] = BrokerMetadata(node_id, host, port, None)
|
||||
return brokers
|
||||
|
||||
def is_bootstrap(self, node_id):
|
||||
return node_id in self._bootstrap_brokers
|
||||
|
||||
def brokers(self):
|
||||
"""Get all BrokerMetadata
|
||||
|
||||
Returns:
|
||||
set: {BrokerMetadata, ...}
|
||||
"""
|
||||
return set(self._brokers.values()) or set(self._bootstrap_brokers.values())
|
||||
|
||||
def broker_metadata(self, broker_id):
|
||||
"""Get BrokerMetadata
|
||||
|
||||
Arguments:
|
||||
broker_id (int or str): node_id for a broker to check
|
||||
|
||||
Returns:
|
||||
BrokerMetadata or None if not found
|
||||
"""
|
||||
return (
|
||||
self._brokers.get(broker_id) or
|
||||
self._bootstrap_brokers.get(broker_id) or
|
||||
self._coordinator_brokers.get(broker_id)
|
||||
)
|
||||
|
||||
def partitions_for_topic(self, topic):
|
||||
"""Return set of all partitions for topic (whether available or not)
|
||||
|
||||
Arguments:
|
||||
topic (str): topic to check for partitions
|
||||
|
||||
Returns:
|
||||
set: {partition (int), ...}
|
||||
None if topic not found.
|
||||
"""
|
||||
if topic not in self._partitions:
|
||||
return None
|
||||
return set(self._partitions[topic].keys())
|
||||
|
||||
def available_partitions_for_topic(self, topic):
|
||||
"""Return set of partitions with known leaders
|
||||
|
||||
Arguments:
|
||||
topic (str): topic to check for partitions
|
||||
|
||||
Returns:
|
||||
set: {partition (int), ...}
|
||||
None if topic not found.
|
||||
"""
|
||||
if topic not in self._partitions:
|
||||
return None
|
||||
return set([partition for partition, metadata
|
||||
in six.iteritems(self._partitions[topic])
|
||||
if metadata.leader != -1])
|
||||
|
||||
def leader_for_partition(self, partition):
|
||||
"""Return node_id of leader, -1 unavailable, None if unknown."""
|
||||
if partition.topic not in self._partitions:
|
||||
return None
|
||||
elif partition.partition not in self._partitions[partition.topic]:
|
||||
return None
|
||||
return self._partitions[partition.topic][partition.partition].leader
|
||||
|
||||
def leader_epoch_for_partition(self, partition):
|
||||
return self._partitions[partition.topic][partition.partition].leader_epoch
|
||||
|
||||
def partitions_for_broker(self, broker_id):
|
||||
"""Return TopicPartitions for which the broker is a leader.
|
||||
|
||||
Arguments:
|
||||
broker_id (int or str): node id for a broker
|
||||
|
||||
Returns:
|
||||
set: {TopicPartition, ...}
|
||||
None if the broker either has no partitions or does not exist.
|
||||
"""
|
||||
return self._broker_partitions.get(broker_id)
|
||||
|
||||
def coordinator_for_group(self, group):
|
||||
"""Return node_id of group coordinator.
|
||||
|
||||
Arguments:
|
||||
group (str): name of consumer group
|
||||
|
||||
Returns:
|
||||
node_id (int or str) for group coordinator, -1 if coordinator unknown
|
||||
None if the group does not exist.
|
||||
"""
|
||||
return self._coordinators.get(('group', group))
|
||||
|
||||
def ttl(self):
|
||||
"""Milliseconds until metadata should be refreshed"""
|
||||
now = time.time() * 1000
|
||||
if self._need_update:
|
||||
ttl = 0
|
||||
else:
|
||||
metadata_age = now - self._last_successful_refresh_ms
|
||||
ttl = self.config['metadata_max_age_ms'] - metadata_age
|
||||
|
||||
retry_age = now - self._last_refresh_ms
|
||||
next_retry = self.config['retry_backoff_ms'] - retry_age
|
||||
|
||||
return max(ttl, next_retry, 0)
|
||||
|
||||
def refresh_backoff(self):
|
||||
"""Return milliseconds to wait before attempting to retry after failure"""
|
||||
return self.config['retry_backoff_ms']
|
||||
|
||||
def request_update(self):
|
||||
"""Flags metadata for update, return Future()
|
||||
|
||||
Actual update must be handled separately. This method will only
|
||||
change the reported ttl()
|
||||
|
||||
Returns:
|
||||
kafka.future.Future (value will be the cluster object after update)
|
||||
"""
|
||||
with self._lock:
|
||||
self._need_update = True
|
||||
if not self._future or self._future.is_done:
|
||||
self._future = Future()
|
||||
return self._future
|
||||
|
||||
@property
|
||||
def need_update(self):
|
||||
return self._need_update
|
||||
|
||||
def topics(self, exclude_internal_topics=True):
|
||||
"""Get set of known topics.
|
||||
|
||||
Arguments:
|
||||
exclude_internal_topics (bool): Whether records from internal topics
|
||||
(such as offsets) should be exposed to the consumer. If set to
|
||||
True the only way to receive records from an internal topic is
|
||||
subscribing to it. Default True
|
||||
|
||||
Returns:
|
||||
set: {topic (str), ...}
|
||||
"""
|
||||
topics = set(self._partitions.keys())
|
||||
if exclude_internal_topics:
|
||||
return topics - self.internal_topics
|
||||
else:
|
||||
return topics
|
||||
|
||||
def failed_update(self, exception):
|
||||
"""Update cluster state given a failed MetadataRequest."""
|
||||
f = None
|
||||
with self._lock:
|
||||
if self._future:
|
||||
f = self._future
|
||||
self._future = None
|
||||
if f:
|
||||
f.failure(exception)
|
||||
self._last_refresh_ms = time.time() * 1000
|
||||
|
||||
def update_metadata(self, metadata):
|
||||
"""Update cluster state given a MetadataResponse.
|
||||
|
||||
Arguments:
|
||||
metadata (MetadataResponse): broker response to a metadata request
|
||||
|
||||
Returns: None
|
||||
"""
|
||||
if not metadata.brokers:
|
||||
log.warning("No broker metadata found in MetadataResponse -- ignoring.")
|
||||
return self.failed_update(Errors.MetadataEmptyBrokerList(metadata))
|
||||
|
||||
_new_brokers = {}
|
||||
for broker in metadata.brokers:
|
||||
if metadata.API_VERSION == 0:
|
||||
node_id, host, port = broker
|
||||
rack = None
|
||||
else:
|
||||
node_id, host, port, rack = broker
|
||||
_new_brokers.update({
|
||||
node_id: BrokerMetadata(node_id, host, port, rack)
|
||||
})
|
||||
|
||||
if metadata.API_VERSION == 0:
|
||||
_new_controller = None
|
||||
else:
|
||||
_new_controller = _new_brokers.get(metadata.controller_id)
|
||||
|
||||
if metadata.API_VERSION < 2:
|
||||
_new_cluster_id = None
|
||||
else:
|
||||
_new_cluster_id = metadata.cluster_id
|
||||
|
||||
_new_partitions = {}
|
||||
_new_broker_partitions = collections.defaultdict(set)
|
||||
_new_unauthorized_topics = set()
|
||||
_new_internal_topics = set()
|
||||
|
||||
for topic_data in metadata.topics:
|
||||
if metadata.API_VERSION == 0:
|
||||
error_code, topic, partitions = topic_data
|
||||
is_internal = False
|
||||
else:
|
||||
error_code, topic, is_internal, partitions = topic_data
|
||||
if is_internal:
|
||||
_new_internal_topics.add(topic)
|
||||
error_type = Errors.for_code(error_code)
|
||||
if error_type is Errors.NoError:
|
||||
_new_partitions[topic] = {}
|
||||
for partition_data in partitions:
|
||||
leader_epoch = -1
|
||||
offline_replicas = []
|
||||
if metadata.API_VERSION >= 7:
|
||||
p_error, partition, leader, leader_epoch, replicas, isr, offline_replicas = partition_data
|
||||
elif metadata.API_VERSION >= 5:
|
||||
p_error, partition, leader, replicas, isr, offline_replicas = partition_data
|
||||
else:
|
||||
p_error, partition, leader, replicas, isr = partition_data
|
||||
|
||||
_new_partitions[topic][partition] = PartitionMetadata(
|
||||
topic=topic, partition=partition,
|
||||
leader=leader, leader_epoch=leader_epoch,
|
||||
replicas=replicas, isr=isr, offline_replicas=offline_replicas,
|
||||
error=p_error)
|
||||
if leader != -1:
|
||||
_new_broker_partitions[leader].add(
|
||||
TopicPartition(topic, partition))
|
||||
|
||||
# Specific topic errors can be ignored if this is a full metadata fetch
|
||||
elif self.need_all_topic_metadata:
|
||||
continue
|
||||
|
||||
elif error_type is Errors.LeaderNotAvailableError:
|
||||
log.warning("Topic %s is not available during auto-create"
|
||||
" initialization", topic)
|
||||
elif error_type is Errors.UnknownTopicOrPartitionError:
|
||||
log.error("Topic %s not found in cluster metadata", topic)
|
||||
elif error_type is Errors.TopicAuthorizationFailedError:
|
||||
log.error("Topic %s is not authorized for this client", topic)
|
||||
_new_unauthorized_topics.add(topic)
|
||||
elif error_type is Errors.InvalidTopicError:
|
||||
log.error("'%s' is not a valid topic name", topic)
|
||||
else:
|
||||
log.error("Error fetching metadata for topic %s: %s",
|
||||
topic, error_type)
|
||||
|
||||
with self._lock:
|
||||
self._brokers = _new_brokers
|
||||
self.controller = _new_controller
|
||||
self.cluster_id = _new_cluster_id
|
||||
self._partitions = _new_partitions
|
||||
self._broker_partitions = _new_broker_partitions
|
||||
self.unauthorized_topics = _new_unauthorized_topics
|
||||
self.internal_topics = _new_internal_topics
|
||||
f = None
|
||||
if self._future:
|
||||
f = self._future
|
||||
self._future = None
|
||||
self._need_update = False
|
||||
|
||||
now = time.time() * 1000
|
||||
self._last_refresh_ms = now
|
||||
self._last_successful_refresh_ms = now
|
||||
|
||||
if f:
|
||||
# In the common case where we ask for a single topic and get back an
|
||||
# error, we should fail the future
|
||||
if len(metadata.topics) == 1 and metadata.topics[0][0] != Errors.NoError.errno:
|
||||
error_code, topic = metadata.topics[0][:2]
|
||||
error = Errors.for_code(error_code)(topic)
|
||||
f.failure(error)
|
||||
else:
|
||||
f.success(self)
|
||||
|
||||
log.debug("Updated cluster metadata to %s", self)
|
||||
|
||||
for listener in self._listeners:
|
||||
listener(self)
|
||||
|
||||
if self.need_all_topic_metadata:
|
||||
# the listener may change the interested topics,
|
||||
# which could cause another metadata refresh.
|
||||
# If we have already fetched all topics, however,
|
||||
# another fetch should be unnecessary.
|
||||
self._need_update = False
|
||||
|
||||
def add_listener(self, listener):
|
||||
"""Add a callback function to be called on each metadata update"""
|
||||
self._listeners.add(listener)
|
||||
|
||||
def remove_listener(self, listener):
|
||||
"""Remove a previously added listener callback"""
|
||||
self._listeners.remove(listener)
|
||||
|
||||
def add_coordinator(self, response, coord_type, coord_key):
|
||||
"""Update with metadata for a group or txn coordinator
|
||||
|
||||
Arguments:
|
||||
response (FindCoordinatorResponse): broker response
|
||||
coord_type (str): 'group' or 'transaction'
|
||||
coord_key (str): consumer_group or transactional_id
|
||||
|
||||
Returns:
|
||||
string: coordinator node_id if metadata is updated, None on error
|
||||
"""
|
||||
log.debug("Updating coordinator for %s/%s: %s", coord_type, coord_key, response)
|
||||
error_type = Errors.for_code(response.error_code)
|
||||
if error_type is not Errors.NoError:
|
||||
log.error("FindCoordinatorResponse error: %s", error_type)
|
||||
self._coordinators[(coord_type, coord_key)] = -1
|
||||
return
|
||||
|
||||
# Use a coordinator-specific node id so that requests
|
||||
# get a dedicated connection
|
||||
node_id = 'coordinator-{}'.format(response.coordinator_id)
|
||||
coordinator = BrokerMetadata(
|
||||
node_id,
|
||||
response.host,
|
||||
response.port,
|
||||
None)
|
||||
|
||||
log.info("Coordinator for %s/%s is %s", coord_type, coord_key, coordinator)
|
||||
self._coordinator_brokers[node_id] = coordinator
|
||||
self._coordinators[(coord_type, coord_key)] = node_id
|
||||
return node_id
|
||||
|
||||
def with_partitions(self, partitions_to_add):
|
||||
"""Returns a copy of cluster metadata with partitions added"""
|
||||
new_metadata = ClusterMetadata(**self.config)
|
||||
new_metadata._brokers = copy.deepcopy(self._brokers)
|
||||
new_metadata._partitions = copy.deepcopy(self._partitions)
|
||||
new_metadata._broker_partitions = copy.deepcopy(self._broker_partitions)
|
||||
new_metadata._coordinators = copy.deepcopy(self._coordinators)
|
||||
new_metadata.internal_topics = copy.deepcopy(self.internal_topics)
|
||||
new_metadata.unauthorized_topics = copy.deepcopy(self.unauthorized_topics)
|
||||
|
||||
for partition in partitions_to_add:
|
||||
new_metadata._partitions[partition.topic][partition.partition] = partition
|
||||
|
||||
if partition.leader is not None and partition.leader != -1:
|
||||
new_metadata._broker_partitions[partition.leader].add(
|
||||
TopicPartition(partition.topic, partition.partition))
|
||||
|
||||
return new_metadata
|
||||
|
||||
def __str__(self):
|
||||
return 'ClusterMetadata(brokers: %d, topics: %d, coordinators: %d)' % \
|
||||
(len(self._brokers), len(self._partitions), len(self._coordinators))
|
||||
|
||||
|
||||
def collect_hosts(hosts, randomize=True):
|
||||
"""
|
||||
Collects a comma-separated set of hosts (host:port) and optionally
|
||||
randomize the returned list.
|
||||
"""
|
||||
|
||||
if isinstance(hosts, six.string_types):
|
||||
hosts = hosts.strip().split(',')
|
||||
|
||||
result = []
|
||||
for host_port in hosts:
|
||||
# ignore leading SECURITY_PROTOCOL:// to mimic java client
|
||||
host_port = re.sub('^.*://', '', host_port)
|
||||
host, port, afi = get_ip_port_afi(host_port)
|
||||
result.append((host, port, afi))
|
||||
|
||||
if randomize:
|
||||
random.shuffle(result)
|
||||
return result
|
||||
@@ -0,0 +1,333 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import platform
|
||||
import struct
|
||||
|
||||
from kafka.vendor import six
|
||||
from kafka.vendor.six.moves import range
|
||||
|
||||
_XERIAL_V1_HEADER = (-126, b'S', b'N', b'A', b'P', b'P', b'Y', 0, 1, 1)
|
||||
_XERIAL_V1_FORMAT = 'bccccccBii'
|
||||
ZSTD_MAX_OUTPUT_SIZE = 1024 * 1024
|
||||
|
||||
try:
|
||||
import snappy
|
||||
except ImportError:
|
||||
snappy = None
|
||||
|
||||
try:
|
||||
import zstandard as zstd
|
||||
except ImportError:
|
||||
zstd = None
|
||||
|
||||
try:
|
||||
import lz4.frame as lz4
|
||||
|
||||
def _lz4_compress(payload, **kwargs):
|
||||
# Kafka does not support LZ4 dependent blocks
|
||||
try:
|
||||
# For lz4>=0.12.0
|
||||
kwargs.pop('block_linked', None)
|
||||
return lz4.compress(payload, block_linked=False, **kwargs)
|
||||
except TypeError:
|
||||
# For earlier versions of lz4
|
||||
kwargs.pop('block_mode', None)
|
||||
return lz4.compress(payload, block_mode=1, **kwargs)
|
||||
|
||||
except ImportError:
|
||||
lz4 = None
|
||||
|
||||
try:
|
||||
import lz4f
|
||||
except ImportError:
|
||||
lz4f = None
|
||||
|
||||
try:
|
||||
import lz4framed
|
||||
except ImportError:
|
||||
lz4framed = None
|
||||
|
||||
try:
|
||||
import xxhash
|
||||
except ImportError:
|
||||
xxhash = None
|
||||
|
||||
PYPY = bool(platform.python_implementation() == 'PyPy')
|
||||
|
||||
def has_gzip():
|
||||
return True
|
||||
|
||||
|
||||
def has_snappy():
|
||||
return snappy is not None
|
||||
|
||||
|
||||
def has_zstd():
|
||||
return zstd is not None
|
||||
|
||||
|
||||
def has_lz4():
|
||||
if lz4 is not None:
|
||||
return True
|
||||
if lz4f is not None:
|
||||
return True
|
||||
if lz4framed is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def gzip_encode(payload, compresslevel=None):
|
||||
if not compresslevel:
|
||||
compresslevel = 9
|
||||
|
||||
buf = io.BytesIO()
|
||||
|
||||
# Gzip context manager introduced in python 2.7
|
||||
# so old-fashioned way until we decide to not support 2.6
|
||||
gzipper = gzip.GzipFile(fileobj=buf, mode="w", compresslevel=compresslevel)
|
||||
try:
|
||||
gzipper.write(payload)
|
||||
finally:
|
||||
gzipper.close()
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def gzip_decode(payload):
|
||||
buf = io.BytesIO(payload)
|
||||
|
||||
# Gzip context manager introduced in python 2.7
|
||||
# so old-fashioned way until we decide to not support 2.6
|
||||
gzipper = gzip.GzipFile(fileobj=buf, mode='r')
|
||||
try:
|
||||
return gzipper.read()
|
||||
finally:
|
||||
gzipper.close()
|
||||
|
||||
|
||||
def snappy_encode(payload, xerial_compatible=True, xerial_blocksize=32*1024):
|
||||
"""Encodes the given data with snappy compression.
|
||||
|
||||
If xerial_compatible is set then the stream is encoded in a fashion
|
||||
compatible with the xerial snappy library.
|
||||
|
||||
The block size (xerial_blocksize) controls how frequent the blocking occurs
|
||||
32k is the default in the xerial library.
|
||||
|
||||
The format winds up being:
|
||||
|
||||
|
||||
+-------------+------------+--------------+------------+--------------+
|
||||
| Header | Block1 len | Block1 data | Blockn len | Blockn data |
|
||||
+-------------+------------+--------------+------------+--------------+
|
||||
| 16 bytes | BE int32 | snappy bytes | BE int32 | snappy bytes |
|
||||
+-------------+------------+--------------+------------+--------------+
|
||||
|
||||
|
||||
It is important to note that the blocksize is the amount of uncompressed
|
||||
data presented to snappy at each block, whereas the blocklen is the number
|
||||
of bytes that will be present in the stream; so the length will always be
|
||||
<= blocksize.
|
||||
|
||||
"""
|
||||
|
||||
if not has_snappy():
|
||||
raise NotImplementedError("Snappy codec is not available")
|
||||
|
||||
if not xerial_compatible:
|
||||
return snappy.compress(payload)
|
||||
|
||||
out = io.BytesIO()
|
||||
for fmt, dat in zip(_XERIAL_V1_FORMAT, _XERIAL_V1_HEADER):
|
||||
out.write(struct.pack('!' + fmt, dat))
|
||||
|
||||
# Chunk through buffers to avoid creating intermediate slice copies
|
||||
if PYPY:
|
||||
# on pypy, snappy.compress() on a sliced buffer consumes the entire
|
||||
# buffer... likely a python-snappy bug, so just use a slice copy
|
||||
chunker = lambda payload, i, size: payload[i:size+i]
|
||||
|
||||
elif six.PY2:
|
||||
# Sliced buffer avoids additional copies
|
||||
# pylint: disable-msg=undefined-variable
|
||||
chunker = lambda payload, i, size: buffer(payload, i, size)
|
||||
else:
|
||||
# snappy.compress does not like raw memoryviews, so we have to convert
|
||||
# tobytes, which is a copy... oh well. it's the thought that counts.
|
||||
# pylint: disable-msg=undefined-variable
|
||||
chunker = lambda payload, i, size: memoryview(payload)[i:size+i].tobytes()
|
||||
|
||||
for chunk in (chunker(payload, i, xerial_blocksize)
|
||||
for i in range(0, len(payload), xerial_blocksize)):
|
||||
|
||||
block = snappy.compress(chunk)
|
||||
block_size = len(block)
|
||||
out.write(struct.pack('!i', block_size))
|
||||
out.write(block)
|
||||
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _detect_xerial_stream(payload):
|
||||
"""Detects if the data given might have been encoded with the blocking mode
|
||||
of the xerial snappy library.
|
||||
|
||||
This mode writes a magic header of the format:
|
||||
+--------+--------------+------------+---------+--------+
|
||||
| Marker | Magic String | Null / Pad | Version | Compat |
|
||||
+--------+--------------+------------+---------+--------+
|
||||
| byte | c-string | byte | int32 | int32 |
|
||||
+--------+--------------+------------+---------+--------+
|
||||
| -126 | 'SNAPPY' | \0 | | |
|
||||
+--------+--------------+------------+---------+--------+
|
||||
|
||||
The pad appears to be to ensure that SNAPPY is a valid cstring
|
||||
The version is the version of this format as written by xerial,
|
||||
in the wild this is currently 1 as such we only support v1.
|
||||
|
||||
Compat is there to claim the minimum supported version that
|
||||
can read a xerial block stream, presently in the wild this is
|
||||
1.
|
||||
"""
|
||||
|
||||
if len(payload) > 16:
|
||||
magic = struct.unpack('!' + _XERIAL_V1_FORMAT[:8], bytes(payload)[:8])
|
||||
version, compat = struct.unpack('!' + _XERIAL_V1_FORMAT[8:], bytes(payload)[8:16])
|
||||
# Until there is more than one way to do xerial blocking, the version + compat
|
||||
# fields can be ignored. Also some producers (i.e., redpanda) are known to
|
||||
# incorrectly encode these as little-endian, and that causes us to fail decoding
|
||||
# when we otherwise would have succeeded.
|
||||
# See https://github.com/dpkp/kafka-python/issues/2414
|
||||
if magic == _XERIAL_V1_HEADER[:8]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def snappy_decode(payload):
|
||||
if not has_snappy():
|
||||
raise NotImplementedError("Snappy codec is not available")
|
||||
|
||||
if _detect_xerial_stream(payload):
|
||||
# TODO ? Should become a fileobj ?
|
||||
out = io.BytesIO()
|
||||
byt = payload[16:]
|
||||
length = len(byt)
|
||||
cursor = 0
|
||||
|
||||
while cursor < length:
|
||||
block_size = struct.unpack_from('!i', byt[cursor:])[0]
|
||||
# Skip the block size
|
||||
cursor += 4
|
||||
end = cursor + block_size
|
||||
out.write(snappy.decompress(byt[cursor:end]))
|
||||
cursor = end
|
||||
|
||||
out.seek(0)
|
||||
return out.read()
|
||||
else:
|
||||
return snappy.decompress(payload)
|
||||
|
||||
|
||||
if lz4:
|
||||
lz4_encode = _lz4_compress # pylint: disable-msg=no-member
|
||||
elif lz4f:
|
||||
lz4_encode = lz4f.compressFrame # pylint: disable-msg=no-member
|
||||
elif lz4framed:
|
||||
lz4_encode = lz4framed.compress # pylint: disable-msg=no-member
|
||||
else:
|
||||
lz4_encode = None
|
||||
|
||||
|
||||
def lz4f_decode(payload):
|
||||
"""Decode payload using interoperable LZ4 framing. Requires Kafka >= 0.10"""
|
||||
# pylint: disable-msg=no-member
|
||||
ctx = lz4f.createDecompContext()
|
||||
data = lz4f.decompressFrame(payload, ctx)
|
||||
lz4f.freeDecompContext(ctx)
|
||||
|
||||
# lz4f python module does not expose how much of the payload was
|
||||
# actually read if the decompression was only partial.
|
||||
if data['next'] != 0:
|
||||
raise RuntimeError('lz4f unable to decompress full payload')
|
||||
return data['decomp']
|
||||
|
||||
|
||||
if lz4:
|
||||
lz4_decode = lz4.decompress # pylint: disable-msg=no-member
|
||||
elif lz4f:
|
||||
lz4_decode = lz4f_decode
|
||||
elif lz4framed:
|
||||
lz4_decode = lz4framed.decompress # pylint: disable-msg=no-member
|
||||
else:
|
||||
lz4_decode = None
|
||||
|
||||
|
||||
def lz4_encode_old_kafka(payload):
|
||||
"""Encode payload for 0.8/0.9 brokers -- requires an incorrect header checksum."""
|
||||
assert xxhash is not None
|
||||
data = lz4_encode(payload)
|
||||
header_size = 7
|
||||
flg = data[4]
|
||||
if not isinstance(flg, int):
|
||||
flg = ord(flg)
|
||||
|
||||
content_size_bit = ((flg >> 3) & 1)
|
||||
if content_size_bit:
|
||||
# Old kafka does not accept the content-size field
|
||||
# so we need to discard it and reset the header flag
|
||||
flg -= 8
|
||||
data = bytearray(data)
|
||||
data[4] = flg
|
||||
data = bytes(data)
|
||||
payload = data[header_size+8:]
|
||||
else:
|
||||
payload = data[header_size:]
|
||||
|
||||
# This is the incorrect hc
|
||||
hc = xxhash.xxh32(data[0:header_size-1]).digest()[-2:-1] # pylint: disable-msg=no-member
|
||||
|
||||
return b''.join([
|
||||
data[0:header_size-1],
|
||||
hc,
|
||||
payload
|
||||
])
|
||||
|
||||
|
||||
def lz4_decode_old_kafka(payload):
|
||||
assert xxhash is not None
|
||||
# Kafka's LZ4 code has a bug in its header checksum implementation
|
||||
header_size = 7
|
||||
if isinstance(payload[4], int):
|
||||
flg = payload[4]
|
||||
else:
|
||||
flg = ord(payload[4])
|
||||
content_size_bit = ((flg >> 3) & 1)
|
||||
if content_size_bit:
|
||||
header_size += 8
|
||||
|
||||
# This should be the correct hc
|
||||
hc = xxhash.xxh32(payload[4:header_size-1]).digest()[-2:-1] # pylint: disable-msg=no-member
|
||||
|
||||
munged_payload = b''.join([
|
||||
payload[0:header_size-1],
|
||||
hc,
|
||||
payload[header_size:]
|
||||
])
|
||||
return lz4_decode(munged_payload)
|
||||
|
||||
|
||||
def zstd_encode(payload):
|
||||
if not zstd:
|
||||
raise NotImplementedError("Zstd codec is not available")
|
||||
return zstd.ZstdCompressor().compress(payload)
|
||||
|
||||
|
||||
def zstd_decode(payload):
|
||||
if not zstd:
|
||||
raise NotImplementedError("Zstd codec is not available")
|
||||
try:
|
||||
return zstd.ZstdDecompressor().decompress(payload)
|
||||
except zstd.ZstdError:
|
||||
return zstd.ZstdDecompressor().decompress(payload, max_output_size=ZSTD_MAX_OUTPUT_SIZE)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.consumer.group import KafkaConsumer
|
||||
|
||||
__all__ = [
|
||||
'KafkaConsumer'
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
from collections import OrderedDict
|
||||
try:
|
||||
from collections.abc import Sequence
|
||||
except ImportError:
|
||||
from collections import Sequence
|
||||
try:
|
||||
# enum in stdlib as of py3.4
|
||||
from enum import IntEnum # pylint: disable=import-error
|
||||
except ImportError:
|
||||
# vendored backport module
|
||||
from kafka.vendor.enum34 import IntEnum
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
import kafka.errors as Errors
|
||||
from kafka.protocol.list_offsets import OffsetResetStrategy
|
||||
from kafka.structs import OffsetAndMetadata
|
||||
from kafka.util import ensure_valid_topic_name, synchronized
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionType(IntEnum):
|
||||
NONE = 0
|
||||
AUTO_TOPICS = 1
|
||||
AUTO_PATTERN = 2
|
||||
USER_ASSIGNED = 3
|
||||
|
||||
|
||||
class SubscriptionState(object):
|
||||
"""
|
||||
A class for tracking the topics, partitions, and offsets for the consumer.
|
||||
A partition is "assigned" either directly with assign_from_user() (manual
|
||||
assignment) or with assign_from_subscribed() (automatic assignment from
|
||||
subscription).
|
||||
|
||||
Once assigned, the partition is not considered "fetchable" until its initial
|
||||
position has been set with seek(). Fetchable partitions track a fetch
|
||||
position which is used to set the offset of the next fetch, and a consumed
|
||||
position which is the last offset that has been returned to the user. You
|
||||
can suspend fetching from a partition through pause() without affecting the
|
||||
fetched/consumed offsets. The partition will remain unfetchable until the
|
||||
resume() is used. You can also query the pause state independently with
|
||||
is_paused().
|
||||
|
||||
Note that pause state as well as fetch/consumed positions are not preserved
|
||||
when partition assignment is changed whether directly by the user or
|
||||
through a group rebalance.
|
||||
"""
|
||||
_SUBSCRIPTION_EXCEPTION_MESSAGE = (
|
||||
"You must choose only one way to configure your consumer:"
|
||||
" (1) subscribe to specific topics by name,"
|
||||
" (2) subscribe to topics matching a regex pattern,"
|
||||
" (3) assign itself specific topic-partitions.")
|
||||
|
||||
def __init__(self, offset_reset_strategy='earliest'):
|
||||
"""Initialize a SubscriptionState instance
|
||||
|
||||
Keyword Arguments:
|
||||
offset_reset_strategy: 'earliest' or 'latest', otherwise
|
||||
exception will be raised when fetching an offset that is no
|
||||
longer available. Default: 'earliest'
|
||||
"""
|
||||
try:
|
||||
offset_reset_strategy = getattr(OffsetResetStrategy,
|
||||
offset_reset_strategy.upper())
|
||||
except AttributeError:
|
||||
log.warning('Unrecognized offset_reset_strategy, using NONE')
|
||||
offset_reset_strategy = OffsetResetStrategy.NONE
|
||||
self._default_offset_reset_strategy = offset_reset_strategy
|
||||
|
||||
self.subscription = None # set() or None
|
||||
self.subscription_type = SubscriptionType.NONE
|
||||
self.subscribed_pattern = None # regex str or None
|
||||
self._group_subscription = set()
|
||||
self._user_assignment = set()
|
||||
self.assignment = OrderedDict()
|
||||
self.rebalance_listener = None
|
||||
self.listeners = []
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def _set_subscription_type(self, subscription_type):
|
||||
if not isinstance(subscription_type, SubscriptionType):
|
||||
raise ValueError('SubscriptionType enum required')
|
||||
if self.subscription_type == SubscriptionType.NONE:
|
||||
self.subscription_type = subscription_type
|
||||
elif self.subscription_type != subscription_type:
|
||||
raise Errors.IllegalStateError(self._SUBSCRIPTION_EXCEPTION_MESSAGE)
|
||||
|
||||
@synchronized
|
||||
def subscribe(self, topics=(), pattern=None, listener=None):
|
||||
"""Subscribe to a list of topics, or a topic regex pattern.
|
||||
|
||||
Partitions will be dynamically assigned via a group coordinator.
|
||||
Topic subscriptions are not incremental: this list will replace the
|
||||
current assignment (if there is one).
|
||||
|
||||
This method is incompatible with assign_from_user()
|
||||
|
||||
Arguments:
|
||||
topics (list): List of topics for subscription.
|
||||
pattern (str): Pattern to match available topics. You must provide
|
||||
either topics or pattern, but not both.
|
||||
listener (ConsumerRebalanceListener): Optionally include listener
|
||||
callback, which will be called before and after each rebalance
|
||||
operation.
|
||||
|
||||
As part of group management, the consumer will keep track of the
|
||||
list of consumers that belong to a particular group and will
|
||||
trigger a rebalance operation if one of the following events
|
||||
trigger:
|
||||
|
||||
* Number of partitions change for any of the subscribed topics
|
||||
* Topic is created or deleted
|
||||
* An existing member of the consumer group dies
|
||||
* A new member is added to the consumer group
|
||||
|
||||
When any of these events are triggered, the provided listener
|
||||
will be invoked first to indicate that the consumer's assignment
|
||||
has been revoked, and then again when the new assignment has
|
||||
been received. Note that this listener will immediately override
|
||||
any listener set in a previous call to subscribe. It is
|
||||
guaranteed, however, that the partitions revoked/assigned
|
||||
through this interface are from topics subscribed in this call.
|
||||
"""
|
||||
assert topics or pattern, 'Must provide topics or pattern'
|
||||
if (topics and pattern):
|
||||
raise Errors.IllegalStateError(self._SUBSCRIPTION_EXCEPTION_MESSAGE)
|
||||
|
||||
elif pattern:
|
||||
self._set_subscription_type(SubscriptionType.AUTO_PATTERN)
|
||||
log.info('Subscribing to pattern: /%s/', pattern)
|
||||
self.subscription = set()
|
||||
self.subscribed_pattern = re.compile(pattern)
|
||||
else:
|
||||
if isinstance(topics, str) or not isinstance(topics, Sequence):
|
||||
raise TypeError('Topics must be a list (or non-str sequence)')
|
||||
self._set_subscription_type(SubscriptionType.AUTO_TOPICS)
|
||||
self.change_subscription(topics)
|
||||
|
||||
if listener and not isinstance(listener, ConsumerRebalanceListener):
|
||||
raise TypeError('listener must be a ConsumerRebalanceListener')
|
||||
self.rebalance_listener = listener
|
||||
|
||||
@synchronized
|
||||
def change_subscription(self, topics):
|
||||
"""Change the topic subscription.
|
||||
|
||||
Arguments:
|
||||
topics (list of str): topics for subscription
|
||||
|
||||
Raises:
|
||||
IllegalStateError: if assign_from_user has been used already
|
||||
TypeError: if a topic is None or a non-str
|
||||
ValueError: if a topic is an empty string or
|
||||
- a topic name is '.' or '..' or
|
||||
- a topic name does not consist of ASCII-characters/'-'/'_'/'.'
|
||||
"""
|
||||
if not self.partitions_auto_assigned():
|
||||
raise Errors.IllegalStateError(self._SUBSCRIPTION_EXCEPTION_MESSAGE)
|
||||
|
||||
if isinstance(topics, six.string_types):
|
||||
topics = [topics]
|
||||
|
||||
if self.subscription == set(topics):
|
||||
log.warning("subscription unchanged by change_subscription(%s)",
|
||||
topics)
|
||||
return
|
||||
|
||||
for t in topics:
|
||||
ensure_valid_topic_name(t)
|
||||
|
||||
log.info('Updating subscribed topics to: %s', topics)
|
||||
self.subscription = set(topics)
|
||||
self._group_subscription.update(topics)
|
||||
|
||||
@synchronized
|
||||
def group_subscribe(self, topics):
|
||||
"""Add topics to the current group subscription.
|
||||
|
||||
This is used by the group leader to ensure that it receives metadata
|
||||
updates for all topics that any member of the group is subscribed to.
|
||||
|
||||
Arguments:
|
||||
topics (list of str): topics to add to the group subscription
|
||||
"""
|
||||
if not self.partitions_auto_assigned():
|
||||
raise Errors.IllegalStateError(self._SUBSCRIPTION_EXCEPTION_MESSAGE)
|
||||
self._group_subscription.update(topics)
|
||||
|
||||
@synchronized
|
||||
def reset_group_subscription(self):
|
||||
"""Reset the group's subscription to only contain topics subscribed by this consumer."""
|
||||
if not self.partitions_auto_assigned():
|
||||
raise Errors.IllegalStateError(self._SUBSCRIPTION_EXCEPTION_MESSAGE)
|
||||
assert self.subscription is not None, 'Subscription required'
|
||||
self._group_subscription.intersection_update(self.subscription)
|
||||
|
||||
@synchronized
|
||||
def assign_from_user(self, partitions):
|
||||
"""Manually assign a list of TopicPartitions to this consumer.
|
||||
|
||||
This interface does not allow for incremental assignment and will
|
||||
replace the previous assignment (if there was one).
|
||||
|
||||
Manual topic assignment through this method does not use the consumer's
|
||||
group management functionality. As such, there will be no rebalance
|
||||
operation triggered when group membership or cluster and topic metadata
|
||||
change. Note that it is not possible to use both manual partition
|
||||
assignment with assign() and group assignment with subscribe().
|
||||
|
||||
Arguments:
|
||||
partitions (list of TopicPartition): assignment for this instance.
|
||||
|
||||
Raises:
|
||||
IllegalStateError: if consumer has already called subscribe()
|
||||
"""
|
||||
self._set_subscription_type(SubscriptionType.USER_ASSIGNED)
|
||||
if self._user_assignment != set(partitions):
|
||||
self._user_assignment = set(partitions)
|
||||
self._set_assignment({partition: self.assignment.get(partition, TopicPartitionState())
|
||||
for partition in partitions})
|
||||
|
||||
@synchronized
|
||||
def assign_from_subscribed(self, assignments):
|
||||
"""Update the assignment to the specified partitions
|
||||
|
||||
This method is called by the coordinator to dynamically assign
|
||||
partitions based on the consumer's topic subscription. This is different
|
||||
from assign_from_user() which directly sets the assignment from a
|
||||
user-supplied TopicPartition list.
|
||||
|
||||
Arguments:
|
||||
assignments (list of TopicPartition): partitions to assign to this
|
||||
consumer instance.
|
||||
"""
|
||||
if not self.partitions_auto_assigned():
|
||||
raise Errors.IllegalStateError(self._SUBSCRIPTION_EXCEPTION_MESSAGE)
|
||||
|
||||
for tp in assignments:
|
||||
if tp.topic not in self.subscription:
|
||||
raise ValueError("Assigned partition %s for non-subscribed topic." % (tp,))
|
||||
|
||||
# randomized ordering should improve balance for short-lived consumers
|
||||
self._set_assignment({partition: TopicPartitionState() for partition in assignments}, randomize=True)
|
||||
log.info("Updated partition assignment: %s", assignments)
|
||||
|
||||
def _set_assignment(self, partition_states, randomize=False):
|
||||
"""Batch partition assignment by topic (self.assignment is OrderedDict)"""
|
||||
self.assignment.clear()
|
||||
topics = [tp.topic for tp in six.iterkeys(partition_states)]
|
||||
if randomize:
|
||||
random.shuffle(topics)
|
||||
topic_partitions = OrderedDict({topic: [] for topic in topics})
|
||||
for tp in six.iterkeys(partition_states):
|
||||
topic_partitions[tp.topic].append(tp)
|
||||
for topic in six.iterkeys(topic_partitions):
|
||||
for tp in topic_partitions[topic]:
|
||||
self.assignment[tp] = partition_states[tp]
|
||||
|
||||
@synchronized
|
||||
def unsubscribe(self):
|
||||
"""Clear all topic subscriptions and partition assignments"""
|
||||
self.subscription = None
|
||||
self._user_assignment.clear()
|
||||
self.assignment.clear()
|
||||
self.subscribed_pattern = None
|
||||
self.subscription_type = SubscriptionType.NONE
|
||||
|
||||
@synchronized
|
||||
def group_subscription(self):
|
||||
"""Get the topic subscription for the group.
|
||||
|
||||
For the leader, this will include the union of all member subscriptions.
|
||||
For followers, it is the member's subscription only.
|
||||
|
||||
This is used when querying topic metadata to detect metadata changes
|
||||
that would require rebalancing (the leader fetches metadata for all
|
||||
topics in the group so that it can do partition assignment).
|
||||
|
||||
Returns:
|
||||
set: topics
|
||||
"""
|
||||
return self._group_subscription
|
||||
|
||||
@synchronized
|
||||
def seek(self, partition, offset):
|
||||
"""Manually specify the fetch offset for a TopicPartition.
|
||||
|
||||
Overrides the fetch offsets that the consumer will use on the next
|
||||
poll(). If this API is invoked for the same partition more than once,
|
||||
the latest offset will be used on the next poll(). Note that you may
|
||||
lose data if this API is arbitrarily used in the middle of consumption,
|
||||
to reset the fetch offsets.
|
||||
|
||||
Arguments:
|
||||
partition (TopicPartition): partition for seek operation
|
||||
offset (int or OffsetAndMetadata): message offset in partition
|
||||
"""
|
||||
if not isinstance(offset, (int, OffsetAndMetadata)):
|
||||
raise TypeError("offset must be type in or OffsetAndMetadata")
|
||||
self.assignment[partition].seek(offset)
|
||||
|
||||
@synchronized
|
||||
def assigned_partitions(self):
|
||||
"""Return set of TopicPartitions in current assignment."""
|
||||
return set(self.assignment.keys())
|
||||
|
||||
@synchronized
|
||||
def paused_partitions(self):
|
||||
"""Return current set of paused TopicPartitions."""
|
||||
return set(partition for partition in self.assignment
|
||||
if self.is_paused(partition))
|
||||
|
||||
@synchronized
|
||||
def fetchable_partitions(self):
|
||||
"""Return ordered list of TopicPartitions that should be Fetched."""
|
||||
fetchable = list()
|
||||
for partition, state in six.iteritems(self.assignment):
|
||||
if state.is_fetchable():
|
||||
fetchable.append(partition)
|
||||
return fetchable
|
||||
|
||||
@synchronized
|
||||
def partitions_auto_assigned(self):
|
||||
"""Return True unless user supplied partitions manually."""
|
||||
return self.subscription_type in (SubscriptionType.AUTO_TOPICS, SubscriptionType.AUTO_PATTERN)
|
||||
|
||||
@synchronized
|
||||
def all_consumed_offsets(self):
|
||||
"""Returns consumed offsets as {TopicPartition: OffsetAndMetadata}"""
|
||||
all_consumed = {}
|
||||
for partition, state in six.iteritems(self.assignment):
|
||||
if state.has_valid_position:
|
||||
all_consumed[partition] = state.position
|
||||
return all_consumed
|
||||
|
||||
@synchronized
|
||||
def request_offset_reset(self, partition, offset_reset_strategy=None):
|
||||
"""Mark partition for offset reset using specified or default strategy.
|
||||
|
||||
Arguments:
|
||||
partition (TopicPartition): partition to mark
|
||||
offset_reset_strategy (OffsetResetStrategy, optional)
|
||||
"""
|
||||
if offset_reset_strategy is None:
|
||||
offset_reset_strategy = self._default_offset_reset_strategy
|
||||
self.assignment[partition].reset(offset_reset_strategy)
|
||||
|
||||
@synchronized
|
||||
def set_reset_pending(self, partitions, next_allowed_reset_time):
|
||||
for partition in partitions:
|
||||
self.assignment[partition].set_reset_pending(next_allowed_reset_time)
|
||||
|
||||
@synchronized
|
||||
def has_default_offset_reset_policy(self):
|
||||
"""Return True if default offset reset policy is Earliest or Latest"""
|
||||
return self._default_offset_reset_strategy != OffsetResetStrategy.NONE
|
||||
|
||||
@synchronized
|
||||
def is_offset_reset_needed(self, partition):
|
||||
return self.assignment[partition].awaiting_reset
|
||||
|
||||
@synchronized
|
||||
def has_all_fetch_positions(self):
|
||||
for state in six.itervalues(self.assignment):
|
||||
if not state.has_valid_position:
|
||||
return False
|
||||
return True
|
||||
|
||||
@synchronized
|
||||
def missing_fetch_positions(self):
|
||||
missing = set()
|
||||
for partition, state in six.iteritems(self.assignment):
|
||||
if state.is_missing_position():
|
||||
missing.add(partition)
|
||||
return missing
|
||||
|
||||
@synchronized
|
||||
def has_valid_position(self, partition):
|
||||
return partition in self.assignment and self.assignment[partition].has_valid_position
|
||||
|
||||
@synchronized
|
||||
def reset_missing_positions(self):
|
||||
partitions_with_no_offsets = set()
|
||||
for tp, state in six.iteritems(self.assignment):
|
||||
if state.is_missing_position():
|
||||
if self._default_offset_reset_strategy == OffsetResetStrategy.NONE:
|
||||
partitions_with_no_offsets.add(tp)
|
||||
else:
|
||||
state.reset(self._default_offset_reset_strategy)
|
||||
|
||||
if partitions_with_no_offsets:
|
||||
raise Errors.NoOffsetForPartitionError(partitions_with_no_offsets)
|
||||
|
||||
@synchronized
|
||||
def partitions_needing_reset(self):
|
||||
partitions = set()
|
||||
for tp, state in six.iteritems(self.assignment):
|
||||
if state.awaiting_reset and state.is_reset_allowed():
|
||||
partitions.add(tp)
|
||||
return partitions
|
||||
|
||||
@synchronized
|
||||
def is_assigned(self, partition):
|
||||
return partition in self.assignment
|
||||
|
||||
@synchronized
|
||||
def is_paused(self, partition):
|
||||
return partition in self.assignment and self.assignment[partition].paused
|
||||
|
||||
@synchronized
|
||||
def is_fetchable(self, partition):
|
||||
return partition in self.assignment and self.assignment[partition].is_fetchable()
|
||||
|
||||
@synchronized
|
||||
def pause(self, partition):
|
||||
self.assignment[partition].pause()
|
||||
|
||||
@synchronized
|
||||
def resume(self, partition):
|
||||
self.assignment[partition].resume()
|
||||
|
||||
@synchronized
|
||||
def reset_failed(self, partitions, next_retry_time):
|
||||
for partition in partitions:
|
||||
self.assignment[partition].reset_failed(next_retry_time)
|
||||
|
||||
@synchronized
|
||||
def move_partition_to_end(self, partition):
|
||||
if partition in self.assignment:
|
||||
try:
|
||||
self.assignment.move_to_end(partition)
|
||||
except AttributeError:
|
||||
state = self.assignment.pop(partition)
|
||||
self.assignment[partition] = state
|
||||
|
||||
@synchronized
|
||||
def position(self, partition):
|
||||
return self.assignment[partition].position
|
||||
|
||||
|
||||
class TopicPartitionState(object):
|
||||
def __init__(self):
|
||||
self.paused = False # whether this partition has been paused by the user
|
||||
self.reset_strategy = None # the reset strategy if awaiting_reset is set
|
||||
self._position = None # OffsetAndMetadata exposed to the user
|
||||
self.highwater = None
|
||||
self.drop_pending_record_batch = False
|
||||
self.next_allowed_retry_time = None
|
||||
|
||||
def _set_position(self, offset):
|
||||
assert self.has_valid_position, 'Valid position required'
|
||||
assert isinstance(offset, OffsetAndMetadata)
|
||||
self._position = offset
|
||||
|
||||
def _get_position(self):
|
||||
return self._position
|
||||
|
||||
position = property(_get_position, _set_position, None, "last position")
|
||||
|
||||
def reset(self, strategy):
|
||||
assert strategy is not None
|
||||
self.reset_strategy = strategy
|
||||
self._position = None
|
||||
self.next_allowed_retry_time = None
|
||||
|
||||
def is_reset_allowed(self):
|
||||
return self.next_allowed_retry_time is None or self.next_allowed_retry_time < time.time()
|
||||
|
||||
@property
|
||||
def awaiting_reset(self):
|
||||
return self.reset_strategy is not None
|
||||
|
||||
def set_reset_pending(self, next_allowed_retry_time):
|
||||
self.next_allowed_retry_time = next_allowed_retry_time
|
||||
|
||||
def reset_failed(self, next_allowed_retry_time):
|
||||
self.next_allowed_retry_time = next_allowed_retry_time
|
||||
|
||||
@property
|
||||
def has_valid_position(self):
|
||||
return self._position is not None
|
||||
|
||||
def is_missing_position(self):
|
||||
return not self.has_valid_position and not self.awaiting_reset
|
||||
|
||||
def seek(self, offset):
|
||||
self._position = offset if isinstance(offset, OffsetAndMetadata) else OffsetAndMetadata(offset, '', -1)
|
||||
self.reset_strategy = None
|
||||
self.drop_pending_record_batch = True
|
||||
self.next_allowed_retry_time = None
|
||||
|
||||
def pause(self):
|
||||
self.paused = True
|
||||
|
||||
def resume(self):
|
||||
self.paused = False
|
||||
|
||||
def is_fetchable(self):
|
||||
return not self.paused and self.has_valid_position
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class ConsumerRebalanceListener(object):
|
||||
"""
|
||||
A callback interface that the user can implement to trigger custom actions
|
||||
when the set of partitions assigned to the consumer changes.
|
||||
|
||||
This is applicable when the consumer is having Kafka auto-manage group
|
||||
membership. If the consumer's directly assign partitions, those
|
||||
partitions will never be reassigned and this callback is not applicable.
|
||||
|
||||
When Kafka is managing the group membership, a partition re-assignment will
|
||||
be triggered any time the members of the group changes or the subscription
|
||||
of the members changes. This can occur when processes die, new process
|
||||
instances are added or old instances come back to life after failure.
|
||||
Rebalances can also be triggered by changes affecting the subscribed
|
||||
topics (e.g. when then number of partitions is administratively adjusted).
|
||||
|
||||
There are many uses for this functionality. One common use is saving offsets
|
||||
in a custom store. By saving offsets in the on_partitions_revoked(), call we
|
||||
can ensure that any time partition assignment changes the offset gets saved.
|
||||
|
||||
Another use is flushing out any kind of cache of intermediate results the
|
||||
consumer may be keeping. For example, consider a case where the consumer is
|
||||
subscribed to a topic containing user page views, and the goal is to count
|
||||
the number of page views per users for each five minute window. Let's say
|
||||
the topic is partitioned by the user id so that all events for a particular
|
||||
user will go to a single consumer instance. The consumer can keep in memory
|
||||
a running tally of actions per user and only flush these out to a remote
|
||||
data store when its cache gets too big. However if a partition is reassigned
|
||||
it may want to automatically trigger a flush of this cache, before the new
|
||||
owner takes over consumption.
|
||||
|
||||
This callback will execute in the user thread as part of the Consumer.poll()
|
||||
whenever partition assignment changes.
|
||||
|
||||
It is guaranteed that all consumer processes will invoke
|
||||
on_partitions_revoked() prior to any process invoking
|
||||
on_partitions_assigned(). So if offsets or other state is saved in the
|
||||
on_partitions_revoked() call, it should be saved by the time the process
|
||||
taking over that partition has their on_partitions_assigned() callback
|
||||
called to load the state.
|
||||
"""
|
||||
@abc.abstractmethod
|
||||
def on_partitions_revoked(self, revoked):
|
||||
"""
|
||||
A callback method the user can implement to provide handling of offset
|
||||
commits to a customized store on the start of a rebalance operation.
|
||||
This method will be called before a rebalance operation starts and
|
||||
after the consumer stops fetching data. It is recommended that offsets
|
||||
should be committed in this callback to either Kafka or a custom offset
|
||||
store to prevent duplicate data.
|
||||
|
||||
NOTE: This method is only called before rebalances. It is not called
|
||||
prior to KafkaConsumer.close()
|
||||
|
||||
Arguments:
|
||||
revoked (list of TopicPartition): the partitions that were assigned
|
||||
to the consumer on the last rebalance
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def on_partitions_assigned(self, assigned):
|
||||
"""
|
||||
A callback method the user can implement to provide handling of
|
||||
customized offsets on completion of a successful partition
|
||||
re-assignment. This method will be called after an offset re-assignment
|
||||
completes and before the consumer starts fetching data.
|
||||
|
||||
It is guaranteed that all the processes in a consumer group will execute
|
||||
their on_partitions_revoked() callback before any instance executes its
|
||||
on_partitions_assigned() callback.
|
||||
|
||||
Arguments:
|
||||
assigned (list of TopicPartition): the partitions assigned to the
|
||||
consumer (may include partitions that were previously assigned)
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbstractPartitionAssignor(object):
|
||||
"""
|
||||
Abstract assignor implementation which does some common grunt work (in particular collecting
|
||||
partition counts which are always needed in assignors).
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def name(self):
|
||||
""".name should be a string identifying the assignor"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def assign(self, cluster, members):
|
||||
"""Perform group assignment given cluster metadata and member subscriptions
|
||||
|
||||
Arguments:
|
||||
cluster (ClusterMetadata): metadata for use in assignment
|
||||
members (dict of {member_id: MemberMetadata}): decoded metadata for
|
||||
each member in the group.
|
||||
|
||||
Returns:
|
||||
dict: {member_id: MemberAssignment}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def metadata(self, topics):
|
||||
"""Generate ProtocolMetadata to be submitted via JoinGroupRequest.
|
||||
|
||||
Arguments:
|
||||
topics (set): a member's subscribed topics
|
||||
|
||||
Returns:
|
||||
MemberMetadata struct
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def on_assignment(self, assignment):
|
||||
"""Callback that runs on each assignment.
|
||||
|
||||
This method can be used to update internal state, if any, of the
|
||||
partition assignor.
|
||||
|
||||
Arguments:
|
||||
assignment (MemberAssignment): the member's assignment
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
import logging
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
from kafka.coordinator.assignors.abstract import AbstractPartitionAssignor
|
||||
from kafka.coordinator.protocol import ConsumerProtocolMemberMetadata, ConsumerProtocolMemberAssignment
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RangePartitionAssignor(AbstractPartitionAssignor):
|
||||
"""
|
||||
The range assignor works on a per-topic basis. For each topic, we lay out
|
||||
the available partitions in numeric order and the consumers in
|
||||
lexicographic order. We then divide the number of partitions by the total
|
||||
number of consumers to determine the number of partitions to assign to each
|
||||
consumer. If it does not evenly divide, then the first few consumers will
|
||||
have one extra partition.
|
||||
|
||||
For example, suppose there are two consumers C0 and C1, two topics t0 and
|
||||
t1, and each topic has 3 partitions, resulting in partitions t0p0, t0p1,
|
||||
t0p2, t1p0, t1p1, and t1p2.
|
||||
|
||||
The assignment will be:
|
||||
C0: [t0p0, t0p1, t1p0, t1p1]
|
||||
C1: [t0p2, t1p2]
|
||||
"""
|
||||
name = 'range'
|
||||
version = 0
|
||||
|
||||
@classmethod
|
||||
def assign(cls, cluster, member_metadata):
|
||||
consumers_per_topic = collections.defaultdict(list)
|
||||
for member, metadata in six.iteritems(member_metadata):
|
||||
for topic in metadata.subscription:
|
||||
consumers_per_topic[topic].append(member)
|
||||
|
||||
# construct {member_id: {topic: [partition, ...]}}
|
||||
assignment = collections.defaultdict(dict)
|
||||
|
||||
for topic, consumers_for_topic in six.iteritems(consumers_per_topic):
|
||||
partitions = cluster.partitions_for_topic(topic)
|
||||
if partitions is None:
|
||||
log.warning('No partition metadata for topic %s', topic)
|
||||
continue
|
||||
partitions = sorted(partitions)
|
||||
consumers_for_topic.sort()
|
||||
|
||||
partitions_per_consumer = len(partitions) // len(consumers_for_topic)
|
||||
consumers_with_extra = len(partitions) % len(consumers_for_topic)
|
||||
|
||||
for i, member in enumerate(consumers_for_topic):
|
||||
start = partitions_per_consumer * i
|
||||
start += min(i, consumers_with_extra)
|
||||
length = partitions_per_consumer
|
||||
if not i + 1 > consumers_with_extra:
|
||||
length += 1
|
||||
assignment[member][topic] = partitions[start:start+length]
|
||||
|
||||
protocol_assignment = {}
|
||||
for member_id in member_metadata:
|
||||
protocol_assignment[member_id] = ConsumerProtocolMemberAssignment(
|
||||
cls.version,
|
||||
sorted(assignment[member_id].items()),
|
||||
b'')
|
||||
return protocol_assignment
|
||||
|
||||
@classmethod
|
||||
def metadata(cls, topics):
|
||||
return ConsumerProtocolMemberMetadata(cls.version, list(topics), b'')
|
||||
|
||||
@classmethod
|
||||
def on_assignment(cls, assignment):
|
||||
pass
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
import itertools
|
||||
import logging
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
from kafka.coordinator.assignors.abstract import AbstractPartitionAssignor
|
||||
from kafka.coordinator.protocol import ConsumerProtocolMemberMetadata, ConsumerProtocolMemberAssignment
|
||||
from kafka.structs import TopicPartition
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RoundRobinPartitionAssignor(AbstractPartitionAssignor):
|
||||
"""
|
||||
The roundrobin assignor lays out all the available partitions and all the
|
||||
available consumers. It then proceeds to do a roundrobin assignment from
|
||||
partition to consumer. If the subscriptions of all consumer instances are
|
||||
identical, then the partitions will be uniformly distributed. (i.e., the
|
||||
partition ownership counts will be within a delta of exactly one across all
|
||||
consumers.)
|
||||
|
||||
For example, suppose there are two consumers C0 and C1, two topics t0 and
|
||||
t1, and each topic has 3 partitions, resulting in partitions t0p0, t0p1,
|
||||
t0p2, t1p0, t1p1, and t1p2.
|
||||
|
||||
The assignment will be:
|
||||
C0: [t0p0, t0p2, t1p1]
|
||||
C1: [t0p1, t1p0, t1p2]
|
||||
|
||||
When subscriptions differ across consumer instances, the assignment process
|
||||
still considers each consumer instance in round robin fashion but skips
|
||||
over an instance if it is not subscribed to the topic. Unlike the case when
|
||||
subscriptions are identical, this can result in imbalanced assignments.
|
||||
|
||||
For example, suppose we have three consumers C0, C1, C2, and three topics
|
||||
t0, t1, t2, with unbalanced partitions t0p0, t1p0, t1p1, t2p0, t2p1, t2p2,
|
||||
where C0 is subscribed to t0; C1 is subscribed to t0, t1; and C2 is
|
||||
subscribed to t0, t1, t2.
|
||||
|
||||
The assignment will be:
|
||||
C0: [t0p0]
|
||||
C1: [t1p0]
|
||||
C2: [t1p1, t2p0, t2p1, t2p2]
|
||||
"""
|
||||
name = 'roundrobin'
|
||||
version = 0
|
||||
|
||||
@classmethod
|
||||
def assign(cls, cluster, member_metadata):
|
||||
all_topics = set()
|
||||
for metadata in six.itervalues(member_metadata):
|
||||
all_topics.update(metadata.subscription)
|
||||
|
||||
all_topic_partitions = []
|
||||
for topic in all_topics:
|
||||
partitions = cluster.partitions_for_topic(topic)
|
||||
if partitions is None:
|
||||
log.warning('No partition metadata for topic %s', topic)
|
||||
continue
|
||||
for partition in partitions:
|
||||
all_topic_partitions.append(TopicPartition(topic, partition))
|
||||
all_topic_partitions.sort()
|
||||
|
||||
# construct {member_id: {topic: [partition, ...]}}
|
||||
assignment = collections.defaultdict(lambda: collections.defaultdict(list))
|
||||
|
||||
member_iter = itertools.cycle(sorted(member_metadata.keys()))
|
||||
for partition in all_topic_partitions:
|
||||
member_id = next(member_iter)
|
||||
|
||||
# Because we constructed all_topic_partitions from the set of
|
||||
# member subscribed topics, we should be safe assuming that
|
||||
# each topic in all_topic_partitions is in at least one member
|
||||
# subscription; otherwise this could yield an infinite loop
|
||||
while partition.topic not in member_metadata[member_id].subscription:
|
||||
member_id = next(member_iter)
|
||||
assignment[member_id][partition.topic].append(partition.partition)
|
||||
|
||||
protocol_assignment = {}
|
||||
for member_id in member_metadata:
|
||||
protocol_assignment[member_id] = ConsumerProtocolMemberAssignment(
|
||||
cls.version,
|
||||
sorted(assignment[member_id].items()),
|
||||
b'')
|
||||
return protocol_assignment
|
||||
|
||||
@classmethod
|
||||
def metadata(cls, topics):
|
||||
return ConsumerProtocolMemberMetadata(cls.version, list(topics), b'')
|
||||
|
||||
@classmethod
|
||||
def on_assignment(cls, assignment):
|
||||
pass
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
from collections import defaultdict, namedtuple
|
||||
from copy import deepcopy
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ConsumerPair = namedtuple("ConsumerPair", ["src_member_id", "dst_member_id"])
|
||||
"""
|
||||
Represents a pair of Kafka consumer ids involved in a partition reassignment.
|
||||
Each ConsumerPair corresponds to a particular partition or topic, indicates that the particular partition or some
|
||||
partition of the particular topic was moved from the source consumer to the destination consumer
|
||||
during the rebalance. This class helps in determining whether a partition reassignment results in cycles among
|
||||
the generated graph of consumer pairs.
|
||||
"""
|
||||
|
||||
|
||||
def is_sublist(source, target):
|
||||
"""Checks if one list is a sublist of another.
|
||||
|
||||
Arguments:
|
||||
source: the list in which to search for the occurrence of target.
|
||||
target: the list to search for as a sublist of source
|
||||
|
||||
Returns:
|
||||
true if target is in source; false otherwise
|
||||
"""
|
||||
for index in (i for i, e in enumerate(source) if e == target[0]):
|
||||
if tuple(source[index: index + len(target)]) == target:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PartitionMovements:
|
||||
"""
|
||||
This class maintains some data structures to simplify lookup of partition movements among consumers.
|
||||
At each point of time during a partition rebalance it keeps track of partition movements
|
||||
corresponding to each topic, and also possible movement (in form a ConsumerPair object) for each partition.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.partition_movements_by_topic = defaultdict(
|
||||
lambda: defaultdict(set)
|
||||
)
|
||||
self.partition_movements = {}
|
||||
|
||||
def move_partition(self, partition, old_consumer, new_consumer):
|
||||
pair = ConsumerPair(src_member_id=old_consumer, dst_member_id=new_consumer)
|
||||
if partition in self.partition_movements:
|
||||
# this partition has previously moved
|
||||
existing_pair = self._remove_movement_record_of_partition(partition)
|
||||
assert existing_pair.dst_member_id == old_consumer
|
||||
if existing_pair.src_member_id != new_consumer:
|
||||
# the partition is not moving back to its previous consumer
|
||||
self._add_partition_movement_record(
|
||||
partition, ConsumerPair(src_member_id=existing_pair.src_member_id, dst_member_id=new_consumer)
|
||||
)
|
||||
else:
|
||||
self._add_partition_movement_record(partition, pair)
|
||||
|
||||
def get_partition_to_be_moved(self, partition, old_consumer, new_consumer):
|
||||
if partition.topic not in self.partition_movements_by_topic:
|
||||
return partition
|
||||
if partition in self.partition_movements:
|
||||
# this partition has previously moved
|
||||
assert old_consumer == self.partition_movements[partition].dst_member_id
|
||||
old_consumer = self.partition_movements[partition].src_member_id
|
||||
reverse_pair = ConsumerPair(src_member_id=new_consumer, dst_member_id=old_consumer)
|
||||
if reverse_pair not in self.partition_movements_by_topic[partition.topic]:
|
||||
return partition
|
||||
|
||||
return next(iter(self.partition_movements_by_topic[partition.topic][reverse_pair]))
|
||||
|
||||
def are_sticky(self):
|
||||
for topic, movements in six.iteritems(self.partition_movements_by_topic):
|
||||
movement_pairs = set(movements.keys())
|
||||
if self._has_cycles(movement_pairs):
|
||||
log.error(
|
||||
"Stickiness is violated for topic {}\n"
|
||||
"Partition movements for this topic occurred among the following consumer pairs:\n"
|
||||
"{}".format(topic, movement_pairs)
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _remove_movement_record_of_partition(self, partition):
|
||||
pair = self.partition_movements[partition]
|
||||
del self.partition_movements[partition]
|
||||
|
||||
self.partition_movements_by_topic[partition.topic][pair].remove(partition)
|
||||
if not self.partition_movements_by_topic[partition.topic][pair]:
|
||||
del self.partition_movements_by_topic[partition.topic][pair]
|
||||
if not self.partition_movements_by_topic[partition.topic]:
|
||||
del self.partition_movements_by_topic[partition.topic]
|
||||
|
||||
return pair
|
||||
|
||||
def _add_partition_movement_record(self, partition, pair):
|
||||
self.partition_movements[partition] = pair
|
||||
self.partition_movements_by_topic[partition.topic][pair].add(partition)
|
||||
|
||||
def _has_cycles(self, consumer_pairs):
|
||||
cycles = set()
|
||||
for pair in consumer_pairs:
|
||||
reduced_pairs = deepcopy(consumer_pairs)
|
||||
reduced_pairs.remove(pair)
|
||||
path = [pair.src_member_id]
|
||||
if self._is_linked(pair.dst_member_id, pair.src_member_id, reduced_pairs, path) and not self._is_subcycle(
|
||||
path, cycles
|
||||
):
|
||||
cycles.add(tuple(path))
|
||||
log.error("A cycle of length {} was found: {}".format(len(path) - 1, path))
|
||||
|
||||
# for now we want to make sure there is no partition movements of the same topic between a pair of consumers.
|
||||
# the odds of finding a cycle among more than two consumers seem to be very low (according to various randomized
|
||||
# tests with the given sticky algorithm) that it should not worth the added complexity of handling those cases.
|
||||
for cycle in cycles:
|
||||
if len(cycle) == 3: # indicates a cycle of length 2
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_subcycle(cycle, cycles):
|
||||
super_cycle = deepcopy(cycle)
|
||||
super_cycle = super_cycle[:-1]
|
||||
super_cycle.extend(cycle)
|
||||
for found_cycle in cycles:
|
||||
if len(found_cycle) == len(cycle) and is_sublist(super_cycle, found_cycle):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_linked(self, src, dst, pairs, current_path):
|
||||
if src == dst:
|
||||
return False
|
||||
if not pairs:
|
||||
return False
|
||||
if ConsumerPair(src, dst) in pairs:
|
||||
current_path.append(src)
|
||||
current_path.append(dst)
|
||||
return True
|
||||
for pair in pairs:
|
||||
if pair.src_member_id == src:
|
||||
reduced_set = deepcopy(pairs)
|
||||
reduced_set.remove(pair)
|
||||
current_path.append(pair.src_member_id)
|
||||
return self._is_linked(pair.dst_member_id, dst, reduced_set, current_path)
|
||||
return False
|
||||
@@ -0,0 +1,63 @@
|
||||
class SortedSet:
|
||||
def __init__(self, iterable=None, key=None):
|
||||
self._key = key if key is not None else lambda x: x
|
||||
self._set = set(iterable) if iterable is not None else set()
|
||||
|
||||
self._cached_last = None
|
||||
self._cached_first = None
|
||||
|
||||
def first(self):
|
||||
if self._cached_first is not None:
|
||||
return self._cached_first
|
||||
|
||||
first = None
|
||||
for element in self._set:
|
||||
if first is None or self._key(first) > self._key(element):
|
||||
first = element
|
||||
self._cached_first = first
|
||||
return first
|
||||
|
||||
def last(self):
|
||||
if self._cached_last is not None:
|
||||
return self._cached_last
|
||||
|
||||
last = None
|
||||
for element in self._set:
|
||||
if last is None or self._key(last) < self._key(element):
|
||||
last = element
|
||||
self._cached_last = last
|
||||
return last
|
||||
|
||||
def pop_last(self):
|
||||
value = self.last()
|
||||
self._set.remove(value)
|
||||
self._cached_last = None
|
||||
return value
|
||||
|
||||
def add(self, value):
|
||||
if self._cached_last is not None and self._key(value) > self._key(self._cached_last):
|
||||
self._cached_last = value
|
||||
if self._cached_first is not None and self._key(value) < self._key(self._cached_first):
|
||||
self._cached_first = value
|
||||
|
||||
return self._set.add(value)
|
||||
|
||||
def remove(self, value):
|
||||
if self._cached_last is not None and self._cached_last == value:
|
||||
self._cached_last = None
|
||||
if self._cached_first is not None and self._cached_first == value:
|
||||
self._cached_first = None
|
||||
|
||||
return self._set.remove(value)
|
||||
|
||||
def __contains__(self, value):
|
||||
return value in self._set
|
||||
|
||||
def __iter__(self):
|
||||
return iter(sorted(self._set, key=self._key))
|
||||
|
||||
def _bool(self):
|
||||
return len(self._set) != 0
|
||||
|
||||
__nonzero__ = _bool
|
||||
__bool__ = _bool
|
||||
+684
@@ -0,0 +1,684 @@
|
||||
import logging
|
||||
from collections import defaultdict, namedtuple
|
||||
from copy import deepcopy
|
||||
|
||||
from kafka.coordinator.assignors.abstract import AbstractPartitionAssignor
|
||||
from kafka.coordinator.assignors.sticky.partition_movements import PartitionMovements
|
||||
from kafka.coordinator.assignors.sticky.sorted_set import SortedSet
|
||||
from kafka.coordinator.protocol import ConsumerProtocolMemberMetadata, ConsumerProtocolMemberAssignment
|
||||
from kafka.coordinator.protocol import Schema
|
||||
from kafka.protocol.struct import Struct
|
||||
from kafka.protocol.types import String, Array, Int32
|
||||
from kafka.structs import TopicPartition
|
||||
from kafka.vendor import six
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ConsumerGenerationPair = namedtuple("ConsumerGenerationPair", ["consumer", "generation"])
|
||||
|
||||
|
||||
def has_identical_list_elements(list_):
|
||||
"""Checks if all lists in the collection have the same members
|
||||
|
||||
Arguments:
|
||||
list_: collection of lists
|
||||
|
||||
Returns:
|
||||
true if all lists in the collection have the same members; false otherwise
|
||||
"""
|
||||
if not list_:
|
||||
return True
|
||||
for i in range(1, len(list_)):
|
||||
if list_[i] != list_[i - 1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def subscriptions_comparator_key(element):
|
||||
return len(element[1]), element[0]
|
||||
|
||||
|
||||
def partitions_comparator_key(element):
|
||||
return len(element[1]), element[0].topic, element[0].partition
|
||||
|
||||
|
||||
def remove_if_present(collection, element):
|
||||
try:
|
||||
collection.remove(element)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
|
||||
StickyAssignorMemberMetadataV1 = namedtuple("StickyAssignorMemberMetadataV1",
|
||||
["subscription", "partitions", "generation"])
|
||||
|
||||
|
||||
class StickyAssignorUserDataV1(Struct):
|
||||
"""
|
||||
Used for preserving consumer's previously assigned partitions
|
||||
list and sending it as user data to the leader during a rebalance
|
||||
"""
|
||||
|
||||
SCHEMA = Schema(
|
||||
("previous_assignment", Array(("topic", String("utf-8")), ("partitions", Array(Int32)))), ("generation", Int32)
|
||||
)
|
||||
|
||||
|
||||
class StickyAssignmentExecutor:
|
||||
def __init__(self, cluster, members):
|
||||
self.members = members
|
||||
# a mapping between consumers and their assigned partitions that is updated during assignment procedure
|
||||
self.current_assignment = defaultdict(list)
|
||||
# an assignment from a previous generation
|
||||
self.previous_assignment = {}
|
||||
# a mapping between partitions and their assigned consumers
|
||||
self.current_partition_consumer = {}
|
||||
# a flag indicating that there were no previous assignments performed ever
|
||||
self.is_fresh_assignment = False
|
||||
# a mapping of all topic partitions to all consumers that can be assigned to them
|
||||
self.partition_to_all_potential_consumers = {}
|
||||
# a mapping of all consumers to all potential topic partitions that can be assigned to them
|
||||
self.consumer_to_all_potential_partitions = {}
|
||||
# an ascending sorted set of consumers based on how many topic partitions are already assigned to them
|
||||
self.sorted_current_subscriptions = SortedSet()
|
||||
# an ascending sorted list of topic partitions based on how many consumers can potentially use them
|
||||
self.sorted_partitions = []
|
||||
# all partitions that need to be assigned
|
||||
self.unassigned_partitions = []
|
||||
# a flag indicating that a certain partition cannot remain assigned to its current consumer because the consumer
|
||||
# is no longer subscribed to its topic
|
||||
self.revocation_required = False
|
||||
|
||||
self.partition_movements = PartitionMovements()
|
||||
self._initialize(cluster)
|
||||
|
||||
def perform_initial_assignment(self):
|
||||
self._populate_sorted_partitions()
|
||||
self._populate_partitions_to_reassign()
|
||||
|
||||
def balance(self):
|
||||
self._initialize_current_subscriptions()
|
||||
initializing = len(self.current_assignment[self._get_consumer_with_most_subscriptions()]) == 0
|
||||
|
||||
# assign all unassigned partitions
|
||||
for partition in self.unassigned_partitions:
|
||||
# skip if there is no potential consumer for the partition
|
||||
if not self.partition_to_all_potential_consumers[partition]:
|
||||
continue
|
||||
self._assign_partition(partition)
|
||||
|
||||
# narrow down the reassignment scope to only those partitions that can actually be reassigned
|
||||
fixed_partitions = set()
|
||||
for partition in six.iterkeys(self.partition_to_all_potential_consumers):
|
||||
if not self._can_partition_participate_in_reassignment(partition):
|
||||
fixed_partitions.add(partition)
|
||||
for fixed_partition in fixed_partitions:
|
||||
remove_if_present(self.sorted_partitions, fixed_partition)
|
||||
remove_if_present(self.unassigned_partitions, fixed_partition)
|
||||
|
||||
# narrow down the reassignment scope to only those consumers that are subject to reassignment
|
||||
fixed_assignments = {}
|
||||
for consumer in six.iterkeys(self.consumer_to_all_potential_partitions):
|
||||
if not self._can_consumer_participate_in_reassignment(consumer):
|
||||
self._remove_consumer_from_current_subscriptions_and_maintain_order(consumer)
|
||||
fixed_assignments[consumer] = self.current_assignment[consumer]
|
||||
del self.current_assignment[consumer]
|
||||
|
||||
# create a deep copy of the current assignment so we can revert to it
|
||||
# if we do not get a more balanced assignment later
|
||||
prebalance_assignment = deepcopy(self.current_assignment)
|
||||
prebalance_partition_consumers = deepcopy(self.current_partition_consumer)
|
||||
|
||||
# if we don't already need to revoke something due to subscription changes,
|
||||
# first try to balance by only moving newly added partitions
|
||||
if not self.revocation_required:
|
||||
self._perform_reassignments(self.unassigned_partitions)
|
||||
reassignment_performed = self._perform_reassignments(self.sorted_partitions)
|
||||
|
||||
# if we are not preserving existing assignments and we have made changes to the current assignment
|
||||
# make sure we are getting a more balanced assignment; otherwise, revert to previous assignment
|
||||
if (
|
||||
not initializing
|
||||
and reassignment_performed
|
||||
and self._get_balance_score(self.current_assignment) >= self._get_balance_score(prebalance_assignment)
|
||||
):
|
||||
self.current_assignment = prebalance_assignment
|
||||
self.current_partition_consumer.clear()
|
||||
self.current_partition_consumer.update(prebalance_partition_consumers)
|
||||
|
||||
# add the fixed assignments (those that could not change) back
|
||||
for consumer, partitions in six.iteritems(fixed_assignments):
|
||||
self.current_assignment[consumer] = partitions
|
||||
self._add_consumer_to_current_subscriptions_and_maintain_order(consumer)
|
||||
|
||||
def get_final_assignment(self, member_id):
|
||||
assignment = defaultdict(list)
|
||||
for topic_partition in self.current_assignment[member_id]:
|
||||
assignment[topic_partition.topic].append(topic_partition.partition)
|
||||
assignment = {k: sorted(v) for k, v in six.iteritems(assignment)}
|
||||
return six.viewitems(assignment)
|
||||
|
||||
def _initialize(self, cluster):
|
||||
self._init_current_assignments(self.members)
|
||||
|
||||
for topic in cluster.topics():
|
||||
partitions = cluster.partitions_for_topic(topic)
|
||||
if partitions is None:
|
||||
log.warning("No partition metadata for topic %s", topic)
|
||||
continue
|
||||
for p in partitions:
|
||||
partition = TopicPartition(topic=topic, partition=p)
|
||||
self.partition_to_all_potential_consumers[partition] = []
|
||||
for consumer_id, member_metadata in six.iteritems(self.members):
|
||||
self.consumer_to_all_potential_partitions[consumer_id] = []
|
||||
for topic in member_metadata.subscription:
|
||||
if cluster.partitions_for_topic(topic) is None:
|
||||
log.warning("No partition metadata for topic {}".format(topic))
|
||||
continue
|
||||
for p in cluster.partitions_for_topic(topic):
|
||||
partition = TopicPartition(topic=topic, partition=p)
|
||||
self.consumer_to_all_potential_partitions[consumer_id].append(partition)
|
||||
self.partition_to_all_potential_consumers[partition].append(consumer_id)
|
||||
if consumer_id not in self.current_assignment:
|
||||
self.current_assignment[consumer_id] = []
|
||||
|
||||
def _init_current_assignments(self, members):
|
||||
# we need to process subscriptions' user data with each consumer's reported generation in mind
|
||||
# higher generations overwrite lower generations in case of a conflict
|
||||
# note that a conflict could exists only if user data is for different generations
|
||||
|
||||
# for each partition we create a map of its consumers by generation
|
||||
sorted_partition_consumers_by_generation = {}
|
||||
for consumer, member_metadata in six.iteritems(members):
|
||||
for partitions in member_metadata.partitions:
|
||||
if partitions in sorted_partition_consumers_by_generation:
|
||||
consumers = sorted_partition_consumers_by_generation[partitions]
|
||||
if member_metadata.generation and member_metadata.generation in consumers:
|
||||
# same partition is assigned to two consumers during the same rebalance.
|
||||
# log a warning and skip this record
|
||||
log.warning(
|
||||
"Partition {} is assigned to multiple consumers "
|
||||
"following sticky assignment generation {}.".format(partitions, member_metadata.generation)
|
||||
)
|
||||
else:
|
||||
consumers[member_metadata.generation] = consumer
|
||||
else:
|
||||
sorted_consumers = {member_metadata.generation: consumer}
|
||||
sorted_partition_consumers_by_generation[partitions] = sorted_consumers
|
||||
|
||||
# previous_assignment holds the prior ConsumerGenerationPair (before current) of each partition
|
||||
# current and previous consumers are the last two consumers of each partition in the above sorted map
|
||||
for partitions, consumers in six.iteritems(sorted_partition_consumers_by_generation):
|
||||
generations = sorted(consumers.keys(), reverse=True)
|
||||
self.current_assignment[consumers[generations[0]]].append(partitions)
|
||||
# now update previous assignment if any
|
||||
if len(generations) > 1:
|
||||
self.previous_assignment[partitions] = ConsumerGenerationPair(
|
||||
consumer=consumers[generations[1]], generation=generations[1]
|
||||
)
|
||||
|
||||
self.is_fresh_assignment = len(self.current_assignment) == 0
|
||||
|
||||
for consumer_id, partitions in six.iteritems(self.current_assignment):
|
||||
for partition in partitions:
|
||||
self.current_partition_consumer[partition] = consumer_id
|
||||
|
||||
def _are_subscriptions_identical(self):
|
||||
"""
|
||||
Returns:
|
||||
true, if both potential consumers of partitions and potential partitions that consumers can
|
||||
consume are the same
|
||||
"""
|
||||
if not has_identical_list_elements(list(six.itervalues(self.partition_to_all_potential_consumers))):
|
||||
return False
|
||||
return has_identical_list_elements(list(six.itervalues(self.consumer_to_all_potential_partitions)))
|
||||
|
||||
def _populate_sorted_partitions(self):
|
||||
# set of topic partitions with their respective potential consumers
|
||||
all_partitions = set((tp, tuple(consumers))
|
||||
for tp, consumers in six.iteritems(self.partition_to_all_potential_consumers))
|
||||
partitions_sorted_by_num_of_potential_consumers = sorted(all_partitions, key=partitions_comparator_key)
|
||||
|
||||
self.sorted_partitions = []
|
||||
if not self.is_fresh_assignment and self._are_subscriptions_identical():
|
||||
# if this is a reassignment and the subscriptions are identical (all consumers can consumer from all topics)
|
||||
# then we just need to simply list partitions in a round robin fashion (from consumers with
|
||||
# most assigned partitions to those with least)
|
||||
assignments = deepcopy(self.current_assignment)
|
||||
for consumer_id, partitions in six.iteritems(assignments):
|
||||
to_remove = []
|
||||
for partition in partitions:
|
||||
if partition not in self.partition_to_all_potential_consumers:
|
||||
to_remove.append(partition)
|
||||
for partition in to_remove:
|
||||
partitions.remove(partition)
|
||||
|
||||
sorted_consumers = SortedSet(
|
||||
iterable=[(consumer, tuple(partitions)) for consumer, partitions in six.iteritems(assignments)],
|
||||
key=subscriptions_comparator_key,
|
||||
)
|
||||
# at this point, sorted_consumers contains an ascending-sorted list of consumers based on
|
||||
# how many valid partitions are currently assigned to them
|
||||
while sorted_consumers:
|
||||
# take the consumer with the most partitions
|
||||
consumer, _ = sorted_consumers.pop_last()
|
||||
# currently assigned partitions to this consumer
|
||||
remaining_partitions = assignments[consumer]
|
||||
# from partitions that had a different consumer before,
|
||||
# keep only those that are assigned to this consumer now
|
||||
previous_partitions = set(six.iterkeys(self.previous_assignment)).intersection(set(remaining_partitions))
|
||||
if previous_partitions:
|
||||
# if there is a partition of this consumer that was assigned to another consumer before
|
||||
# mark it as good options for reassignment
|
||||
partition = previous_partitions.pop()
|
||||
remaining_partitions.remove(partition)
|
||||
self.sorted_partitions.append(partition)
|
||||
sorted_consumers.add((consumer, tuple(assignments[consumer])))
|
||||
elif remaining_partitions:
|
||||
# otherwise, mark any other one of the current partitions as a reassignment candidate
|
||||
self.sorted_partitions.append(remaining_partitions.pop())
|
||||
sorted_consumers.add((consumer, tuple(assignments[consumer])))
|
||||
|
||||
while partitions_sorted_by_num_of_potential_consumers:
|
||||
partition = partitions_sorted_by_num_of_potential_consumers.pop(0)[0]
|
||||
if partition not in self.sorted_partitions:
|
||||
self.sorted_partitions.append(partition)
|
||||
else:
|
||||
while partitions_sorted_by_num_of_potential_consumers:
|
||||
self.sorted_partitions.append(partitions_sorted_by_num_of_potential_consumers.pop(0)[0])
|
||||
|
||||
def _populate_partitions_to_reassign(self):
|
||||
self.unassigned_partitions = deepcopy(self.sorted_partitions)
|
||||
|
||||
assignments_to_remove = []
|
||||
for consumer_id, partitions in six.iteritems(self.current_assignment):
|
||||
if consumer_id not in self.members:
|
||||
# if a consumer that existed before (and had some partition assignments) is now removed,
|
||||
# remove it from current_assignment
|
||||
for partition in partitions:
|
||||
del self.current_partition_consumer[partition]
|
||||
assignments_to_remove.append(consumer_id)
|
||||
else:
|
||||
# otherwise (the consumer still exists)
|
||||
partitions_to_remove = []
|
||||
for partition in partitions:
|
||||
if partition not in self.partition_to_all_potential_consumers:
|
||||
# if this topic partition of this consumer no longer exists
|
||||
# remove it from current_assignment of the consumer
|
||||
partitions_to_remove.append(partition)
|
||||
elif partition.topic not in self.members[consumer_id].subscription:
|
||||
# if this partition cannot remain assigned to its current consumer because the consumer
|
||||
# is no longer subscribed to its topic remove it from current_assignment of the consumer
|
||||
partitions_to_remove.append(partition)
|
||||
self.revocation_required = True
|
||||
else:
|
||||
# otherwise, remove the topic partition from those that need to be assigned only if
|
||||
# its current consumer is still subscribed to its topic (because it is already assigned
|
||||
# and we would want to preserve that assignment as much as possible)
|
||||
self.unassigned_partitions.remove(partition)
|
||||
for partition in partitions_to_remove:
|
||||
self.current_assignment[consumer_id].remove(partition)
|
||||
del self.current_partition_consumer[partition]
|
||||
for consumer_id in assignments_to_remove:
|
||||
del self.current_assignment[consumer_id]
|
||||
|
||||
def _initialize_current_subscriptions(self):
|
||||
self.sorted_current_subscriptions = SortedSet(
|
||||
iterable=[(consumer, tuple(partitions)) for consumer, partitions in six.iteritems(self.current_assignment)],
|
||||
key=subscriptions_comparator_key,
|
||||
)
|
||||
|
||||
def _get_consumer_with_least_subscriptions(self):
|
||||
return self.sorted_current_subscriptions.first()[0]
|
||||
|
||||
def _get_consumer_with_most_subscriptions(self):
|
||||
return self.sorted_current_subscriptions.last()[0]
|
||||
|
||||
def _remove_consumer_from_current_subscriptions_and_maintain_order(self, consumer):
|
||||
self.sorted_current_subscriptions.remove((consumer, tuple(self.current_assignment[consumer])))
|
||||
|
||||
def _add_consumer_to_current_subscriptions_and_maintain_order(self, consumer):
|
||||
self.sorted_current_subscriptions.add((consumer, tuple(self.current_assignment[consumer])))
|
||||
|
||||
def _is_balanced(self):
|
||||
"""Determines if the current assignment is a balanced one"""
|
||||
if (
|
||||
len(self.current_assignment[self._get_consumer_with_least_subscriptions()])
|
||||
>= len(self.current_assignment[self._get_consumer_with_most_subscriptions()]) - 1
|
||||
):
|
||||
# if minimum and maximum numbers of partitions assigned to consumers differ by at most one return true
|
||||
return True
|
||||
|
||||
# create a mapping from partitions to the consumer assigned to them
|
||||
all_assigned_partitions = {}
|
||||
for consumer_id, consumer_partitions in six.iteritems(self.current_assignment):
|
||||
for partition in consumer_partitions:
|
||||
if partition in all_assigned_partitions:
|
||||
log.error("{} is assigned to more than one consumer.".format(partition))
|
||||
all_assigned_partitions[partition] = consumer_id
|
||||
|
||||
# for each consumer that does not have all the topic partitions it can get
|
||||
# make sure none of the topic partitions it could but did not get cannot be moved to it
|
||||
# (because that would break the balance)
|
||||
for consumer, _ in self.sorted_current_subscriptions:
|
||||
consumer_partition_count = len(self.current_assignment[consumer])
|
||||
# skip if this consumer already has all the topic partitions it can get
|
||||
if consumer_partition_count == len(self.consumer_to_all_potential_partitions[consumer]):
|
||||
continue
|
||||
|
||||
# otherwise make sure it cannot get any more
|
||||
for partition in self.consumer_to_all_potential_partitions[consumer]:
|
||||
if partition not in self.current_assignment[consumer]:
|
||||
other_consumer = all_assigned_partitions[partition]
|
||||
other_consumer_partition_count = len(self.current_assignment[other_consumer])
|
||||
if consumer_partition_count < other_consumer_partition_count:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _assign_partition(self, partition):
|
||||
for consumer, _ in self.sorted_current_subscriptions:
|
||||
if partition in self.consumer_to_all_potential_partitions[consumer]:
|
||||
self._remove_consumer_from_current_subscriptions_and_maintain_order(consumer)
|
||||
self.current_assignment[consumer].append(partition)
|
||||
self.current_partition_consumer[partition] = consumer
|
||||
self._add_consumer_to_current_subscriptions_and_maintain_order(consumer)
|
||||
break
|
||||
|
||||
def _can_partition_participate_in_reassignment(self, partition):
|
||||
return len(self.partition_to_all_potential_consumers[partition]) >= 2
|
||||
|
||||
def _can_consumer_participate_in_reassignment(self, consumer):
|
||||
current_partitions = self.current_assignment[consumer]
|
||||
current_assignment_size = len(current_partitions)
|
||||
max_assignment_size = len(self.consumer_to_all_potential_partitions[consumer])
|
||||
if current_assignment_size > max_assignment_size:
|
||||
log.error("The consumer {} is assigned more partitions than the maximum possible.".format(consumer))
|
||||
if current_assignment_size < max_assignment_size:
|
||||
# if a consumer is not assigned all its potential partitions it is subject to reassignment
|
||||
return True
|
||||
for partition in current_partitions:
|
||||
# if any of the partitions assigned to a consumer is subject to reassignment the consumer itself
|
||||
# is subject to reassignment
|
||||
if self._can_partition_participate_in_reassignment(partition):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _perform_reassignments(self, reassignable_partitions):
|
||||
reassignment_performed = False
|
||||
|
||||
# repeat reassignment until no partition can be moved to improve the balance
|
||||
while True:
|
||||
modified = False
|
||||
# reassign all reassignable partitions until the full list is processed or a balance is achieved
|
||||
# (starting from the partition with least potential consumers and if needed)
|
||||
for partition in reassignable_partitions:
|
||||
if self._is_balanced():
|
||||
break
|
||||
# the partition must have at least two potential consumers
|
||||
if len(self.partition_to_all_potential_consumers[partition]) <= 1:
|
||||
log.error("Expected more than one potential consumer for partition {}".format(partition))
|
||||
# the partition must have a current consumer
|
||||
consumer = self.current_partition_consumer.get(partition)
|
||||
if consumer is None:
|
||||
log.error("Expected partition {} to be assigned to a consumer".format(partition))
|
||||
|
||||
if (
|
||||
partition in self.previous_assignment
|
||||
and len(self.current_assignment[consumer])
|
||||
> len(self.current_assignment[self.previous_assignment[partition].consumer]) + 1
|
||||
):
|
||||
self._reassign_partition_to_consumer(
|
||||
partition, self.previous_assignment[partition].consumer,
|
||||
)
|
||||
reassignment_performed = True
|
||||
modified = True
|
||||
continue
|
||||
|
||||
# check if a better-suited consumer exist for the partition; if so, reassign it
|
||||
for other_consumer in self.partition_to_all_potential_consumers[partition]:
|
||||
if len(self.current_assignment[consumer]) > len(self.current_assignment[other_consumer]) + 1:
|
||||
self._reassign_partition(partition)
|
||||
reassignment_performed = True
|
||||
modified = True
|
||||
break
|
||||
|
||||
if not modified:
|
||||
break
|
||||
return reassignment_performed
|
||||
|
||||
def _reassign_partition(self, partition):
|
||||
new_consumer = None
|
||||
for another_consumer, _ in self.sorted_current_subscriptions:
|
||||
if partition in self.consumer_to_all_potential_partitions[another_consumer]:
|
||||
new_consumer = another_consumer
|
||||
break
|
||||
assert new_consumer is not None
|
||||
self._reassign_partition_to_consumer(partition, new_consumer)
|
||||
|
||||
def _reassign_partition_to_consumer(self, partition, new_consumer):
|
||||
consumer = self.current_partition_consumer[partition]
|
||||
# find the correct partition movement considering the stickiness requirement
|
||||
partition_to_be_moved = self.partition_movements.get_partition_to_be_moved(partition, consumer, new_consumer)
|
||||
self._move_partition(partition_to_be_moved, new_consumer)
|
||||
|
||||
def _move_partition(self, partition, new_consumer):
|
||||
old_consumer = self.current_partition_consumer[partition]
|
||||
self._remove_consumer_from_current_subscriptions_and_maintain_order(old_consumer)
|
||||
self._remove_consumer_from_current_subscriptions_and_maintain_order(new_consumer)
|
||||
|
||||
self.partition_movements.move_partition(partition, old_consumer, new_consumer)
|
||||
|
||||
self.current_assignment[old_consumer].remove(partition)
|
||||
self.current_assignment[new_consumer].append(partition)
|
||||
self.current_partition_consumer[partition] = new_consumer
|
||||
|
||||
self._add_consumer_to_current_subscriptions_and_maintain_order(new_consumer)
|
||||
self._add_consumer_to_current_subscriptions_and_maintain_order(old_consumer)
|
||||
|
||||
@staticmethod
|
||||
def _get_balance_score(assignment):
|
||||
"""Calculates a balance score of a give assignment
|
||||
as the sum of assigned partitions size difference of all consumer pairs.
|
||||
A perfectly balanced assignment (with all consumers getting the same number of partitions)
|
||||
has a balance score of 0. Lower balance score indicates a more balanced assignment.
|
||||
|
||||
Arguments:
|
||||
assignment (dict): {consumer: list of assigned topic partitions}
|
||||
|
||||
Returns:
|
||||
the balance score of the assignment
|
||||
"""
|
||||
score = 0
|
||||
consumer_to_assignment = {}
|
||||
for consumer_id, partitions in six.iteritems(assignment):
|
||||
consumer_to_assignment[consumer_id] = len(partitions)
|
||||
|
||||
consumers_to_explore = set(consumer_to_assignment.keys())
|
||||
for consumer_id in consumer_to_assignment.keys():
|
||||
if consumer_id in consumers_to_explore:
|
||||
consumers_to_explore.remove(consumer_id)
|
||||
for other_consumer_id in consumers_to_explore:
|
||||
score += abs(consumer_to_assignment[consumer_id] - consumer_to_assignment[other_consumer_id])
|
||||
return score
|
||||
|
||||
|
||||
class StickyPartitionAssignor(AbstractPartitionAssignor):
|
||||
"""
|
||||
https://cwiki.apache.org/confluence/display/KAFKA/KIP-54+-+Sticky+Partition+Assignment+Strategy
|
||||
|
||||
The sticky assignor serves two purposes. First, it guarantees an assignment that is as balanced as possible, meaning either:
|
||||
- the numbers of topic partitions assigned to consumers differ by at most one; or
|
||||
- each consumer that has 2+ fewer topic partitions than some other consumer cannot get any of those topic partitions transferred to it.
|
||||
|
||||
Second, it preserved as many existing assignment as possible when a reassignment occurs.
|
||||
This helps in saving some of the overhead processing when topic partitions move from one consumer to another.
|
||||
|
||||
Starting fresh it would work by distributing the partitions over consumers as evenly as possible.
|
||||
Even though this may sound similar to how round robin assignor works, the second example below shows that it is not.
|
||||
During a reassignment it would perform the reassignment in such a way that in the new assignment
|
||||
- topic partitions are still distributed as evenly as possible, and
|
||||
- topic partitions stay with their previously assigned consumers as much as possible.
|
||||
|
||||
The first goal above takes precedence over the second one.
|
||||
|
||||
Example 1.
|
||||
Suppose there are three consumers C0, C1, C2,
|
||||
four topics t0, t1, t2, t3, and each topic has 2 partitions,
|
||||
resulting in partitions t0p0, t0p1, t1p0, t1p1, t2p0, t2p1, t3p0, t3p1.
|
||||
Each consumer is subscribed to all three topics.
|
||||
|
||||
The assignment with both sticky and round robin assignors will be:
|
||||
- C0: [t0p0, t1p1, t3p0]
|
||||
- C1: [t0p1, t2p0, t3p1]
|
||||
- C2: [t1p0, t2p1]
|
||||
|
||||
Now, let's assume C1 is removed and a reassignment is about to happen. The round robin assignor would produce:
|
||||
- C0: [t0p0, t1p0, t2p0, t3p0]
|
||||
- C2: [t0p1, t1p1, t2p1, t3p1]
|
||||
|
||||
while the sticky assignor would result in:
|
||||
- C0 [t0p0, t1p1, t3p0, t2p0]
|
||||
- C2 [t1p0, t2p1, t0p1, t3p1]
|
||||
preserving all the previous assignments (unlike the round robin assignor).
|
||||
|
||||
|
||||
Example 2.
|
||||
There are three consumers C0, C1, C2,
|
||||
and three topics t0, t1, t2, with 1, 2, and 3 partitions respectively.
|
||||
Therefore, the partitions are t0p0, t1p0, t1p1, t2p0, t2p1, t2p2.
|
||||
C0 is subscribed to t0;
|
||||
C1 is subscribed to t0, t1;
|
||||
and C2 is subscribed to t0, t1, t2.
|
||||
|
||||
The round robin assignor would come up with the following assignment:
|
||||
- C0 [t0p0]
|
||||
- C1 [t1p0]
|
||||
- C2 [t1p1, t2p0, t2p1, t2p2]
|
||||
|
||||
which is not as balanced as the assignment suggested by sticky assignor:
|
||||
- C0 [t0p0]
|
||||
- C1 [t1p0, t1p1]
|
||||
- C2 [t2p0, t2p1, t2p2]
|
||||
|
||||
Now, if consumer C0 is removed, these two assignors would produce the following assignments.
|
||||
Round Robin (preserves 3 partition assignments):
|
||||
- C1 [t0p0, t1p1]
|
||||
- C2 [t1p0, t2p0, t2p1, t2p2]
|
||||
|
||||
Sticky (preserves 5 partition assignments):
|
||||
- C1 [t1p0, t1p1, t0p0]
|
||||
- C2 [t2p0, t2p1, t2p2]
|
||||
"""
|
||||
|
||||
DEFAULT_GENERATION_ID = -1
|
||||
|
||||
name = "sticky"
|
||||
version = 0
|
||||
|
||||
member_assignment = None
|
||||
generation = DEFAULT_GENERATION_ID
|
||||
|
||||
_latest_partition_movements = None
|
||||
|
||||
@classmethod
|
||||
def assign(cls, cluster, members):
|
||||
"""Performs group assignment given cluster metadata and member subscriptions
|
||||
|
||||
Arguments:
|
||||
cluster (ClusterMetadata): cluster metadata
|
||||
members (dict of {member_id: MemberMetadata}): decoded metadata for each member in the group.
|
||||
|
||||
Returns:
|
||||
dict: {member_id: MemberAssignment}
|
||||
"""
|
||||
members_metadata = {}
|
||||
for consumer, member_metadata in six.iteritems(members):
|
||||
members_metadata[consumer] = cls.parse_member_metadata(member_metadata)
|
||||
|
||||
executor = StickyAssignmentExecutor(cluster, members_metadata)
|
||||
executor.perform_initial_assignment()
|
||||
executor.balance()
|
||||
|
||||
cls._latest_partition_movements = executor.partition_movements
|
||||
|
||||
assignment = {}
|
||||
for member_id in members:
|
||||
assignment[member_id] = ConsumerProtocolMemberAssignment(
|
||||
cls.version, sorted(executor.get_final_assignment(member_id)), b''
|
||||
)
|
||||
return assignment
|
||||
|
||||
@classmethod
|
||||
def parse_member_metadata(cls, metadata):
|
||||
"""
|
||||
Parses member metadata into a python object.
|
||||
This implementation only serializes and deserializes the StickyAssignorMemberMetadataV1 user data,
|
||||
since no StickyAssignor written in Python was deployed ever in the wild with version V0, meaning that
|
||||
there is no need to support backward compatibility with V0.
|
||||
|
||||
Arguments:
|
||||
metadata (MemberMetadata): decoded metadata for a member of the group.
|
||||
|
||||
Returns:
|
||||
parsed metadata (StickyAssignorMemberMetadataV1)
|
||||
"""
|
||||
user_data = metadata.user_data
|
||||
if not user_data:
|
||||
return StickyAssignorMemberMetadataV1(
|
||||
partitions=[], generation=cls.DEFAULT_GENERATION_ID, subscription=metadata.subscription
|
||||
)
|
||||
|
||||
try:
|
||||
decoded_user_data = StickyAssignorUserDataV1.decode(user_data)
|
||||
except Exception as e:
|
||||
# ignore the consumer's previous assignment if it cannot be parsed
|
||||
log.error("Could not parse member data", e) # pylint: disable=logging-too-many-args
|
||||
return StickyAssignorMemberMetadataV1(
|
||||
partitions=[], generation=cls.DEFAULT_GENERATION_ID, subscription=metadata.subscription
|
||||
)
|
||||
|
||||
member_partitions = []
|
||||
for topic, partitions in decoded_user_data.previous_assignment: # pylint: disable=no-member
|
||||
member_partitions.extend([TopicPartition(topic, partition) for partition in partitions])
|
||||
return StickyAssignorMemberMetadataV1(
|
||||
# pylint: disable=no-member
|
||||
partitions=member_partitions, generation=decoded_user_data.generation, subscription=metadata.subscription
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def metadata(cls, topics):
|
||||
return cls._metadata(topics, cls.member_assignment, cls.generation)
|
||||
|
||||
@classmethod
|
||||
def _metadata(cls, topics, member_assignment_partitions, generation=-1):
|
||||
if member_assignment_partitions is None:
|
||||
log.debug("No member assignment available")
|
||||
user_data = b''
|
||||
else:
|
||||
log.debug("Member assignment is available, generating the metadata: generation {}".format(cls.generation))
|
||||
partitions_by_topic = defaultdict(list)
|
||||
for topic_partition in member_assignment_partitions:
|
||||
partitions_by_topic[topic_partition.topic].append(topic_partition.partition)
|
||||
data = StickyAssignorUserDataV1(list(partitions_by_topic.items()), generation)
|
||||
user_data = data.encode()
|
||||
return ConsumerProtocolMemberMetadata(cls.version, list(topics), user_data)
|
||||
|
||||
@classmethod
|
||||
def on_assignment(cls, assignment):
|
||||
"""Callback that runs on each assignment. Updates assignor's state.
|
||||
|
||||
Arguments:
|
||||
assignment: MemberAssignment
|
||||
"""
|
||||
log.debug("On assignment: assignment={}".format(assignment))
|
||||
cls.member_assignment = assignment.partitions()
|
||||
|
||||
@classmethod
|
||||
def on_generation_assignment(cls, generation):
|
||||
"""Callback that runs on each assignment. Updates assignor's generation id.
|
||||
|
||||
Arguments:
|
||||
generation: generation id
|
||||
"""
|
||||
log.debug("On generation assignment: generation={}".format(generation))
|
||||
cls.generation = generation
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,968 @@
|
||||
from __future__ import absolute_import, division
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
from kafka.coordinator.base import BaseCoordinator, Generation
|
||||
from kafka.coordinator.assignors.range import RangePartitionAssignor
|
||||
from kafka.coordinator.assignors.roundrobin import RoundRobinPartitionAssignor
|
||||
from kafka.coordinator.assignors.sticky.sticky_assignor import StickyPartitionAssignor
|
||||
from kafka.coordinator.protocol import ConsumerProtocol
|
||||
import kafka.errors as Errors
|
||||
from kafka.future import Future
|
||||
from kafka.metrics import AnonMeasurable
|
||||
from kafka.metrics.stats import Avg, Count, Max, Rate
|
||||
from kafka.protocol.commit import OffsetCommitRequest, OffsetFetchRequest
|
||||
from kafka.structs import OffsetAndMetadata, TopicPartition
|
||||
from kafka.util import Timer, WeakMethod
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsumerCoordinator(BaseCoordinator):
|
||||
"""This class manages the coordination process with the consumer coordinator."""
|
||||
DEFAULT_CONFIG = {
|
||||
'group_id': 'kafka-python-default-group',
|
||||
'enable_auto_commit': True,
|
||||
'auto_commit_interval_ms': 5000,
|
||||
'default_offset_commit_callback': None,
|
||||
'assignors': (RangePartitionAssignor, RoundRobinPartitionAssignor, StickyPartitionAssignor),
|
||||
'session_timeout_ms': 10000,
|
||||
'heartbeat_interval_ms': 3000,
|
||||
'max_poll_interval_ms': 300000,
|
||||
'retry_backoff_ms': 100,
|
||||
'api_version': (0, 10, 1),
|
||||
'exclude_internal_topics': True,
|
||||
'metrics': None,
|
||||
'metric_group_prefix': 'consumer'
|
||||
}
|
||||
|
||||
def __init__(self, client, subscription, **configs):
|
||||
"""Initialize the coordination manager.
|
||||
|
||||
Keyword Arguments:
|
||||
group_id (str): name of the consumer group to join for dynamic
|
||||
partition assignment (if enabled), and to use for fetching and
|
||||
committing offsets. Default: 'kafka-python-default-group'
|
||||
enable_auto_commit (bool): If true the consumer's offset will be
|
||||
periodically committed in the background. Default: True.
|
||||
auto_commit_interval_ms (int): milliseconds between automatic
|
||||
offset commits, if enable_auto_commit is True. Default: 5000.
|
||||
default_offset_commit_callback (callable): called as
|
||||
callback(offsets, response) response will be either an Exception
|
||||
or None. This callback can be used to trigger custom actions when
|
||||
a commit request completes.
|
||||
assignors (list): List of objects to use to distribute partition
|
||||
ownership amongst consumer instances when group management is
|
||||
used. Default: [RangePartitionAssignor, RoundRobinPartitionAssignor]
|
||||
heartbeat_interval_ms (int): The expected time in milliseconds
|
||||
between heartbeats to the consumer coordinator when using
|
||||
Kafka's group management feature. Heartbeats are used to ensure
|
||||
that the consumer's session stays active and to facilitate
|
||||
rebalancing when new consumers join or leave the group. The
|
||||
value must be set lower than session_timeout_ms, but typically
|
||||
should be set no higher than 1/3 of that value. It can be
|
||||
adjusted even lower to control the expected time for normal
|
||||
rebalances. Default: 3000
|
||||
session_timeout_ms (int): The timeout used to detect failures when
|
||||
using Kafka's group management facilities. Default: 30000
|
||||
retry_backoff_ms (int): Milliseconds to backoff when retrying on
|
||||
errors. Default: 100.
|
||||
exclude_internal_topics (bool): Whether records from internal topics
|
||||
(such as offsets) should be exposed to the consumer. If set to
|
||||
True the only way to receive records from an internal topic is
|
||||
subscribing to it. Requires 0.10+. Default: True
|
||||
"""
|
||||
super(ConsumerCoordinator, self).__init__(client, **configs)
|
||||
|
||||
self.config = copy.copy(self.DEFAULT_CONFIG)
|
||||
for key in self.config:
|
||||
if key in configs:
|
||||
self.config[key] = configs[key]
|
||||
|
||||
self._subscription = subscription
|
||||
self._is_leader = False
|
||||
self._joined_subscription = set()
|
||||
self._metadata_snapshot = self._build_metadata_snapshot(subscription, client.cluster)
|
||||
self._assignment_snapshot = None
|
||||
self._cluster = client.cluster
|
||||
self.auto_commit_interval = self.config['auto_commit_interval_ms'] / 1000
|
||||
self.next_auto_commit_deadline = None
|
||||
self.completed_offset_commits = collections.deque()
|
||||
self._offset_fetch_futures = dict()
|
||||
|
||||
if self.config['default_offset_commit_callback'] is None:
|
||||
self.config['default_offset_commit_callback'] = self._default_offset_commit_callback
|
||||
|
||||
if self.config['group_id'] is not None:
|
||||
if self.config['api_version'] >= (0, 9):
|
||||
if not self.config['assignors']:
|
||||
raise Errors.KafkaConfigurationError('Coordinator requires assignors')
|
||||
if self.config['api_version'] < (0, 10, 1):
|
||||
if self.config['max_poll_interval_ms'] != self.config['session_timeout_ms']:
|
||||
raise Errors.KafkaConfigurationError("Broker version %s does not support "
|
||||
"different values for max_poll_interval_ms "
|
||||
"and session_timeout_ms")
|
||||
|
||||
if self.config['enable_auto_commit']:
|
||||
if self.config['api_version'] < (0, 8, 1):
|
||||
log.warning('Broker version (%s) does not support offset'
|
||||
' commits; disabling auto-commit.',
|
||||
self.config['api_version'])
|
||||
self.config['enable_auto_commit'] = False
|
||||
elif self.config['group_id'] is None:
|
||||
log.warning('group_id is None: disabling auto-commit.')
|
||||
self.config['enable_auto_commit'] = False
|
||||
else:
|
||||
self.next_auto_commit_deadline = time.time() + self.auto_commit_interval
|
||||
|
||||
if self.config['metrics']:
|
||||
self._consumer_sensors = ConsumerCoordinatorMetrics(
|
||||
self.config['metrics'], self.config['metric_group_prefix'], self._subscription)
|
||||
else:
|
||||
self._consumer_sensors = None
|
||||
|
||||
self._cluster.request_update()
|
||||
self._cluster.add_listener(WeakMethod(self._handle_metadata_update))
|
||||
|
||||
def __del__(self):
|
||||
if hasattr(self, '_cluster') and self._cluster:
|
||||
try:
|
||||
self._cluster.remove_listener(WeakMethod(self._handle_metadata_update))
|
||||
except TypeError:
|
||||
pass
|
||||
super(ConsumerCoordinator, self).__del__()
|
||||
|
||||
def protocol_type(self):
|
||||
return ConsumerProtocol.PROTOCOL_TYPE
|
||||
|
||||
def group_protocols(self):
|
||||
"""Returns list of preferred (protocols, metadata)"""
|
||||
if self._subscription.subscription is None:
|
||||
raise Errors.IllegalStateError('Consumer has not subscribed to topics')
|
||||
# dpkp note: I really dislike this.
|
||||
# why? because we are using this strange method group_protocols,
|
||||
# which is seemingly innocuous, to set internal state (_joined_subscription)
|
||||
# that is later used to check whether metadata has changed since we joined a group
|
||||
# but there is no guarantee that this method, group_protocols, will get called
|
||||
# in the correct sequence or that it will only be called when we want it to be.
|
||||
# So this really should be moved elsewhere, but I don't have the energy to
|
||||
# work that out right now. If you read this at some later date after the mutable
|
||||
# state has bitten you... I'm sorry! It mimics the java client, and that's the
|
||||
# best I've got for now.
|
||||
self._joined_subscription = set(self._subscription.subscription)
|
||||
metadata_list = []
|
||||
for assignor in self.config['assignors']:
|
||||
metadata = assignor.metadata(self._joined_subscription)
|
||||
group_protocol = (assignor.name, metadata)
|
||||
metadata_list.append(group_protocol)
|
||||
return metadata_list
|
||||
|
||||
def _handle_metadata_update(self, cluster):
|
||||
# if we encounter any unauthorized topics, raise an exception
|
||||
if cluster.unauthorized_topics:
|
||||
raise Errors.TopicAuthorizationFailedError(cluster.unauthorized_topics)
|
||||
|
||||
if self._subscription.subscribed_pattern:
|
||||
topics = []
|
||||
for topic in cluster.topics(self.config['exclude_internal_topics']):
|
||||
if self._subscription.subscribed_pattern.match(topic):
|
||||
topics.append(topic)
|
||||
|
||||
if set(topics) != self._subscription.subscription:
|
||||
self._subscription.change_subscription(topics)
|
||||
self._client.set_topics(self._subscription.group_subscription())
|
||||
|
||||
# check if there are any changes to the metadata which should trigger
|
||||
# a rebalance
|
||||
if self._subscription.partitions_auto_assigned():
|
||||
metadata_snapshot = self._build_metadata_snapshot(self._subscription, cluster)
|
||||
if self._metadata_snapshot != metadata_snapshot:
|
||||
self._metadata_snapshot = metadata_snapshot
|
||||
|
||||
# If we haven't got group coordinator support,
|
||||
# just assign all partitions locally
|
||||
if self._auto_assign_all_partitions():
|
||||
self._subscription.assign_from_subscribed([
|
||||
TopicPartition(topic, partition)
|
||||
for topic in self._subscription.subscription
|
||||
for partition in self._metadata_snapshot[topic]
|
||||
])
|
||||
|
||||
def _auto_assign_all_partitions(self):
|
||||
# For users that use "subscribe" without group support,
|
||||
# we will simply assign all partitions to this consumer
|
||||
if self.config['api_version'] < (0, 9):
|
||||
return True
|
||||
elif self.config['group_id'] is None:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def _build_metadata_snapshot(self, subscription, cluster):
|
||||
metadata_snapshot = {}
|
||||
for topic in subscription.group_subscription():
|
||||
partitions = cluster.partitions_for_topic(topic)
|
||||
metadata_snapshot[topic] = partitions or set()
|
||||
return metadata_snapshot
|
||||
|
||||
def _lookup_assignor(self, name):
|
||||
for assignor in self.config['assignors']:
|
||||
if assignor.name == name:
|
||||
return assignor
|
||||
return None
|
||||
|
||||
def _on_join_complete(self, generation, member_id, protocol,
|
||||
member_assignment_bytes):
|
||||
# only the leader is responsible for monitoring for metadata changes
|
||||
# (i.e. partition changes)
|
||||
if not self._is_leader:
|
||||
self._assignment_snapshot = None
|
||||
|
||||
assignor = self._lookup_assignor(protocol)
|
||||
assert assignor, 'Coordinator selected invalid assignment protocol: %s' % (protocol,)
|
||||
|
||||
assignment = ConsumerProtocol.ASSIGNMENT.decode(member_assignment_bytes)
|
||||
|
||||
try:
|
||||
self._subscription.assign_from_subscribed(assignment.partitions())
|
||||
except ValueError as e:
|
||||
log.warning("%s. Probably due to a deleted topic. Requesting Re-join" % e)
|
||||
self.request_rejoin()
|
||||
|
||||
# give the assignor a chance to update internal state
|
||||
# based on the received assignment
|
||||
assignor.on_assignment(assignment)
|
||||
if assignor.name == 'sticky':
|
||||
assignor.on_generation_assignment(generation)
|
||||
|
||||
# reschedule the auto commit starting from now
|
||||
self.next_auto_commit_deadline = time.time() + self.auto_commit_interval
|
||||
|
||||
assigned = set(self._subscription.assigned_partitions())
|
||||
log.info("Setting newly assigned partitions %s for group %s",
|
||||
assigned, self.group_id)
|
||||
|
||||
# execute the user's callback after rebalance
|
||||
if self._subscription.rebalance_listener:
|
||||
try:
|
||||
self._subscription.rebalance_listener.on_partitions_assigned(assigned)
|
||||
except Exception:
|
||||
log.exception("User provided rebalance listener %s for group %s"
|
||||
" failed on partition assignment: %s",
|
||||
self._subscription.rebalance_listener, self.group_id,
|
||||
assigned)
|
||||
|
||||
def poll(self, timeout_ms=None):
|
||||
"""
|
||||
Poll for coordinator events. Only applicable if group_id is set, and
|
||||
broker version supports GroupCoordinators. This ensures that the
|
||||
coordinator is known, and if using automatic partition assignment,
|
||||
ensures that the consumer has joined the group. This also handles
|
||||
periodic offset commits if they are enabled.
|
||||
"""
|
||||
if self.group_id is None:
|
||||
return True
|
||||
|
||||
timer = Timer(timeout_ms)
|
||||
try:
|
||||
self._invoke_completed_offset_commit_callbacks()
|
||||
if not self.ensure_coordinator_ready(timeout_ms=timer.timeout_ms):
|
||||
log.debug('coordinator.poll: timeout in ensure_coordinator_ready; returning early')
|
||||
return False
|
||||
|
||||
if self.config['api_version'] >= (0, 9) and self._subscription.partitions_auto_assigned():
|
||||
if self.need_rejoin():
|
||||
# due to a race condition between the initial metadata fetch and the
|
||||
# initial rebalance, we need to ensure that the metadata is fresh
|
||||
# before joining initially, and then request the metadata update. If
|
||||
# metadata update arrives while the rebalance is still pending (for
|
||||
# example, when the join group is still inflight), then we will lose
|
||||
# track of the fact that we need to rebalance again to reflect the
|
||||
# change to the topic subscription. Without ensuring that the
|
||||
# metadata is fresh, any metadata update that changes the topic
|
||||
# subscriptions and arrives while a rebalance is in progress will
|
||||
# essentially be ignored. See KAFKA-3949 for the complete
|
||||
# description of the problem.
|
||||
if self._subscription.subscribed_pattern:
|
||||
metadata_update = self._client.cluster.request_update()
|
||||
self._client.poll(future=metadata_update, timeout_ms=timer.timeout_ms)
|
||||
if not metadata_update.is_done:
|
||||
log.debug('coordinator.poll: timeout updating metadata; returning early')
|
||||
return False
|
||||
|
||||
if not self.ensure_active_group(timeout_ms=timer.timeout_ms):
|
||||
log.debug('coordinator.poll: timeout in ensure_active_group; returning early')
|
||||
return False
|
||||
|
||||
self.poll_heartbeat()
|
||||
|
||||
self._maybe_auto_commit_offsets_async()
|
||||
return True
|
||||
|
||||
except Errors.KafkaTimeoutError:
|
||||
return False
|
||||
|
||||
def time_to_next_poll(self):
|
||||
"""Return seconds (float) remaining until :meth:`.poll` should be called again"""
|
||||
if not self.config['enable_auto_commit']:
|
||||
return self.time_to_next_heartbeat()
|
||||
|
||||
if time.time() > self.next_auto_commit_deadline:
|
||||
return 0
|
||||
|
||||
return min(self.next_auto_commit_deadline - time.time(),
|
||||
self.time_to_next_heartbeat())
|
||||
|
||||
def _perform_assignment(self, leader_id, assignment_strategy, members):
|
||||
assignor = self._lookup_assignor(assignment_strategy)
|
||||
assert assignor, 'Invalid assignment protocol: %s' % (assignment_strategy,)
|
||||
member_metadata = {}
|
||||
all_subscribed_topics = set()
|
||||
for member_id, metadata_bytes in members:
|
||||
metadata = ConsumerProtocol.METADATA.decode(metadata_bytes)
|
||||
member_metadata[member_id] = metadata
|
||||
all_subscribed_topics.update(metadata.subscription) # pylint: disable-msg=no-member
|
||||
|
||||
# the leader will begin watching for changes to any of the topics
|
||||
# the group is interested in, which ensures that all metadata changes
|
||||
# will eventually be seen
|
||||
# Because assignment typically happens within response callbacks,
|
||||
# we cannot block on metadata updates here (no recursion into poll())
|
||||
self._subscription.group_subscribe(all_subscribed_topics)
|
||||
self._client.set_topics(self._subscription.group_subscription())
|
||||
|
||||
# keep track of the metadata used for assignment so that we can check
|
||||
# after rebalance completion whether anything has changed
|
||||
self._cluster.request_update()
|
||||
self._is_leader = True
|
||||
self._assignment_snapshot = self._metadata_snapshot
|
||||
|
||||
log.debug("Performing assignment for group %s using strategy %s"
|
||||
" with subscriptions %s", self.group_id, assignor.name,
|
||||
member_metadata)
|
||||
|
||||
assignments = assignor.assign(self._cluster, member_metadata)
|
||||
|
||||
log.debug("Finished assignment for group %s: %s", self.group_id, assignments)
|
||||
|
||||
group_assignment = {}
|
||||
for member_id, assignment in six.iteritems(assignments):
|
||||
group_assignment[member_id] = assignment
|
||||
return group_assignment
|
||||
|
||||
def _on_join_prepare(self, generation, member_id, timeout_ms=None):
|
||||
# commit offsets prior to rebalance if auto-commit enabled
|
||||
self._maybe_auto_commit_offsets_sync(timeout_ms=timeout_ms)
|
||||
|
||||
# execute the user's callback before rebalance
|
||||
log.info("Revoking previously assigned partitions %s for group %s",
|
||||
self._subscription.assigned_partitions(), self.group_id)
|
||||
if self._subscription.rebalance_listener:
|
||||
try:
|
||||
revoked = set(self._subscription.assigned_partitions())
|
||||
self._subscription.rebalance_listener.on_partitions_revoked(revoked)
|
||||
except Exception:
|
||||
log.exception("User provided subscription rebalance listener %s"
|
||||
" for group %s failed on_partitions_revoked",
|
||||
self._subscription.rebalance_listener, self.group_id)
|
||||
|
||||
self._is_leader = False
|
||||
self._subscription.reset_group_subscription()
|
||||
|
||||
def need_rejoin(self):
|
||||
"""Check whether the group should be rejoined
|
||||
|
||||
Returns:
|
||||
bool: True if consumer should rejoin group, False otherwise
|
||||
"""
|
||||
if not self._subscription.partitions_auto_assigned():
|
||||
return False
|
||||
|
||||
if self._auto_assign_all_partitions():
|
||||
return False
|
||||
|
||||
# we need to rejoin if we performed the assignment and metadata has changed
|
||||
if (self._assignment_snapshot is not None
|
||||
and self._assignment_snapshot != self._metadata_snapshot):
|
||||
return True
|
||||
|
||||
# we need to join if our subscription has changed since the last join
|
||||
if (self._joined_subscription is not None
|
||||
and self._joined_subscription != self._subscription.subscription):
|
||||
return True
|
||||
|
||||
return super(ConsumerCoordinator, self).need_rejoin()
|
||||
|
||||
def refresh_committed_offsets_if_needed(self, timeout_ms=None):
|
||||
"""Fetch committed offsets for assigned partitions."""
|
||||
missing_fetch_positions = set(self._subscription.missing_fetch_positions())
|
||||
try:
|
||||
offsets = self.fetch_committed_offsets(missing_fetch_positions, timeout_ms=timeout_ms)
|
||||
except Errors.KafkaTimeoutError:
|
||||
return False
|
||||
for partition, offset in six.iteritems(offsets):
|
||||
log.debug("Setting offset for partition %s to the committed offset %s", partition, offset.offset)
|
||||
self._subscription.seek(partition, offset.offset)
|
||||
return True
|
||||
|
||||
def fetch_committed_offsets(self, partitions, timeout_ms=None):
|
||||
"""Fetch the current committed offsets for specified partitions
|
||||
|
||||
Arguments:
|
||||
partitions (list of TopicPartition): partitions to fetch
|
||||
|
||||
Returns:
|
||||
dict: {TopicPartition: OffsetAndMetadata}
|
||||
|
||||
Raises:
|
||||
KafkaTimeoutError if timeout_ms provided
|
||||
"""
|
||||
if not partitions:
|
||||
return {}
|
||||
|
||||
future_key = frozenset(partitions)
|
||||
timer = Timer(timeout_ms)
|
||||
while True:
|
||||
if not self.ensure_coordinator_ready(timeout_ms=timer.timeout_ms):
|
||||
timer.maybe_raise()
|
||||
|
||||
# contact coordinator to fetch committed offsets
|
||||
if future_key in self._offset_fetch_futures:
|
||||
future = self._offset_fetch_futures[future_key]
|
||||
else:
|
||||
future = self._send_offset_fetch_request(partitions)
|
||||
self._offset_fetch_futures[future_key] = future
|
||||
|
||||
self._client.poll(future=future, timeout_ms=timer.timeout_ms)
|
||||
|
||||
if future.is_done:
|
||||
del self._offset_fetch_futures[future_key]
|
||||
|
||||
if future.succeeded():
|
||||
return future.value
|
||||
|
||||
elif not future.retriable():
|
||||
raise future.exception # pylint: disable-msg=raising-bad-type
|
||||
|
||||
# future failed but is retriable, or is not done yet
|
||||
if timer.timeout_ms is None or timer.timeout_ms > self.config['retry_backoff_ms']:
|
||||
time.sleep(self.config['retry_backoff_ms'] / 1000)
|
||||
else:
|
||||
time.sleep(timer.timeout_ms / 1000)
|
||||
timer.maybe_raise()
|
||||
|
||||
def close(self, autocommit=True, timeout_ms=None):
|
||||
"""Close the coordinator, leave the current group,
|
||||
and reset local generation / member_id.
|
||||
|
||||
Keyword Arguments:
|
||||
autocommit (bool): If auto-commit is configured for this consumer,
|
||||
this optional flag causes the consumer to attempt to commit any
|
||||
pending consumed offsets prior to close. Default: True
|
||||
"""
|
||||
try:
|
||||
if autocommit:
|
||||
self._maybe_auto_commit_offsets_sync(timeout_ms=timeout_ms)
|
||||
finally:
|
||||
super(ConsumerCoordinator, self).close(timeout_ms=timeout_ms)
|
||||
|
||||
def _invoke_completed_offset_commit_callbacks(self):
|
||||
while self.completed_offset_commits:
|
||||
callback, offsets, res_or_exc = self.completed_offset_commits.popleft()
|
||||
callback(offsets, res_or_exc)
|
||||
|
||||
def commit_offsets_async(self, offsets, callback=None):
|
||||
"""Commit specific offsets asynchronously.
|
||||
|
||||
Arguments:
|
||||
offsets (dict {TopicPartition: OffsetAndMetadata}): what to commit
|
||||
callback (callable, optional): called as callback(offsets, response)
|
||||
response will be either an Exception or a OffsetCommitResponse
|
||||
struct. This callback can be used to trigger custom actions when
|
||||
a commit request completes.
|
||||
|
||||
Returns:
|
||||
kafka.future.Future
|
||||
"""
|
||||
self._invoke_completed_offset_commit_callbacks()
|
||||
if not self.coordinator_unknown():
|
||||
future = self._do_commit_offsets_async(offsets, callback)
|
||||
else:
|
||||
# we don't know the current coordinator, so try to find it and then
|
||||
# send the commit or fail (we don't want recursive retries which can
|
||||
# cause offset commits to arrive out of order). Note that there may
|
||||
# be multiple offset commits chained to the same coordinator lookup
|
||||
# request. This is fine because the listeners will be invoked in the
|
||||
# same order that they were added. Note also that BaseCoordinator
|
||||
# prevents multiple concurrent coordinator lookup requests.
|
||||
future = self.lookup_coordinator()
|
||||
future.add_callback(lambda r: functools.partial(self._do_commit_offsets_async, offsets, callback)())
|
||||
if callback:
|
||||
future.add_errback(lambda e: self.completed_offset_commits.appendleft((callback, offsets, e)))
|
||||
|
||||
# ensure the commit has a chance to be transmitted (without blocking on
|
||||
# its completion). Note that commits are treated as heartbeats by the
|
||||
# coordinator, so there is no need to explicitly allow heartbeats
|
||||
# through delayed task execution.
|
||||
self._client.poll(timeout_ms=0) # no wakeup if we add that feature
|
||||
|
||||
return future
|
||||
|
||||
def _do_commit_offsets_async(self, offsets, callback=None):
|
||||
if self.config['api_version'] < (0, 8, 1):
|
||||
raise Errors.UnsupportedVersionError('OffsetCommitRequest requires 0.8.1+ broker')
|
||||
assert all(map(lambda k: isinstance(k, TopicPartition), offsets))
|
||||
assert all(map(lambda v: isinstance(v, OffsetAndMetadata),
|
||||
offsets.values()))
|
||||
if callback is None:
|
||||
callback = self.config['default_offset_commit_callback']
|
||||
future = self._send_offset_commit_request(offsets)
|
||||
future.add_both(lambda res: self.completed_offset_commits.appendleft((callback, offsets, res)))
|
||||
return future
|
||||
|
||||
def commit_offsets_sync(self, offsets, timeout_ms=None):
|
||||
"""Commit specific offsets synchronously.
|
||||
|
||||
This method will retry until the commit completes successfully or an
|
||||
unrecoverable error is encountered.
|
||||
|
||||
Arguments:
|
||||
offsets (dict {TopicPartition: OffsetAndMetadata}): what to commit
|
||||
|
||||
Raises error on failure
|
||||
"""
|
||||
if self.config['api_version'] < (0, 8, 1):
|
||||
raise Errors.UnsupportedVersionError('OffsetCommitRequest requires 0.8.1+ broker')
|
||||
assert all(map(lambda k: isinstance(k, TopicPartition), offsets))
|
||||
assert all(map(lambda v: isinstance(v, OffsetAndMetadata),
|
||||
offsets.values()))
|
||||
self._invoke_completed_offset_commit_callbacks()
|
||||
if not offsets:
|
||||
return
|
||||
|
||||
timer = Timer(timeout_ms)
|
||||
while True:
|
||||
self.ensure_coordinator_ready(timeout_ms=timer.timeout_ms)
|
||||
|
||||
future = self._send_offset_commit_request(offsets)
|
||||
self._client.poll(future=future, timeout_ms=timer.timeout_ms)
|
||||
|
||||
if future.is_done:
|
||||
if future.succeeded():
|
||||
return future.value
|
||||
|
||||
elif not future.retriable():
|
||||
raise future.exception # pylint: disable-msg=raising-bad-type
|
||||
|
||||
# future failed but is retriable, or it is still pending
|
||||
if timer.timeout_ms is None or timer.timeout_ms > self.config['retry_backoff_ms']:
|
||||
time.sleep(self.config['retry_backoff_ms'] / 1000)
|
||||
else:
|
||||
time.sleep(timer.timeout_ms / 1000)
|
||||
timer.maybe_raise()
|
||||
|
||||
def _maybe_auto_commit_offsets_sync(self, timeout_ms=None):
|
||||
if self.config['enable_auto_commit']:
|
||||
try:
|
||||
self.commit_offsets_sync(self._subscription.all_consumed_offsets(), timeout_ms=timeout_ms)
|
||||
|
||||
# The three main group membership errors are known and should not
|
||||
# require a stacktrace -- just a warning
|
||||
except (Errors.UnknownMemberIdError,
|
||||
Errors.IllegalGenerationError,
|
||||
Errors.RebalanceInProgressError):
|
||||
log.warning("Offset commit failed: group membership out of date"
|
||||
" This is likely to cause duplicate message"
|
||||
" delivery.")
|
||||
except Exception:
|
||||
log.exception("Offset commit failed: This is likely to cause"
|
||||
" duplicate message delivery")
|
||||
|
||||
def _send_offset_commit_request(self, offsets):
|
||||
"""Commit offsets for the specified list of topics and partitions.
|
||||
|
||||
This is a non-blocking call which returns a request future that can be
|
||||
polled in the case of a synchronous commit or ignored in the
|
||||
asynchronous case.
|
||||
|
||||
Arguments:
|
||||
offsets (dict of {TopicPartition: OffsetAndMetadata}): what should
|
||||
be committed
|
||||
|
||||
Returns:
|
||||
Future: indicating whether the commit was successful or not
|
||||
"""
|
||||
if self.config['api_version'] < (0, 8, 1):
|
||||
raise Errors.UnsupportedVersionError('OffsetCommitRequest requires 0.8.1+ broker')
|
||||
assert all(map(lambda k: isinstance(k, TopicPartition), offsets))
|
||||
assert all(map(lambda v: isinstance(v, OffsetAndMetadata),
|
||||
offsets.values()))
|
||||
if not offsets:
|
||||
log.debug('No offsets to commit')
|
||||
return Future().success(None)
|
||||
|
||||
node_id = self.coordinator()
|
||||
if node_id is None:
|
||||
return Future().failure(Errors.CoordinatorNotAvailableError)
|
||||
|
||||
# Verify node is ready
|
||||
if not self._client.ready(node_id, metadata_priority=False):
|
||||
log.debug("Node %s not ready -- failing offset commit request",
|
||||
node_id)
|
||||
return Future().failure(Errors.NodeNotReadyError)
|
||||
|
||||
# create the offset commit request
|
||||
offset_data = collections.defaultdict(dict)
|
||||
for tp, offset in six.iteritems(offsets):
|
||||
offset_data[tp.topic][tp.partition] = offset
|
||||
|
||||
version = self._client.api_version(OffsetCommitRequest, max_version=6)
|
||||
if version > 1 and self._subscription.partitions_auto_assigned():
|
||||
generation = self.generation_if_stable()
|
||||
else:
|
||||
generation = Generation.NO_GENERATION
|
||||
|
||||
# if the generation is None, we are not part of an active group
|
||||
# (and we expect to be). The only thing we can do is fail the commit
|
||||
# and let the user rejoin the group in poll()
|
||||
if generation is None:
|
||||
log.info("Failing OffsetCommit request since the consumer is not part of an active group")
|
||||
if self.rebalance_in_progress():
|
||||
# if the client knows it is already rebalancing, we can use RebalanceInProgressError instead of
|
||||
# CommitFailedError to indicate this is not a fatal error
|
||||
return Future().failure(Errors.RebalanceInProgressError(
|
||||
"Offset commit cannot be completed since the"
|
||||
" consumer is undergoing a rebalance for auto partition assignment. You can try completing the rebalance"
|
||||
" by calling poll() and then retry the operation."))
|
||||
else:
|
||||
return Future().failure(Errors.CommitFailedError(
|
||||
"Offset commit cannot be completed since the"
|
||||
" consumer is not part of an active group for auto partition assignment; it is likely that the consumer"
|
||||
" was kicked out of the group."))
|
||||
|
||||
if version == 0:
|
||||
request = OffsetCommitRequest[version](
|
||||
self.group_id,
|
||||
[(
|
||||
topic, [(
|
||||
partition,
|
||||
offset.offset,
|
||||
offset.metadata
|
||||
) for partition, offset in six.iteritems(partitions)]
|
||||
) for topic, partitions in six.iteritems(offset_data)]
|
||||
)
|
||||
elif version == 1:
|
||||
request = OffsetCommitRequest[version](
|
||||
self.group_id,
|
||||
# This api version was only used in v0.8.2, prior to join group apis
|
||||
# so this always ends up as NO_GENERATION
|
||||
generation.generation_id,
|
||||
generation.member_id,
|
||||
[(
|
||||
topic, [(
|
||||
partition,
|
||||
offset.offset,
|
||||
-1, # timestamp, unused
|
||||
offset.metadata
|
||||
) for partition, offset in six.iteritems(partitions)]
|
||||
) for topic, partitions in six.iteritems(offset_data)]
|
||||
)
|
||||
elif version <= 4:
|
||||
request = OffsetCommitRequest[version](
|
||||
self.group_id,
|
||||
generation.generation_id,
|
||||
generation.member_id,
|
||||
OffsetCommitRequest[version].DEFAULT_RETENTION_TIME,
|
||||
[(
|
||||
topic, [(
|
||||
partition,
|
||||
offset.offset,
|
||||
offset.metadata
|
||||
) for partition, offset in six.iteritems(partitions)]
|
||||
) for topic, partitions in six.iteritems(offset_data)]
|
||||
)
|
||||
elif version <= 5:
|
||||
request = OffsetCommitRequest[version](
|
||||
self.group_id,
|
||||
generation.generation_id,
|
||||
generation.member_id,
|
||||
[(
|
||||
topic, [(
|
||||
partition,
|
||||
offset.offset,
|
||||
offset.metadata
|
||||
) for partition, offset in six.iteritems(partitions)]
|
||||
) for topic, partitions in six.iteritems(offset_data)]
|
||||
)
|
||||
else:
|
||||
request = OffsetCommitRequest[version](
|
||||
self.group_id,
|
||||
generation.generation_id,
|
||||
generation.member_id,
|
||||
[(
|
||||
topic, [(
|
||||
partition,
|
||||
offset.offset,
|
||||
offset.leader_epoch,
|
||||
offset.metadata
|
||||
) for partition, offset in six.iteritems(partitions)]
|
||||
) for topic, partitions in six.iteritems(offset_data)]
|
||||
)
|
||||
|
||||
log.debug("Sending offset-commit request with %s for group %s to %s",
|
||||
offsets, self.group_id, node_id)
|
||||
|
||||
future = Future()
|
||||
_f = self._client.send(node_id, request)
|
||||
_f.add_callback(self._handle_offset_commit_response, offsets, future, time.time())
|
||||
_f.add_errback(self._failed_request, node_id, request, future)
|
||||
return future
|
||||
|
||||
def _handle_offset_commit_response(self, offsets, future, send_time, response):
|
||||
log.debug("Received OffsetCommitResponse: %s", response)
|
||||
# TODO look at adding request_latency_ms to response (like java kafka)
|
||||
if self._consumer_sensors:
|
||||
self._consumer_sensors.commit_latency.record((time.time() - send_time) * 1000)
|
||||
unauthorized_topics = set()
|
||||
|
||||
for topic, partitions in response.topics:
|
||||
for partition, error_code in partitions:
|
||||
tp = TopicPartition(topic, partition)
|
||||
offset = offsets[tp]
|
||||
|
||||
error_type = Errors.for_code(error_code)
|
||||
if error_type is Errors.NoError:
|
||||
log.debug("Group %s committed offset %s for partition %s",
|
||||
self.group_id, offset, tp)
|
||||
elif error_type is Errors.GroupAuthorizationFailedError:
|
||||
log.error("Not authorized to commit offsets for group %s",
|
||||
self.group_id)
|
||||
future.failure(error_type(self.group_id))
|
||||
return
|
||||
elif error_type is Errors.TopicAuthorizationFailedError:
|
||||
unauthorized_topics.add(topic)
|
||||
elif error_type in (Errors.OffsetMetadataTooLargeError,
|
||||
Errors.InvalidCommitOffsetSizeError):
|
||||
# raise the error to the user
|
||||
log.debug("OffsetCommit for group %s failed on partition %s"
|
||||
" %s", self.group_id, tp, error_type.__name__)
|
||||
future.failure(error_type())
|
||||
return
|
||||
elif error_type is Errors.CoordinatorLoadInProgressError:
|
||||
# just retry
|
||||
log.debug("OffsetCommit for group %s failed: %s",
|
||||
self.group_id, error_type.__name__)
|
||||
future.failure(error_type(self.group_id))
|
||||
return
|
||||
elif error_type in (Errors.CoordinatorNotAvailableError,
|
||||
Errors.NotCoordinatorError,
|
||||
Errors.RequestTimedOutError):
|
||||
log.debug("OffsetCommit for group %s failed: %s",
|
||||
self.group_id, error_type.__name__)
|
||||
self.coordinator_dead(error_type())
|
||||
future.failure(error_type(self.group_id))
|
||||
return
|
||||
elif error_type is Errors.RebalanceInProgressError:
|
||||
# Consumer never tries to commit offset in between join-group and sync-group,
|
||||
# and hence on broker-side it is not expected to see a commit offset request
|
||||
# during CompletingRebalance phase; if it ever happens then broker would return
|
||||
# this error. In this case we should just treat as a fatal CommitFailed exception.
|
||||
# However, we do not need to reset generations and just request re-join, such that
|
||||
# if the caller decides to proceed and poll, it would still try to proceed and re-join normally.
|
||||
self.request_rejoin()
|
||||
future.failure(Errors.CommitFailedError(error_type()))
|
||||
return
|
||||
elif error_type in (Errors.UnknownMemberIdError,
|
||||
Errors.IllegalGenerationError):
|
||||
# need reset generation and re-join group
|
||||
error = error_type(self.group_id)
|
||||
log.warning("OffsetCommit for group %s failed: %s",
|
||||
self.group_id, error)
|
||||
self.reset_generation()
|
||||
future.failure(Errors.CommitFailedError(error_type()))
|
||||
return
|
||||
else:
|
||||
log.error("Group %s failed to commit partition %s at offset"
|
||||
" %s: %s", self.group_id, tp, offset,
|
||||
error_type.__name__)
|
||||
future.failure(error_type())
|
||||
return
|
||||
|
||||
if unauthorized_topics:
|
||||
log.error("Not authorized to commit to topics %s for group %s",
|
||||
unauthorized_topics, self.group_id)
|
||||
future.failure(Errors.TopicAuthorizationFailedError(unauthorized_topics))
|
||||
else:
|
||||
future.success(None)
|
||||
|
||||
def _send_offset_fetch_request(self, partitions):
|
||||
"""Fetch the committed offsets for a set of partitions.
|
||||
|
||||
This is a non-blocking call. The returned future can be polled to get
|
||||
the actual offsets returned from the broker.
|
||||
|
||||
Arguments:
|
||||
partitions (list of TopicPartition): the partitions to fetch
|
||||
|
||||
Returns:
|
||||
Future: resolves to dict of offsets: {TopicPartition: OffsetAndMetadata}
|
||||
"""
|
||||
if self.config['api_version'] < (0, 8, 1):
|
||||
raise Errors.UnsupportedVersionError('OffsetFetchRequest requires 0.8.1+ broker')
|
||||
assert all(map(lambda k: isinstance(k, TopicPartition), partitions))
|
||||
if not partitions:
|
||||
return Future().success({})
|
||||
|
||||
node_id = self.coordinator()
|
||||
if node_id is None:
|
||||
return Future().failure(Errors.CoordinatorNotAvailableError)
|
||||
|
||||
# Verify node is ready
|
||||
if not self._client.ready(node_id, metadata_priority=False):
|
||||
log.debug("Node %s not ready -- failing offset fetch request",
|
||||
node_id)
|
||||
return Future().failure(Errors.NodeNotReadyError)
|
||||
|
||||
log.debug("Group %s fetching committed offsets for partitions: %s",
|
||||
self.group_id, partitions)
|
||||
# construct the request
|
||||
topic_partitions = collections.defaultdict(set)
|
||||
for tp in partitions:
|
||||
topic_partitions[tp.topic].add(tp.partition)
|
||||
|
||||
version = self._client.api_version(OffsetFetchRequest, max_version=5)
|
||||
# Starting in version 2, the request can contain a null topics array to indicate that offsets should be fetched
|
||||
# TODO: support
|
||||
request = OffsetFetchRequest[version](
|
||||
self.group_id,
|
||||
list(topic_partitions.items())
|
||||
)
|
||||
|
||||
# send the request with a callback
|
||||
future = Future()
|
||||
_f = self._client.send(node_id, request)
|
||||
_f.add_callback(self._handle_offset_fetch_response, future)
|
||||
_f.add_errback(self._failed_request, node_id, request, future)
|
||||
return future
|
||||
|
||||
def _handle_offset_fetch_response(self, future, response):
|
||||
log.debug("Received OffsetFetchResponse: %s", response)
|
||||
if response.API_VERSION >= 2 and response.error_code != Errors.NoError.errno:
|
||||
error_type = Errors.for_code(response.error_code)
|
||||
log.debug("Offset fetch failed: %s", error_type.__name__)
|
||||
error = error_type()
|
||||
if error_type is Errors.CoordinatorLoadInProgressError:
|
||||
# Retry
|
||||
future.failure(error)
|
||||
elif error_type is Errors.NotCoordinatorError:
|
||||
# re-discover the coordinator and retry
|
||||
self.coordinator_dead(error)
|
||||
future.failure(error)
|
||||
elif error_type is Errors.GroupAuthorizationFailedError:
|
||||
future.failure(error)
|
||||
else:
|
||||
log.error("Unknown error fetching offsets: %s", error)
|
||||
future.failure(error)
|
||||
return
|
||||
|
||||
offsets = {}
|
||||
for topic, partitions in response.topics:
|
||||
for partition_data in partitions:
|
||||
partition, offset = partition_data[:2]
|
||||
if response.API_VERSION >= 5:
|
||||
leader_epoch, metadata, error_code = partition_data[2:]
|
||||
else:
|
||||
metadata, error_code = partition_data[2:]
|
||||
leader_epoch = -1 # noqa: F841
|
||||
tp = TopicPartition(topic, partition)
|
||||
error_type = Errors.for_code(error_code)
|
||||
if error_type is not Errors.NoError:
|
||||
error = error_type()
|
||||
log.debug("Group %s failed to fetch offset for partition"
|
||||
" %s: %s", self.group_id, tp, error)
|
||||
if error_type is Errors.CoordinatorLoadInProgressError:
|
||||
# just retry
|
||||
future.failure(error)
|
||||
elif error_type is Errors.NotCoordinatorError:
|
||||
# re-discover the coordinator and retry
|
||||
self.coordinator_dead(error)
|
||||
future.failure(error)
|
||||
elif error_type is Errors.UnknownTopicOrPartitionError:
|
||||
log.warning("OffsetFetchRequest -- unknown topic %s"
|
||||
" (have you committed any offsets yet?)",
|
||||
topic)
|
||||
continue
|
||||
else:
|
||||
log.error("Unknown error fetching offsets for %s: %s",
|
||||
tp, error)
|
||||
future.failure(error)
|
||||
return
|
||||
elif offset >= 0:
|
||||
# record the position with the offset
|
||||
# (-1 indicates no committed offset to fetch)
|
||||
# TODO: save leader_epoch
|
||||
offsets[tp] = OffsetAndMetadata(offset, metadata, -1)
|
||||
else:
|
||||
log.debug("Group %s has no committed offset for partition"
|
||||
" %s", self.group_id, tp)
|
||||
future.success(offsets)
|
||||
|
||||
def _default_offset_commit_callback(self, offsets, res_or_exc):
|
||||
if isinstance(res_or_exc, Exception):
|
||||
log.warning("Auto offset commit failed for group %s: %s",
|
||||
self.group_id, res_or_exc)
|
||||
else:
|
||||
log.debug("Completed autocommit of offsets %s for group %s",
|
||||
offsets, self.group_id)
|
||||
|
||||
def _commit_offsets_async_on_complete(self, offsets, res_or_exc):
|
||||
if isinstance(res_or_exc, Exception) and getattr(res_or_exc, 'retriable', False):
|
||||
self.next_auto_commit_deadline = min(time.time() + self.config['retry_backoff_ms'] / 1000, self.next_auto_commit_deadline)
|
||||
self.config['default_offset_commit_callback'](offsets, res_or_exc)
|
||||
|
||||
def _maybe_auto_commit_offsets_async(self):
|
||||
if self.config['enable_auto_commit']:
|
||||
if self.coordinator_unknown():
|
||||
self.next_auto_commit_deadline = time.time() + self.config['retry_backoff_ms'] / 1000
|
||||
elif time.time() > self.next_auto_commit_deadline:
|
||||
self.next_auto_commit_deadline = time.time() + self.auto_commit_interval
|
||||
self._do_auto_commit_offsets_async()
|
||||
|
||||
def maybe_auto_commit_offsets_now(self):
|
||||
if self.config['enable_auto_commit'] and not self.coordinator_unknown():
|
||||
self._do_auto_commit_offsets_async()
|
||||
|
||||
def _do_auto_commit_offsets_async(self):
|
||||
self.commit_offsets_async(self._subscription.all_consumed_offsets(),
|
||||
self._commit_offsets_async_on_complete)
|
||||
|
||||
|
||||
class ConsumerCoordinatorMetrics(object):
|
||||
def __init__(self, metrics, metric_group_prefix, subscription):
|
||||
self.metrics = metrics
|
||||
self.metric_group_name = '%s-coordinator-metrics' % (metric_group_prefix,)
|
||||
|
||||
self.commit_latency = metrics.sensor('commit-latency')
|
||||
self.commit_latency.add(metrics.metric_name(
|
||||
'commit-latency-avg', self.metric_group_name,
|
||||
'The average time taken for a commit request'), Avg())
|
||||
self.commit_latency.add(metrics.metric_name(
|
||||
'commit-latency-max', self.metric_group_name,
|
||||
'The max time taken for a commit request'), Max())
|
||||
self.commit_latency.add(metrics.metric_name(
|
||||
'commit-rate', self.metric_group_name,
|
||||
'The number of commit calls per second'), Rate(sampled_stat=Count()))
|
||||
|
||||
num_parts = AnonMeasurable(lambda config, now:
|
||||
len(subscription.assigned_partitions()))
|
||||
metrics.add_metric(metrics.metric_name(
|
||||
'assigned-partitions', self.metric_group_name,
|
||||
'The number of partitions currently assigned to this consumer'),
|
||||
num_parts)
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import absolute_import, division
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
|
||||
from kafka.errors import KafkaConfigurationError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Heartbeat(object):
|
||||
DEFAULT_CONFIG = {
|
||||
'group_id': None,
|
||||
'heartbeat_interval_ms': 3000,
|
||||
'session_timeout_ms': 10000,
|
||||
'max_poll_interval_ms': 300000,
|
||||
'retry_backoff_ms': 100,
|
||||
}
|
||||
|
||||
def __init__(self, **configs):
|
||||
self.config = copy.copy(self.DEFAULT_CONFIG)
|
||||
for key in self.config:
|
||||
if key in configs:
|
||||
self.config[key] = configs[key]
|
||||
|
||||
if self.config['group_id'] is not None:
|
||||
if self.config['heartbeat_interval_ms'] >= self.config['session_timeout_ms']:
|
||||
raise KafkaConfigurationError('Heartbeat interval must be lower than the session timeout (%s v %s)' % (
|
||||
self.config['heartbeat_interval_ms'], self.config['session_timeout_ms']))
|
||||
if self.config['heartbeat_interval_ms'] > (self.config['session_timeout_ms'] / 3):
|
||||
log.warning('heartbeat_interval_ms is high relative to session_timeout_ms (%s v %s).'
|
||||
' Recommend heartbeat interval less than 1/3rd of session timeout',
|
||||
self.config['heartbeat_interval_ms'], self.config['session_timeout_ms'])
|
||||
|
||||
self.last_send = -1 * float('inf')
|
||||
self.last_receive = -1 * float('inf')
|
||||
self.last_poll = -1 * float('inf')
|
||||
self.last_reset = time.time()
|
||||
self.heartbeat_failed = None
|
||||
|
||||
def poll(self):
|
||||
self.last_poll = time.time()
|
||||
|
||||
def sent_heartbeat(self):
|
||||
self.last_send = time.time()
|
||||
self.heartbeat_failed = False
|
||||
|
||||
def fail_heartbeat(self):
|
||||
self.heartbeat_failed = True
|
||||
|
||||
def received_heartbeat(self):
|
||||
self.last_receive = time.time()
|
||||
|
||||
def time_to_next_heartbeat(self):
|
||||
"""Returns seconds (float) remaining before next heartbeat should be sent"""
|
||||
time_since_last_heartbeat = time.time() - max(self.last_send, self.last_reset)
|
||||
if self.heartbeat_failed:
|
||||
delay_to_next_heartbeat = self.config['retry_backoff_ms'] / 1000
|
||||
else:
|
||||
delay_to_next_heartbeat = self.config['heartbeat_interval_ms'] / 1000
|
||||
return max(0, delay_to_next_heartbeat - time_since_last_heartbeat)
|
||||
|
||||
def should_heartbeat(self):
|
||||
return self.time_to_next_heartbeat() == 0
|
||||
|
||||
def session_timeout_expired(self):
|
||||
last_recv = max(self.last_receive, self.last_reset)
|
||||
return (time.time() - last_recv) > (self.config['session_timeout_ms'] / 1000)
|
||||
|
||||
def reset_timeouts(self):
|
||||
self.last_reset = time.time()
|
||||
self.last_poll = time.time()
|
||||
self.heartbeat_failed = False
|
||||
|
||||
def poll_timeout_expired(self):
|
||||
return (time.time() - self.last_poll) > (self.config['max_poll_interval_ms'] / 1000)
|
||||
|
||||
def __str__(self):
|
||||
return ("<Heartbeat group_id={group_id}"
|
||||
" heartbeat_interval_ms={heartbeat_interval_ms}"
|
||||
" session_timeout_ms={session_timeout_ms}"
|
||||
" max_poll_interval_ms={max_poll_interval_ms}"
|
||||
" retry_backoff_ms={retry_backoff_ms}>").format(**self.config)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.struct import Struct
|
||||
from kafka.protocol.types import Array, Bytes, Int16, Int32, Schema, String
|
||||
from kafka.structs import TopicPartition
|
||||
|
||||
|
||||
class ConsumerProtocolMemberMetadata(Struct):
|
||||
SCHEMA = Schema(
|
||||
('version', Int16),
|
||||
('subscription', Array(String('utf-8'))),
|
||||
('user_data', Bytes))
|
||||
|
||||
|
||||
class ConsumerProtocolMemberAssignment(Struct):
|
||||
SCHEMA = Schema(
|
||||
('version', Int16),
|
||||
('assignment', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(Int32)))),
|
||||
('user_data', Bytes))
|
||||
|
||||
def partitions(self):
|
||||
return [TopicPartition(topic, partition)
|
||||
for topic, partitions in self.assignment # pylint: disable-msg=no-member
|
||||
for partition in partitions]
|
||||
|
||||
|
||||
class ConsumerProtocol(object):
|
||||
PROTOCOL_TYPE = 'consumer'
|
||||
ASSIGNMENT_STRATEGIES = ('range', 'roundrobin')
|
||||
METADATA = ConsumerProtocolMemberMetadata
|
||||
ASSIGNMENT = ConsumerProtocolMemberAssignment
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import threading
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Future(object):
|
||||
error_on_callbacks = False # and errbacks
|
||||
|
||||
def __init__(self):
|
||||
self.is_done = False
|
||||
self.value = None
|
||||
self.exception = None
|
||||
self._callbacks = []
|
||||
self._errbacks = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def succeeded(self):
|
||||
return self.is_done and not bool(self.exception)
|
||||
|
||||
def failed(self):
|
||||
return self.is_done and bool(self.exception)
|
||||
|
||||
def retriable(self):
|
||||
try:
|
||||
return self.exception.retriable
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
def success(self, value):
|
||||
assert not self.is_done, 'Future is already complete'
|
||||
with self._lock:
|
||||
self.value = value
|
||||
self.is_done = True
|
||||
if self._callbacks:
|
||||
self._call_backs('callback', self._callbacks, self.value)
|
||||
return self
|
||||
|
||||
def failure(self, e):
|
||||
assert not self.is_done, 'Future is already complete'
|
||||
exception = e if type(e) is not type else e()
|
||||
assert isinstance(exception, BaseException), (
|
||||
'future failed without an exception')
|
||||
with self._lock:
|
||||
self.exception = exception
|
||||
self.is_done = True
|
||||
self._call_backs('errback', self._errbacks, self.exception)
|
||||
return self
|
||||
|
||||
def add_callback(self, f, *args, **kwargs):
|
||||
if args or kwargs:
|
||||
f = functools.partial(f, *args, **kwargs)
|
||||
with self._lock:
|
||||
if not self.is_done:
|
||||
self._callbacks.append(f)
|
||||
elif self.succeeded():
|
||||
self._lock.release()
|
||||
self._call_backs('callback', [f], self.value)
|
||||
self._lock.acquire()
|
||||
return self
|
||||
|
||||
def add_errback(self, f, *args, **kwargs):
|
||||
if args or kwargs:
|
||||
f = functools.partial(f, *args, **kwargs)
|
||||
with self._lock:
|
||||
if not self.is_done:
|
||||
self._errbacks.append(f)
|
||||
elif self.failed():
|
||||
self._lock.release()
|
||||
self._call_backs('errback', [f], self.exception)
|
||||
self._lock.acquire()
|
||||
return self
|
||||
|
||||
def add_both(self, f, *args, **kwargs):
|
||||
self.add_callback(f, *args, **kwargs)
|
||||
self.add_errback(f, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def chain(self, future):
|
||||
self.add_callback(future.success)
|
||||
self.add_errback(future.failure)
|
||||
return self
|
||||
|
||||
def _call_backs(self, back_type, backs, value):
|
||||
for f in backs:
|
||||
try:
|
||||
f(value)
|
||||
except Exception as e:
|
||||
log.exception('Error processing %s', back_type)
|
||||
if self.error_on_callbacks:
|
||||
raise e
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics.compound_stat import NamedMeasurable
|
||||
from kafka.metrics.dict_reporter import DictReporter
|
||||
from kafka.metrics.kafka_metric import KafkaMetric
|
||||
from kafka.metrics.measurable import AnonMeasurable
|
||||
from kafka.metrics.metric_config import MetricConfig
|
||||
from kafka.metrics.metric_name import MetricName
|
||||
from kafka.metrics.metrics import Metrics
|
||||
from kafka.metrics.quota import Quota
|
||||
|
||||
__all__ = [
|
||||
'AnonMeasurable', 'DictReporter', 'KafkaMetric', 'MetricConfig',
|
||||
'MetricName', 'Metrics', 'NamedMeasurable', 'Quota'
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.metrics.stat import AbstractStat
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class AbstractCompoundStat(AbstractStat):
|
||||
"""
|
||||
A compound stat is a stat where a single measurement and associated
|
||||
data structure feeds many metrics. This is the example for a
|
||||
histogram which has many associated percentiles.
|
||||
"""
|
||||
def stats(self):
|
||||
"""
|
||||
Return list of NamedMeasurable
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NamedMeasurable(object):
|
||||
__slots__ = ('_name', '_stat')
|
||||
|
||||
def __init__(self, metric_name, measurable_stat):
|
||||
self._name = metric_name
|
||||
self._stat = measurable_stat
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def stat(self):
|
||||
return self._stat
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from kafka.metrics.metrics_reporter import AbstractMetricsReporter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DictReporter(AbstractMetricsReporter):
|
||||
"""A basic dictionary based metrics reporter.
|
||||
|
||||
Store all metrics in a two level dictionary of category > name > metric.
|
||||
"""
|
||||
def __init__(self, prefix=''):
|
||||
self._lock = threading.Lock()
|
||||
self._prefix = prefix if prefix else '' # never allow None
|
||||
self._store = {}
|
||||
|
||||
def snapshot(self):
|
||||
"""
|
||||
Return a nested dictionary snapshot of all metrics and their
|
||||
values at this time. Example:
|
||||
{
|
||||
'category': {
|
||||
'metric1_name': 42.0,
|
||||
'metric2_name': 'foo'
|
||||
}
|
||||
}
|
||||
"""
|
||||
return dict((category, dict((name, metric.value())
|
||||
for name, metric in list(metrics.items())))
|
||||
for category, metrics in
|
||||
list(self._store.items()))
|
||||
|
||||
def init(self, metrics):
|
||||
for metric in metrics:
|
||||
self.metric_change(metric)
|
||||
|
||||
def metric_change(self, metric):
|
||||
with self._lock:
|
||||
category = self.get_category(metric)
|
||||
if category not in self._store:
|
||||
self._store[category] = {}
|
||||
self._store[category][metric.metric_name.name] = metric
|
||||
|
||||
def metric_removal(self, metric):
|
||||
with self._lock:
|
||||
category = self.get_category(metric)
|
||||
metrics = self._store.get(category, {})
|
||||
removed = metrics.pop(metric.metric_name.name, None)
|
||||
if not metrics:
|
||||
self._store.pop(category, None)
|
||||
return removed
|
||||
|
||||
def get_category(self, metric):
|
||||
"""
|
||||
Return a string category for the metric.
|
||||
|
||||
The category is made up of this reporter's prefix and the
|
||||
metric's group and tags.
|
||||
|
||||
Examples:
|
||||
prefix = 'foo', group = 'bar', tags = {'a': 1, 'b': 2}
|
||||
returns: 'foo.bar.a=1,b=2'
|
||||
|
||||
prefix = 'foo', group = 'bar', tags = None
|
||||
returns: 'foo.bar'
|
||||
|
||||
prefix = None, group = 'bar', tags = None
|
||||
returns: 'bar'
|
||||
"""
|
||||
tags = ','.join('%s=%s' % (k, v) for k, v in
|
||||
sorted(metric.metric_name.tags.items()))
|
||||
return '.'.join(x for x in
|
||||
[self._prefix, metric.metric_name.group, tags] if x)
|
||||
|
||||
def configure(self, configs):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class KafkaMetric(object):
|
||||
__slots__ = ('_metric_name', '_measurable', '_config')
|
||||
|
||||
# NOTE java constructor takes a lock instance
|
||||
def __init__(self, metric_name, measurable, config):
|
||||
if not metric_name:
|
||||
raise ValueError('metric_name must be non-empty')
|
||||
if not measurable:
|
||||
raise ValueError('measurable must be non-empty')
|
||||
self._metric_name = metric_name
|
||||
self._measurable = measurable
|
||||
self._config = config
|
||||
|
||||
@property
|
||||
def metric_name(self):
|
||||
return self._metric_name
|
||||
|
||||
@property
|
||||
def measurable(self):
|
||||
return self._measurable
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self._config
|
||||
|
||||
@config.setter
|
||||
def config(self, config):
|
||||
self._config = config
|
||||
|
||||
def value(self, time_ms=None):
|
||||
if time_ms is None:
|
||||
time_ms = time.time() * 1000
|
||||
return self._measurable.measure(self._config, time_ms)
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
|
||||
class AbstractMeasurable(object):
|
||||
"""A measurable quantity that can be registered as a metric"""
|
||||
@abc.abstractmethod
|
||||
def measure(self, config, now):
|
||||
"""
|
||||
Measure this quantity and return the result
|
||||
|
||||
Arguments:
|
||||
config (MetricConfig): The configuration for this metric
|
||||
now (int): The POSIX time in milliseconds the measurement
|
||||
is being taken
|
||||
|
||||
Returns:
|
||||
The measured value
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AnonMeasurable(AbstractMeasurable):
|
||||
def __init__(self, measure_fn):
|
||||
self._measure_fn = measure_fn
|
||||
|
||||
def measure(self, config, now):
|
||||
return float(self._measure_fn(config, now))
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.metrics.measurable import AbstractMeasurable
|
||||
from kafka.metrics.stat import AbstractStat
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class AbstractMeasurableStat(AbstractStat, AbstractMeasurable):
|
||||
"""
|
||||
An AbstractMeasurableStat is an AbstractStat that is also
|
||||
an AbstractMeasurable (i.e. can produce a single floating point value).
|
||||
This is the interface used for most of the simple statistics such
|
||||
as Avg, Max, Count, etc.
|
||||
"""
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
class MetricConfig(object):
|
||||
"""Configuration values for metrics"""
|
||||
__slots__ = ('quota', '_samples', 'event_window', 'time_window_ms', 'tags')
|
||||
|
||||
def __init__(self, quota=None, samples=2, event_window=sys.maxsize,
|
||||
time_window_ms=30 * 1000, tags=None):
|
||||
"""
|
||||
Arguments:
|
||||
quota (Quota, optional): Upper or lower bound of a value.
|
||||
samples (int, optional): Max number of samples kept per metric.
|
||||
event_window (int, optional): Max number of values per sample.
|
||||
time_window_ms (int, optional): Max age of an individual sample.
|
||||
tags (dict of {str: str}, optional): Tags for each metric.
|
||||
"""
|
||||
self.quota = quota
|
||||
self._samples = samples
|
||||
self.event_window = event_window
|
||||
self.time_window_ms = time_window_ms
|
||||
# tags should be OrderedDict (not supported in py26)
|
||||
self.tags = tags if tags else {}
|
||||
|
||||
@property
|
||||
def samples(self):
|
||||
return self._samples
|
||||
|
||||
@samples.setter
|
||||
def samples(self, value):
|
||||
if value < 1:
|
||||
raise ValueError('The number of samples must be at least 1.')
|
||||
self._samples = value
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import copy
|
||||
|
||||
|
||||
class MetricName(object):
|
||||
"""
|
||||
This class encapsulates a metric's name, logical group and its
|
||||
related attributes (tags).
|
||||
|
||||
group, tags parameters can be used to create unique metric names.
|
||||
e.g. domainName:type=group,key1=val1,key2=val2
|
||||
|
||||
Usage looks something like this:
|
||||
|
||||
# set up metrics:
|
||||
metric_tags = {'client-id': 'producer-1', 'topic': 'topic'}
|
||||
metric_config = MetricConfig(tags=metric_tags)
|
||||
|
||||
# metrics is the global repository of metrics and sensors
|
||||
metrics = Metrics(metric_config)
|
||||
|
||||
sensor = metrics.sensor('message-sizes')
|
||||
metric_name = metrics.metric_name('message-size-avg',
|
||||
'producer-metrics',
|
||||
'average message size')
|
||||
sensor.add(metric_name, Avg())
|
||||
|
||||
metric_name = metrics.metric_name('message-size-max',
|
||||
sensor.add(metric_name, Max())
|
||||
|
||||
tags = {'client-id': 'my-client', 'topic': 'my-topic'}
|
||||
metric_name = metrics.metric_name('message-size-min',
|
||||
'producer-metrics',
|
||||
'message minimum size', tags)
|
||||
sensor.add(metric_name, Min())
|
||||
|
||||
# as messages are sent we record the sizes
|
||||
sensor.record(message_size)
|
||||
"""
|
||||
__slots__ = ('_name', '_group', '_description', '_tags', '_hash')
|
||||
|
||||
def __init__(self, name, group, description=None, tags=None):
|
||||
"""
|
||||
Arguments:
|
||||
name (str): The name of the metric.
|
||||
group (str): The logical group name of the metrics to which this
|
||||
metric belongs.
|
||||
description (str, optional): A human-readable description to
|
||||
include in the metric.
|
||||
tags (dict, optional): Additional key/val attributes of the metric.
|
||||
"""
|
||||
if not (name and group):
|
||||
raise ValueError('name and group must be non-empty.')
|
||||
if tags is not None and not isinstance(tags, dict):
|
||||
raise ValueError('tags must be a dict if present.')
|
||||
|
||||
self._name = name
|
||||
self._group = group
|
||||
self._description = description
|
||||
self._tags = copy.copy(tags)
|
||||
self._hash = 0
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def group(self):
|
||||
return self._group
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
return copy.copy(self._tags)
|
||||
|
||||
def __hash__(self):
|
||||
if self._hash != 0:
|
||||
return self._hash
|
||||
prime = 31
|
||||
result = 1
|
||||
result = prime * result + hash(self.group)
|
||||
result = prime * result + hash(self.name)
|
||||
tags_hash = hash(frozenset(self.tags.items())) if self.tags else 0
|
||||
result = prime * result + tags_hash
|
||||
self._hash = result
|
||||
return result
|
||||
|
||||
def __eq__(self, other):
|
||||
if self is other:
|
||||
return True
|
||||
if other is None:
|
||||
return False
|
||||
return (isinstance(self, type(other)) and
|
||||
self.group == other.group and
|
||||
self.name == other.name and
|
||||
self.tags == other.tags)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
return 'MetricName(name=%s, group=%s, description=%s, tags=%s)' % (
|
||||
self.name, self.group, self.description, self.tags)
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
|
||||
from kafka.metrics import AnonMeasurable, KafkaMetric, MetricConfig, MetricName
|
||||
from kafka.metrics.stats import Sensor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Metrics(object):
|
||||
"""
|
||||
A registry of sensors and metrics.
|
||||
|
||||
A metric is a named, numerical measurement. A sensor is a handle to
|
||||
record numerical measurements as they occur. Each Sensor has zero or
|
||||
more associated metrics. For example a Sensor might represent message
|
||||
sizes and we might associate with this sensor a metric for the average,
|
||||
maximum, or other statistics computed off the sequence of message sizes
|
||||
that are recorded by the sensor.
|
||||
|
||||
Usage looks something like this:
|
||||
# set up metrics:
|
||||
metrics = Metrics() # the global repository of metrics and sensors
|
||||
sensor = metrics.sensor('message-sizes')
|
||||
metric_name = MetricName('message-size-avg', 'producer-metrics')
|
||||
sensor.add(metric_name, Avg())
|
||||
metric_name = MetricName('message-size-max', 'producer-metrics')
|
||||
sensor.add(metric_name, Max())
|
||||
|
||||
# as messages are sent we record the sizes
|
||||
sensor.record(message_size);
|
||||
"""
|
||||
def __init__(self, default_config=None, reporters=None,
|
||||
enable_expiration=False):
|
||||
"""
|
||||
Create a metrics repository with a default config, given metric
|
||||
reporters and the ability to expire eligible sensors
|
||||
|
||||
Arguments:
|
||||
default_config (MetricConfig, optional): The default config
|
||||
reporters (list of AbstractMetricsReporter, optional):
|
||||
The metrics reporters
|
||||
enable_expiration (bool, optional): true if the metrics instance
|
||||
can garbage collect inactive sensors, false otherwise
|
||||
"""
|
||||
self._lock = threading.RLock()
|
||||
self._config = default_config or MetricConfig()
|
||||
self._sensors = {}
|
||||
self._metrics = {}
|
||||
self._children_sensors = {}
|
||||
self._reporters = reporters or []
|
||||
for reporter in self._reporters:
|
||||
reporter.init([])
|
||||
self._closed = False
|
||||
|
||||
if enable_expiration:
|
||||
def expire_loop():
|
||||
while not self._closed:
|
||||
# delay 30 seconds
|
||||
time.sleep(30)
|
||||
self.ExpireSensorTask.run(self)
|
||||
metrics_scheduler = threading.Thread(target=expire_loop)
|
||||
# Creating a daemon thread to not block shutdown
|
||||
metrics_scheduler.daemon = True
|
||||
metrics_scheduler.start()
|
||||
|
||||
self.add_metric(self.metric_name('count', 'kafka-metrics-count',
|
||||
'total number of registered metrics'),
|
||||
AnonMeasurable(lambda config, now: len(self._metrics)))
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def metrics(self):
|
||||
"""
|
||||
Get all the metrics currently maintained and indexed by metricName
|
||||
"""
|
||||
return self._metrics
|
||||
|
||||
def metric_name(self, name, group, description='', tags=None):
|
||||
"""
|
||||
Create a MetricName with the given name, group, description and tags,
|
||||
plus default tags specified in the metric configuration.
|
||||
Tag in tags takes precedence if the same tag key is specified in
|
||||
the default metric configuration.
|
||||
|
||||
Arguments:
|
||||
name (str): The name of the metric
|
||||
group (str): logical group name of the metrics to which this
|
||||
metric belongs
|
||||
description (str, optional): A human-readable description to
|
||||
include in the metric
|
||||
tags (dict, optionals): additional key/value attributes of
|
||||
the metric
|
||||
"""
|
||||
combined_tags = dict(self.config.tags)
|
||||
combined_tags.update(tags or {})
|
||||
return MetricName(name, group, description, combined_tags)
|
||||
|
||||
def get_sensor(self, name):
|
||||
"""
|
||||
Get the sensor with the given name if it exists
|
||||
|
||||
Arguments:
|
||||
name (str): The name of the sensor
|
||||
|
||||
Returns:
|
||||
Sensor: The sensor or None if no such sensor exists
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError('name must be non-empty')
|
||||
return self._sensors.get(name, None)
|
||||
|
||||
def sensor(self, name, config=None,
|
||||
inactive_sensor_expiration_time_seconds=sys.maxsize,
|
||||
parents=None):
|
||||
"""
|
||||
Get or create a sensor with the given unique name and zero or
|
||||
more parent sensors. All parent sensors will receive every value
|
||||
recorded with this sensor.
|
||||
|
||||
Arguments:
|
||||
name (str): The name of the sensor
|
||||
config (MetricConfig, optional): A default configuration to use
|
||||
for this sensor for metrics that don't have their own config
|
||||
inactive_sensor_expiration_time_seconds (int, optional):
|
||||
If no value if recorded on the Sensor for this duration of
|
||||
time, it is eligible for removal
|
||||
parents (list of Sensor): The parent sensors
|
||||
|
||||
Returns:
|
||||
Sensor: The sensor that is created
|
||||
"""
|
||||
sensor = self.get_sensor(name)
|
||||
if sensor:
|
||||
return sensor
|
||||
|
||||
with self._lock:
|
||||
sensor = self.get_sensor(name)
|
||||
if not sensor:
|
||||
sensor = Sensor(self, name, parents, config or self.config,
|
||||
inactive_sensor_expiration_time_seconds)
|
||||
self._sensors[name] = sensor
|
||||
if parents:
|
||||
for parent in parents:
|
||||
children = self._children_sensors.get(parent)
|
||||
if not children:
|
||||
children = []
|
||||
self._children_sensors[parent] = children
|
||||
children.append(sensor)
|
||||
logger.debug('Added sensor with name %s', name)
|
||||
return sensor
|
||||
|
||||
def remove_sensor(self, name):
|
||||
"""
|
||||
Remove a sensor (if it exists), associated metrics and its children.
|
||||
|
||||
Arguments:
|
||||
name (str): The name of the sensor to be removed
|
||||
"""
|
||||
sensor = self._sensors.get(name)
|
||||
if sensor:
|
||||
child_sensors = None
|
||||
with sensor._lock:
|
||||
with self._lock:
|
||||
val = self._sensors.pop(name, None)
|
||||
if val and val == sensor:
|
||||
for metric in sensor.metrics:
|
||||
self.remove_metric(metric.metric_name)
|
||||
logger.debug('Removed sensor with name %s', name)
|
||||
child_sensors = self._children_sensors.pop(sensor, None)
|
||||
if child_sensors:
|
||||
for child_sensor in child_sensors:
|
||||
self.remove_sensor(child_sensor.name)
|
||||
|
||||
def add_metric(self, metric_name, measurable, config=None):
|
||||
"""
|
||||
Add a metric to monitor an object that implements measurable.
|
||||
This metric won't be associated with any sensor.
|
||||
This is a way to expose existing values as metrics.
|
||||
|
||||
Arguments:
|
||||
metricName (MetricName): The name of the metric
|
||||
measurable (AbstractMeasurable): The measurable that will be
|
||||
measured by this metric
|
||||
config (MetricConfig, optional): The configuration to use when
|
||||
measuring this measurable
|
||||
"""
|
||||
# NOTE there was a lock here, but i don't think it's needed
|
||||
metric = KafkaMetric(metric_name, measurable, config or self.config)
|
||||
self.register_metric(metric)
|
||||
|
||||
def remove_metric(self, metric_name):
|
||||
"""
|
||||
Remove a metric if it exists and return it. Return None otherwise.
|
||||
If a metric is removed, `metric_removal` will be invoked
|
||||
for each reporter.
|
||||
|
||||
Arguments:
|
||||
metric_name (MetricName): The name of the metric
|
||||
|
||||
Returns:
|
||||
KafkaMetric: the removed `KafkaMetric` or None if no such
|
||||
metric exists
|
||||
"""
|
||||
with self._lock:
|
||||
metric = self._metrics.pop(metric_name, None)
|
||||
if metric:
|
||||
for reporter in self._reporters:
|
||||
reporter.metric_removal(metric)
|
||||
return metric
|
||||
|
||||
def add_reporter(self, reporter):
|
||||
"""Add a MetricReporter"""
|
||||
with self._lock:
|
||||
reporter.init(list(self.metrics.values()))
|
||||
self._reporters.append(reporter)
|
||||
|
||||
def register_metric(self, metric):
|
||||
with self._lock:
|
||||
if metric.metric_name in self.metrics:
|
||||
raise ValueError('A metric named "%s" already exists, cannot'
|
||||
' register another one.' % (metric.metric_name,))
|
||||
self.metrics[metric.metric_name] = metric
|
||||
for reporter in self._reporters:
|
||||
reporter.metric_change(metric)
|
||||
|
||||
class ExpireSensorTask(object):
|
||||
"""
|
||||
This iterates over every Sensor and triggers a remove_sensor
|
||||
if it has expired. Package private for testing
|
||||
"""
|
||||
@staticmethod
|
||||
def run(metrics):
|
||||
items = list(metrics._sensors.items())
|
||||
for name, sensor in items:
|
||||
# remove_sensor also locks the sensor object. This is fine
|
||||
# because synchronized is reentrant. There is however a minor
|
||||
# race condition here. Assume we have a parent sensor P and
|
||||
# child sensor C. Calling record on C would cause a record on
|
||||
# P as well. So expiration time for P == expiration time for C.
|
||||
# If the record on P happens via C just after P is removed,
|
||||
# that will cause C to also get removed. Since the expiration
|
||||
# time is typically high it is not expected to be a significant
|
||||
# concern and thus not necessary to optimize
|
||||
with sensor._lock:
|
||||
if sensor.has_expired():
|
||||
logger.debug('Removing expired sensor %s', name)
|
||||
metrics.remove_sensor(name)
|
||||
|
||||
def close(self):
|
||||
"""Close this metrics repository."""
|
||||
for reporter in self._reporters:
|
||||
reporter.close()
|
||||
|
||||
self._metrics.clear()
|
||||
self._closed = True
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class AbstractMetricsReporter(object):
|
||||
"""
|
||||
An abstract class to allow things to listen as new metrics
|
||||
are created so they can be reported.
|
||||
"""
|
||||
@abc.abstractmethod
|
||||
def init(self, metrics):
|
||||
"""
|
||||
This is called when the reporter is first registered
|
||||
to initially register all existing metrics
|
||||
|
||||
Arguments:
|
||||
metrics (list of KafkaMetric): All currently existing metrics
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def metric_change(self, metric):
|
||||
"""
|
||||
This is called whenever a metric is updated or added
|
||||
|
||||
Arguments:
|
||||
metric (KafkaMetric)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def metric_removal(self, metric):
|
||||
"""
|
||||
This is called whenever a metric is removed
|
||||
|
||||
Arguments:
|
||||
metric (KafkaMetric)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def configure(self, configs):
|
||||
"""
|
||||
Configure this class with the given key-value pairs
|
||||
|
||||
Arguments:
|
||||
configs (dict of {str, ?})
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def close(self):
|
||||
"""Called when the metrics repository is closed."""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
class Quota(object):
|
||||
"""An upper or lower bound for metrics"""
|
||||
__slots__ = ('_bound', '_upper')
|
||||
|
||||
def __init__(self, bound, is_upper):
|
||||
self._bound = bound
|
||||
self._upper = is_upper
|
||||
|
||||
@staticmethod
|
||||
def upper_bound(upper_bound):
|
||||
return Quota(upper_bound, True)
|
||||
|
||||
@staticmethod
|
||||
def lower_bound(lower_bound):
|
||||
return Quota(lower_bound, False)
|
||||
|
||||
def is_upper_bound(self):
|
||||
return self._upper
|
||||
|
||||
@property
|
||||
def bound(self):
|
||||
return self._bound
|
||||
|
||||
def is_acceptable(self, value):
|
||||
return ((self.is_upper_bound() and value <= self.bound) or
|
||||
(not self.is_upper_bound() and value >= self.bound))
|
||||
|
||||
def __hash__(self):
|
||||
prime = 31
|
||||
result = prime + self.bound
|
||||
return prime * result + self.is_upper_bound()
|
||||
|
||||
def __eq__(self, other):
|
||||
if self is other:
|
||||
return True
|
||||
return (isinstance(self, type(other)) and
|
||||
self.bound == other.bound and
|
||||
self.is_upper_bound() == other.is_upper_bound())
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class AbstractStat(object):
|
||||
"""
|
||||
An AbstractStat is a quantity such as average, max, etc that is computed
|
||||
off the stream of updates to a sensor
|
||||
"""
|
||||
@abc.abstractmethod
|
||||
def record(self, config, value, time_ms):
|
||||
"""
|
||||
Record the given value
|
||||
|
||||
Arguments:
|
||||
config (MetricConfig): The configuration to use for this metric
|
||||
value (float): The value to record
|
||||
timeMs (int): The POSIX time in milliseconds this value occurred
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics.stats.avg import Avg
|
||||
from kafka.metrics.stats.count import Count
|
||||
from kafka.metrics.stats.histogram import Histogram
|
||||
from kafka.metrics.stats.max_stat import Max
|
||||
from kafka.metrics.stats.min_stat import Min
|
||||
from kafka.metrics.stats.percentile import Percentile
|
||||
from kafka.metrics.stats.percentiles import Percentiles
|
||||
from kafka.metrics.stats.rate import Rate
|
||||
from kafka.metrics.stats.sensor import Sensor
|
||||
from kafka.metrics.stats.total import Total
|
||||
|
||||
__all__ = [
|
||||
'Avg', 'Count', 'Histogram', 'Max', 'Min', 'Percentile', 'Percentiles',
|
||||
'Rate', 'Sensor', 'Total'
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics.stats.sampled_stat import AbstractSampledStat
|
||||
|
||||
|
||||
class Avg(AbstractSampledStat):
|
||||
"""
|
||||
An AbstractSampledStat that maintains a simple average over its samples.
|
||||
"""
|
||||
__slots__ = ('_initial_value', '_samples', '_current')
|
||||
|
||||
def __init__(self):
|
||||
super(Avg, self).__init__(0.0)
|
||||
|
||||
def update(self, sample, config, value, now):
|
||||
sample.value += value
|
||||
|
||||
def combine(self, samples, config, now):
|
||||
total_sum = 0
|
||||
total_count = 0
|
||||
for sample in samples:
|
||||
total_sum += sample.value
|
||||
total_count += sample.event_count
|
||||
if not total_count:
|
||||
return 0
|
||||
return float(total_sum) / total_count
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics.stats.sampled_stat import AbstractSampledStat
|
||||
|
||||
|
||||
class Count(AbstractSampledStat):
|
||||
"""
|
||||
An AbstractSampledStat that maintains a simple count of what it has seen.
|
||||
"""
|
||||
__slots__ = ('_initial_value', '_samples', '_current')
|
||||
|
||||
def __init__(self):
|
||||
super(Count, self).__init__(0.0)
|
||||
|
||||
def update(self, sample, config, value, now):
|
||||
sample.value += 1.0
|
||||
|
||||
def combine(self, samples, config, now):
|
||||
return float(sum(sample.value for sample in samples))
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import math
|
||||
|
||||
|
||||
class Histogram(object):
|
||||
__slots__ = ('_hist', '_count', '_bin_scheme')
|
||||
|
||||
def __init__(self, bin_scheme):
|
||||
self._hist = [0.0] * bin_scheme.bins
|
||||
self._count = 0.0
|
||||
self._bin_scheme = bin_scheme
|
||||
|
||||
def record(self, value):
|
||||
self._hist[self._bin_scheme.to_bin(value)] += 1.0
|
||||
self._count += 1.0
|
||||
|
||||
def value(self, quantile):
|
||||
if self._count == 0.0:
|
||||
return float('NaN')
|
||||
_sum = 0.0
|
||||
quant = float(quantile)
|
||||
for i, value in enumerate(self._hist[:-1]):
|
||||
_sum += value
|
||||
if _sum / self._count > quant:
|
||||
return self._bin_scheme.from_bin(i)
|
||||
return float('inf')
|
||||
|
||||
@property
|
||||
def counts(self):
|
||||
return self._hist
|
||||
|
||||
def clear(self):
|
||||
for i in range(self._hist):
|
||||
self._hist[i] = 0.0
|
||||
self._count = 0
|
||||
|
||||
def __str__(self):
|
||||
values = ['%.10f:%.0f' % (self._bin_scheme.from_bin(i), value) for
|
||||
i, value in enumerate(self._hist[:-1])]
|
||||
values.append('%s:%s' % (float('inf'), self._hist[-1]))
|
||||
return '{%s}' % ','.join(values)
|
||||
|
||||
class ConstantBinScheme(object):
|
||||
__slots__ = ('_min', '_max', '_bins', '_bucket_width')
|
||||
|
||||
def __init__(self, bins, min_val, max_val):
|
||||
if bins < 2:
|
||||
raise ValueError('Must have at least 2 bins.')
|
||||
self._min = float(min_val)
|
||||
self._max = float(max_val)
|
||||
self._bins = int(bins)
|
||||
self._bucket_width = (max_val - min_val) / (bins - 2)
|
||||
|
||||
@property
|
||||
def bins(self):
|
||||
return self._bins
|
||||
|
||||
def from_bin(self, b):
|
||||
if b == 0:
|
||||
return float('-inf')
|
||||
elif b == self._bins - 1:
|
||||
return float('inf')
|
||||
else:
|
||||
return self._min + (b - 1) * self._bucket_width
|
||||
|
||||
def to_bin(self, x):
|
||||
if x < self._min:
|
||||
return 0
|
||||
elif x > self._max:
|
||||
return self._bins - 1
|
||||
else:
|
||||
return int(((x - self._min) / self._bucket_width) + 1)
|
||||
|
||||
class LinearBinScheme(object):
|
||||
__slots__ = ('_bins', '_max', '_scale')
|
||||
|
||||
def __init__(self, num_bins, max_val):
|
||||
self._bins = num_bins
|
||||
self._max = max_val
|
||||
self._scale = max_val / (num_bins * (num_bins - 1) / 2)
|
||||
|
||||
@property
|
||||
def bins(self):
|
||||
return self._bins
|
||||
|
||||
def from_bin(self, b):
|
||||
if b == self._bins - 1:
|
||||
return float('inf')
|
||||
else:
|
||||
unscaled = (b * (b + 1.0)) / 2.0
|
||||
return unscaled * self._scale
|
||||
|
||||
def to_bin(self, x):
|
||||
if x < 0.0:
|
||||
raise ValueError('Values less than 0.0 not accepted.')
|
||||
elif x > self._max:
|
||||
return self._bins - 1
|
||||
else:
|
||||
scaled = x / self._scale
|
||||
return int(-0.5 + math.sqrt(2.0 * scaled + 0.25))
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics.stats.sampled_stat import AbstractSampledStat
|
||||
|
||||
|
||||
class Max(AbstractSampledStat):
|
||||
"""An AbstractSampledStat that gives the max over its samples."""
|
||||
__slots__ = ('_initial_value', '_samples', '_current')
|
||||
|
||||
def __init__(self):
|
||||
super(Max, self).__init__(float('-inf'))
|
||||
|
||||
def update(self, sample, config, value, now):
|
||||
sample.value = max(sample.value, value)
|
||||
|
||||
def combine(self, samples, config, now):
|
||||
if not samples:
|
||||
return float('-inf')
|
||||
return float(max(sample.value for sample in samples))
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import sys
|
||||
|
||||
from kafka.metrics.stats.sampled_stat import AbstractSampledStat
|
||||
|
||||
|
||||
class Min(AbstractSampledStat):
|
||||
"""An AbstractSampledStat that gives the min over its samples."""
|
||||
__slots__ = ('_initial_value', '_samples', '_current')
|
||||
|
||||
def __init__(self):
|
||||
super(Min, self).__init__(float(sys.maxsize))
|
||||
|
||||
def update(self, sample, config, value, now):
|
||||
sample.value = min(sample.value, value)
|
||||
|
||||
def combine(self, samples, config, now):
|
||||
if not samples:
|
||||
return float(sys.maxsize)
|
||||
return float(min(sample.value for sample in samples))
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
class Percentile(object):
|
||||
__slots__ = ('_metric_name', '_percentile')
|
||||
|
||||
def __init__(self, metric_name, percentile):
|
||||
self._metric_name = metric_name
|
||||
self._percentile = float(percentile)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._metric_name
|
||||
|
||||
@property
|
||||
def percentile(self):
|
||||
return self._percentile
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics import AnonMeasurable, NamedMeasurable
|
||||
from kafka.metrics.compound_stat import AbstractCompoundStat
|
||||
from kafka.metrics.stats import Histogram
|
||||
from kafka.metrics.stats.sampled_stat import AbstractSampledStat
|
||||
|
||||
|
||||
class BucketSizing(object):
|
||||
CONSTANT = 0
|
||||
LINEAR = 1
|
||||
|
||||
|
||||
class Percentiles(AbstractSampledStat, AbstractCompoundStat):
|
||||
"""A compound stat that reports one or more percentiles"""
|
||||
__slots__ = ('_initial_value', '_samples', '_current',
|
||||
'_percentiles', '_buckets', '_bin_scheme')
|
||||
|
||||
def __init__(self, size_in_bytes, bucketing, max_val, min_val=0.0,
|
||||
percentiles=None):
|
||||
super(Percentiles, self).__init__(0.0)
|
||||
self._percentiles = percentiles or []
|
||||
self._buckets = int(size_in_bytes / 4)
|
||||
if bucketing == BucketSizing.CONSTANT:
|
||||
self._bin_scheme = Histogram.ConstantBinScheme(self._buckets,
|
||||
min_val, max_val)
|
||||
elif bucketing == BucketSizing.LINEAR:
|
||||
if min_val != 0.0:
|
||||
raise ValueError('Linear bucket sizing requires min_val'
|
||||
' to be 0.0.')
|
||||
self.bin_scheme = Histogram.LinearBinScheme(self._buckets, max_val)
|
||||
else:
|
||||
raise ValueError('Unknown bucket type: %s' % (bucketing,))
|
||||
|
||||
def stats(self):
|
||||
measurables = []
|
||||
|
||||
def make_measure_fn(pct):
|
||||
return lambda config, now: self.value(config, now,
|
||||
pct / 100.0)
|
||||
|
||||
for percentile in self._percentiles:
|
||||
measure_fn = make_measure_fn(percentile.percentile)
|
||||
stat = NamedMeasurable(percentile.name, AnonMeasurable(measure_fn))
|
||||
measurables.append(stat)
|
||||
return measurables
|
||||
|
||||
def value(self, config, now, quantile):
|
||||
self.purge_obsolete_samples(config, now)
|
||||
count = sum(sample.event_count for sample in self._samples)
|
||||
if count == 0.0:
|
||||
return float('NaN')
|
||||
sum_val = 0.0
|
||||
quant = float(quantile)
|
||||
for b in range(self._buckets):
|
||||
for sample in self._samples:
|
||||
assert type(sample) is self.HistogramSample
|
||||
hist = sample.histogram.counts
|
||||
sum_val += hist[b]
|
||||
if sum_val / count > quant:
|
||||
return self._bin_scheme.from_bin(b)
|
||||
return float('inf')
|
||||
|
||||
def combine(self, samples, config, now):
|
||||
return self.value(config, now, 0.5)
|
||||
|
||||
def new_sample(self, time_ms):
|
||||
return Percentiles.HistogramSample(self._bin_scheme, time_ms)
|
||||
|
||||
def update(self, sample, config, value, time_ms):
|
||||
assert type(sample) is self.HistogramSample
|
||||
sample.histogram.record(value)
|
||||
|
||||
class HistogramSample(AbstractSampledStat.Sample):
|
||||
def __init__(self, scheme, now):
|
||||
super(Percentiles.HistogramSample, self).__init__(0.0, now)
|
||||
self.histogram = Histogram(scheme)
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics.measurable_stat import AbstractMeasurableStat
|
||||
from kafka.metrics.stats.sampled_stat import AbstractSampledStat
|
||||
|
||||
|
||||
class TimeUnit(object):
|
||||
_names = {
|
||||
'nanosecond': 0,
|
||||
'microsecond': 1,
|
||||
'millisecond': 2,
|
||||
'second': 3,
|
||||
'minute': 4,
|
||||
'hour': 5,
|
||||
'day': 6,
|
||||
}
|
||||
|
||||
NANOSECONDS = _names['nanosecond']
|
||||
MICROSECONDS = _names['microsecond']
|
||||
MILLISECONDS = _names['millisecond']
|
||||
SECONDS = _names['second']
|
||||
MINUTES = _names['minute']
|
||||
HOURS = _names['hour']
|
||||
DAYS = _names['day']
|
||||
|
||||
@staticmethod
|
||||
def get_name(time_unit):
|
||||
return TimeUnit._names[time_unit]
|
||||
|
||||
|
||||
class Rate(AbstractMeasurableStat):
|
||||
"""
|
||||
The rate of the given quantity. By default this is the total observed
|
||||
over a set of samples from a sampled statistic divided by the elapsed
|
||||
time over the sample windows. Alternative AbstractSampledStat
|
||||
implementations can be provided, however, to record the rate of
|
||||
occurrences (e.g. the count of values measured over the time interval)
|
||||
or other such values.
|
||||
"""
|
||||
__slots__ = ('_stat', '_unit')
|
||||
|
||||
def __init__(self, time_unit=TimeUnit.SECONDS, sampled_stat=None):
|
||||
self._stat = sampled_stat or SampledTotal()
|
||||
self._unit = time_unit
|
||||
|
||||
def unit_name(self):
|
||||
return TimeUnit.get_name(self._unit)
|
||||
|
||||
def record(self, config, value, time_ms):
|
||||
self._stat.record(config, value, time_ms)
|
||||
|
||||
def measure(self, config, now):
|
||||
value = self._stat.measure(config, now)
|
||||
return float(value) / self.convert(self.window_size(config, now))
|
||||
|
||||
def window_size(self, config, now):
|
||||
# purge old samples before we compute the window size
|
||||
self._stat.purge_obsolete_samples(config, now)
|
||||
|
||||
"""
|
||||
Here we check the total amount of time elapsed since the oldest
|
||||
non-obsolete window. This give the total window_size of the batch
|
||||
which is the time used for Rate computation. However, there is
|
||||
an issue if we do not have sufficient data for e.g. if only
|
||||
1 second has elapsed in a 30 second window, the measured rate
|
||||
will be very high. Hence we assume that the elapsed time is
|
||||
always N-1 complete windows plus whatever fraction of the final
|
||||
window is complete.
|
||||
|
||||
Note that we could simply count the amount of time elapsed in
|
||||
the current window and add n-1 windows to get the total time,
|
||||
but this approach does not account for sleeps. AbstractSampledStat
|
||||
only creates samples whenever record is called, if no record is
|
||||
called for a period of time that time is not accounted for in
|
||||
window_size and produces incorrect results.
|
||||
"""
|
||||
total_elapsed_time_ms = now - self._stat.oldest(now).last_window_ms
|
||||
# Check how many full windows of data we have currently retained
|
||||
num_full_windows = int(total_elapsed_time_ms / config.time_window_ms)
|
||||
min_full_windows = config.samples - 1
|
||||
|
||||
# If the available windows are less than the minimum required,
|
||||
# add the difference to the totalElapsedTime
|
||||
if num_full_windows < min_full_windows:
|
||||
total_elapsed_time_ms += ((min_full_windows - num_full_windows) *
|
||||
config.time_window_ms)
|
||||
|
||||
return total_elapsed_time_ms
|
||||
|
||||
def convert(self, time_ms):
|
||||
if self._unit == TimeUnit.NANOSECONDS:
|
||||
return time_ms * 1000.0 * 1000.0
|
||||
elif self._unit == TimeUnit.MICROSECONDS:
|
||||
return time_ms * 1000.0
|
||||
elif self._unit == TimeUnit.MILLISECONDS:
|
||||
return time_ms
|
||||
elif self._unit == TimeUnit.SECONDS:
|
||||
return time_ms / 1000.0
|
||||
elif self._unit == TimeUnit.MINUTES:
|
||||
return time_ms / (60.0 * 1000.0)
|
||||
elif self._unit == TimeUnit.HOURS:
|
||||
return time_ms / (60.0 * 60.0 * 1000.0)
|
||||
elif self._unit == TimeUnit.DAYS:
|
||||
return time_ms / (24.0 * 60.0 * 60.0 * 1000.0)
|
||||
else:
|
||||
raise ValueError('Unknown unit: %s' % (self._unit,))
|
||||
|
||||
|
||||
class SampledTotal(AbstractSampledStat):
|
||||
__slots__ = ('_initial_value', '_samples', '_current')
|
||||
def __init__(self, initial_value=None):
|
||||
if initial_value is not None:
|
||||
raise ValueError('initial_value cannot be set on SampledTotal')
|
||||
super(SampledTotal, self).__init__(0.0)
|
||||
|
||||
def update(self, sample, config, value, time_ms):
|
||||
sample.value += value
|
||||
|
||||
def combine(self, samples, config, now):
|
||||
return float(sum(sample.value for sample in samples))
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.metrics.measurable_stat import AbstractMeasurableStat
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class AbstractSampledStat(AbstractMeasurableStat):
|
||||
"""
|
||||
An AbstractSampledStat records a single scalar value measured over
|
||||
one or more samples. Each sample is recorded over a configurable
|
||||
window. The window can be defined by number of events or elapsed
|
||||
time (or both, if both are given the window is complete when
|
||||
*either* the event count or elapsed time criterion is met).
|
||||
|
||||
All the samples are combined to produce the measurement. When a
|
||||
window is complete the oldest sample is cleared and recycled to
|
||||
begin recording the next sample.
|
||||
|
||||
Subclasses of this class define different statistics measured
|
||||
using this basic pattern.
|
||||
"""
|
||||
__slots__ = ('_initial_value', '_samples', '_current')
|
||||
|
||||
def __init__(self, initial_value):
|
||||
self._initial_value = initial_value
|
||||
self._samples = []
|
||||
self._current = 0
|
||||
|
||||
@abc.abstractmethod
|
||||
def update(self, sample, config, value, time_ms):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def combine(self, samples, config, now):
|
||||
raise NotImplementedError
|
||||
|
||||
def record(self, config, value, time_ms):
|
||||
sample = self.current(time_ms)
|
||||
if sample.is_complete(time_ms, config):
|
||||
sample = self._advance(config, time_ms)
|
||||
self.update(sample, config, float(value), time_ms)
|
||||
sample.event_count += 1
|
||||
|
||||
def new_sample(self, time_ms):
|
||||
return self.Sample(self._initial_value, time_ms)
|
||||
|
||||
def measure(self, config, now):
|
||||
self.purge_obsolete_samples(config, now)
|
||||
return float(self.combine(self._samples, config, now))
|
||||
|
||||
def current(self, time_ms):
|
||||
if not self._samples:
|
||||
self._samples.append(self.new_sample(time_ms))
|
||||
return self._samples[self._current]
|
||||
|
||||
def oldest(self, now):
|
||||
if not self._samples:
|
||||
self._samples.append(self.new_sample(now))
|
||||
oldest = self._samples[0]
|
||||
for sample in self._samples[1:]:
|
||||
if sample.last_window_ms < oldest.last_window_ms:
|
||||
oldest = sample
|
||||
return oldest
|
||||
|
||||
def purge_obsolete_samples(self, config, now):
|
||||
"""
|
||||
Timeout any windows that have expired in the absence of any events
|
||||
"""
|
||||
expire_age = config.samples * config.time_window_ms
|
||||
for sample in self._samples:
|
||||
if now - sample.last_window_ms >= expire_age:
|
||||
sample.reset(now)
|
||||
|
||||
def _advance(self, config, time_ms):
|
||||
self._current = (self._current + 1) % config.samples
|
||||
if self._current >= len(self._samples):
|
||||
sample = self.new_sample(time_ms)
|
||||
self._samples.append(sample)
|
||||
return sample
|
||||
else:
|
||||
sample = self.current(time_ms)
|
||||
sample.reset(time_ms)
|
||||
return sample
|
||||
|
||||
class Sample(object):
|
||||
|
||||
def __init__(self, initial_value, now):
|
||||
self.initial_value = initial_value
|
||||
self.event_count = 0
|
||||
self.last_window_ms = now
|
||||
self.value = initial_value
|
||||
|
||||
def reset(self, now):
|
||||
self.event_count = 0
|
||||
self.last_window_ms = now
|
||||
self.value = self.initial_value
|
||||
|
||||
def is_complete(self, time_ms, config):
|
||||
return (time_ms - self.last_window_ms >= config.time_window_ms or
|
||||
self.event_count >= config.event_window)
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kafka.errors import QuotaViolationError
|
||||
from kafka.metrics import KafkaMetric
|
||||
|
||||
|
||||
class Sensor(object):
|
||||
"""
|
||||
A sensor applies a continuous sequence of numerical values
|
||||
to a set of associated metrics. For example a sensor on
|
||||
message size would record a sequence of message sizes using
|
||||
the `record(double)` api and would maintain a set
|
||||
of metrics about request sizes such as the average or max.
|
||||
"""
|
||||
__slots__ = ('_lock', '_registry', '_name', '_parents', '_metrics',
|
||||
'_stats', '_config', '_inactive_sensor_expiration_time_ms',
|
||||
'_last_record_time')
|
||||
|
||||
def __init__(self, registry, name, parents, config,
|
||||
inactive_sensor_expiration_time_seconds):
|
||||
if not name:
|
||||
raise ValueError('name must be non-empty')
|
||||
self._lock = threading.RLock()
|
||||
self._registry = registry
|
||||
self._name = name
|
||||
self._parents = parents or []
|
||||
self._metrics = []
|
||||
self._stats = []
|
||||
self._config = config
|
||||
self._inactive_sensor_expiration_time_ms = (
|
||||
inactive_sensor_expiration_time_seconds * 1000)
|
||||
self._last_record_time = time.time() * 1000
|
||||
self._check_forest(set())
|
||||
|
||||
def _check_forest(self, sensors):
|
||||
"""Validate that this sensor doesn't end up referencing itself."""
|
||||
if self in sensors:
|
||||
raise ValueError('Circular dependency in sensors: %s is its own'
|
||||
'parent.' % (self.name,))
|
||||
sensors.add(self)
|
||||
for parent in self._parents:
|
||||
parent._check_forest(sensors)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
The name this sensor is registered with.
|
||||
This name will be unique among all registered sensors.
|
||||
"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def metrics(self):
|
||||
return tuple(self._metrics)
|
||||
|
||||
def record(self, value=1.0, time_ms=None):
|
||||
"""
|
||||
Record a value at a known time.
|
||||
Arguments:
|
||||
value (double): The value we are recording
|
||||
time_ms (int): A POSIX timestamp in milliseconds.
|
||||
Default: The time when record() is evaluated (now)
|
||||
|
||||
Raises:
|
||||
QuotaViolationException: if recording this value moves a
|
||||
metric beyond its configured maximum or minimum bound
|
||||
"""
|
||||
if time_ms is None:
|
||||
time_ms = time.time() * 1000
|
||||
self._last_record_time = time_ms
|
||||
with self._lock: # XXX high volume, might be performance issue
|
||||
# increment all the stats
|
||||
for stat in self._stats:
|
||||
stat.record(self._config, value, time_ms)
|
||||
self._check_quotas(time_ms)
|
||||
for parent in self._parents:
|
||||
parent.record(value, time_ms)
|
||||
|
||||
def _check_quotas(self, time_ms):
|
||||
"""
|
||||
Check if we have violated our quota for any metric that
|
||||
has a configured quota
|
||||
"""
|
||||
for metric in self._metrics:
|
||||
if metric.config and metric.config.quota:
|
||||
value = metric.value(time_ms)
|
||||
if not metric.config.quota.is_acceptable(value):
|
||||
raise QuotaViolationError("'%s' violated quota. Actual: "
|
||||
"%d, Threshold: %d" %
|
||||
(metric.metric_name,
|
||||
value,
|
||||
metric.config.quota.bound))
|
||||
|
||||
def add_compound(self, compound_stat, config=None):
|
||||
"""
|
||||
Register a compound statistic with this sensor which
|
||||
yields multiple measurable quantities (like a histogram)
|
||||
|
||||
Arguments:
|
||||
stat (AbstractCompoundStat): The stat to register
|
||||
config (MetricConfig): The configuration for this stat.
|
||||
If None then the stat will use the default configuration
|
||||
for this sensor.
|
||||
"""
|
||||
if not compound_stat:
|
||||
raise ValueError('compound stat must be non-empty')
|
||||
self._stats.append(compound_stat)
|
||||
for named_measurable in compound_stat.stats():
|
||||
metric = KafkaMetric(named_measurable.name, named_measurable.stat,
|
||||
config or self._config)
|
||||
self._registry.register_metric(metric)
|
||||
self._metrics.append(metric)
|
||||
|
||||
def add(self, metric_name, stat, config=None):
|
||||
"""
|
||||
Register a metric with this sensor
|
||||
|
||||
Arguments:
|
||||
metric_name (MetricName): The name of the metric
|
||||
stat (AbstractMeasurableStat): The statistic to keep
|
||||
config (MetricConfig): A special configuration for this metric.
|
||||
If None use the sensor default configuration.
|
||||
"""
|
||||
with self._lock:
|
||||
metric = KafkaMetric(metric_name, stat, config or self._config)
|
||||
self._registry.register_metric(metric)
|
||||
self._metrics.append(metric)
|
||||
self._stats.append(stat)
|
||||
|
||||
def has_expired(self):
|
||||
"""
|
||||
Return True if the Sensor is eligible for removal due to inactivity.
|
||||
"""
|
||||
return ((time.time() * 1000 - self._last_record_time) >
|
||||
self._inactive_sensor_expiration_time_ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.metrics.measurable_stat import AbstractMeasurableStat
|
||||
|
||||
|
||||
class Total(AbstractMeasurableStat):
|
||||
"""An un-windowed cumulative total maintained over all time."""
|
||||
__slots__ = ('_total')
|
||||
|
||||
def __init__(self, value=0.0):
|
||||
self._total = value
|
||||
|
||||
def record(self, config, value, now):
|
||||
self._total += value
|
||||
|
||||
def measure(self, config, now):
|
||||
return float(self._total)
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.partitioner.default import DefaultPartitioner, murmur2
|
||||
|
||||
|
||||
__all__ = [
|
||||
'DefaultPartitioner', 'murmur2'
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import random
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
|
||||
class DefaultPartitioner(object):
|
||||
"""Default partitioner.
|
||||
|
||||
Hashes key to partition using murmur2 hashing (from java client)
|
||||
If key is None, selects partition randomly from available,
|
||||
or from all partitions if none are currently available
|
||||
"""
|
||||
@classmethod
|
||||
def __call__(cls, key, all_partitions, available):
|
||||
"""
|
||||
Get the partition corresponding to key
|
||||
:param key: partitioning key
|
||||
:param all_partitions: list of all partitions sorted by partition ID
|
||||
:param available: list of available partitions in no particular order
|
||||
:return: one of the values from all_partitions or available
|
||||
"""
|
||||
if key is None:
|
||||
if available:
|
||||
return random.choice(available)
|
||||
return random.choice(all_partitions)
|
||||
|
||||
idx = murmur2(key)
|
||||
idx &= 0x7fffffff
|
||||
idx %= len(all_partitions)
|
||||
return all_partitions[idx]
|
||||
|
||||
|
||||
# https://github.com/apache/kafka/blob/0.8.2/clients/src/main/java/org/apache/kafka/common/utils/Utils.java#L244
|
||||
def murmur2(data):
|
||||
"""Pure-python Murmur2 implementation.
|
||||
|
||||
Based on java client, see org.apache.kafka.common.utils.Utils.murmur2
|
||||
|
||||
Args:
|
||||
data (bytes): opaque bytes
|
||||
|
||||
Returns: MurmurHash2 of data
|
||||
"""
|
||||
# Python2 bytes is really a str, causing the bitwise operations below to fail
|
||||
# so convert to bytearray.
|
||||
if six.PY2:
|
||||
data = bytearray(bytes(data))
|
||||
|
||||
length = len(data)
|
||||
seed = 0x9747b28c
|
||||
# 'm' and 'r' are mixing constants generated offline.
|
||||
# They're not really 'magic', they just happen to work well.
|
||||
m = 0x5bd1e995
|
||||
r = 24
|
||||
|
||||
# Initialize the hash to a random value
|
||||
h = seed ^ length
|
||||
length4 = length // 4
|
||||
|
||||
for i in range(length4):
|
||||
i4 = i * 4
|
||||
k = ((data[i4 + 0] & 0xff) +
|
||||
((data[i4 + 1] & 0xff) << 8) +
|
||||
((data[i4 + 2] & 0xff) << 16) +
|
||||
((data[i4 + 3] & 0xff) << 24))
|
||||
k &= 0xffffffff
|
||||
k *= m
|
||||
k &= 0xffffffff
|
||||
k ^= (k % 0x100000000) >> r # k ^= k >>> r
|
||||
k &= 0xffffffff
|
||||
k *= m
|
||||
k &= 0xffffffff
|
||||
|
||||
h *= m
|
||||
h &= 0xffffffff
|
||||
h ^= k
|
||||
h &= 0xffffffff
|
||||
|
||||
# Handle the last few bytes of the input array
|
||||
extra_bytes = length % 4
|
||||
if extra_bytes >= 3:
|
||||
h ^= (data[(length & ~3) + 2] & 0xff) << 16
|
||||
h &= 0xffffffff
|
||||
if extra_bytes >= 2:
|
||||
h ^= (data[(length & ~3) + 1] & 0xff) << 8
|
||||
h &= 0xffffffff
|
||||
if extra_bytes >= 1:
|
||||
h ^= (data[length & ~3] & 0xff)
|
||||
h &= 0xffffffff
|
||||
h *= m
|
||||
h &= 0xffffffff
|
||||
|
||||
h ^= (h % 0x100000000) >> 13 # h >>> 13;
|
||||
h &= 0xffffffff
|
||||
h *= m
|
||||
h &= 0xffffffff
|
||||
h ^= (h % 0x100000000) >> 15 # h >>> 15;
|
||||
h &= 0xffffffff
|
||||
|
||||
return h
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.producer.kafka import KafkaProducer
|
||||
|
||||
__all__ = [
|
||||
'KafkaProducer'
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
import threading
|
||||
|
||||
from kafka import errors as Errors
|
||||
from kafka.future import Future
|
||||
|
||||
|
||||
class FutureProduceResult(Future):
|
||||
def __init__(self, topic_partition):
|
||||
super(FutureProduceResult, self).__init__()
|
||||
self.topic_partition = topic_partition
|
||||
self._latch = threading.Event()
|
||||
|
||||
def success(self, value):
|
||||
ret = super(FutureProduceResult, self).success(value)
|
||||
self._latch.set()
|
||||
return ret
|
||||
|
||||
def failure(self, error):
|
||||
ret = super(FutureProduceResult, self).failure(error)
|
||||
self._latch.set()
|
||||
return ret
|
||||
|
||||
def wait(self, timeout=None):
|
||||
# wait() on python2.6 returns None instead of the flag value
|
||||
return self._latch.wait(timeout) or self._latch.is_set()
|
||||
|
||||
|
||||
class FutureRecordMetadata(Future):
|
||||
def __init__(self, produce_future, relative_offset, timestamp_ms, checksum, serialized_key_size, serialized_value_size, serialized_header_size):
|
||||
super(FutureRecordMetadata, self).__init__()
|
||||
self._produce_future = produce_future
|
||||
# packing args as a tuple is a minor speed optimization
|
||||
self.args = (relative_offset, timestamp_ms, checksum, serialized_key_size, serialized_value_size, serialized_header_size)
|
||||
produce_future.add_callback(self._produce_success)
|
||||
produce_future.add_errback(self.failure)
|
||||
|
||||
def _produce_success(self, offset_and_timestamp):
|
||||
offset, produce_timestamp_ms = offset_and_timestamp
|
||||
|
||||
# Unpacking from args tuple is minor speed optimization
|
||||
(relative_offset, timestamp_ms, checksum,
|
||||
serialized_key_size, serialized_value_size, serialized_header_size) = self.args
|
||||
|
||||
# None is when Broker does not support the API (<0.10) and
|
||||
# -1 is when the broker is configured for CREATE_TIME timestamps
|
||||
if produce_timestamp_ms is not None and produce_timestamp_ms != -1:
|
||||
timestamp_ms = produce_timestamp_ms
|
||||
if offset != -1 and relative_offset is not None:
|
||||
offset += relative_offset
|
||||
tp = self._produce_future.topic_partition
|
||||
metadata = RecordMetadata(tp[0], tp[1], tp, offset, timestamp_ms,
|
||||
checksum, serialized_key_size,
|
||||
serialized_value_size, serialized_header_size)
|
||||
self.success(metadata)
|
||||
|
||||
def get(self, timeout=None):
|
||||
if not self.is_done and not self._produce_future.wait(timeout):
|
||||
raise Errors.KafkaTimeoutError(
|
||||
"Timeout after waiting for %s secs." % (timeout,))
|
||||
assert self.is_done
|
||||
if self.failed():
|
||||
raise self.exception # pylint: disable-msg=raising-bad-type
|
||||
return self.value
|
||||
|
||||
|
||||
RecordMetadata = collections.namedtuple(
|
||||
'RecordMetadata', ['topic', 'partition', 'topic_partition', 'offset', 'timestamp',
|
||||
'checksum', 'serialized_key_size', 'serialized_value_size', 'serialized_header_size'])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
||||
from __future__ import absolute_import, division
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
# enum in stdlib as of py3.4
|
||||
from enum import IntEnum # pylint: disable=import-error
|
||||
except ImportError:
|
||||
# vendored backport module
|
||||
from kafka.vendor.enum34 import IntEnum
|
||||
|
||||
import kafka.errors as Errors
|
||||
from kafka.producer.future import FutureRecordMetadata, FutureProduceResult
|
||||
from kafka.record.memory_records import MemoryRecordsBuilder
|
||||
from kafka.structs import TopicPartition
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AtomicInteger(object):
|
||||
def __init__(self, val=0):
|
||||
self._lock = threading.Lock()
|
||||
self._val = val
|
||||
|
||||
def increment(self):
|
||||
with self._lock:
|
||||
self._val += 1
|
||||
return self._val
|
||||
|
||||
def decrement(self):
|
||||
with self._lock:
|
||||
self._val -= 1
|
||||
return self._val
|
||||
|
||||
def get(self):
|
||||
return self._val
|
||||
|
||||
|
||||
class FinalState(IntEnum):
|
||||
ABORTED = 0
|
||||
FAILED = 1
|
||||
SUCCEEDED = 2
|
||||
|
||||
|
||||
class ProducerBatch(object):
|
||||
def __init__(self, tp, records, now=None):
|
||||
now = time.time() if now is None else now
|
||||
self.max_record_size = 0
|
||||
self.created = now
|
||||
self.drained = None
|
||||
self.attempts = 0
|
||||
self.last_attempt = now
|
||||
self.last_append = now
|
||||
self.records = records
|
||||
self.topic_partition = tp
|
||||
self.produce_future = FutureProduceResult(tp)
|
||||
self._retry = False
|
||||
self._final_state = None
|
||||
|
||||
@property
|
||||
def final_state(self):
|
||||
return self._final_state
|
||||
|
||||
@property
|
||||
def record_count(self):
|
||||
return self.records.next_offset()
|
||||
|
||||
@property
|
||||
def producer_id(self):
|
||||
return self.records.producer_id if self.records else None
|
||||
|
||||
@property
|
||||
def producer_epoch(self):
|
||||
return self.records.producer_epoch if self.records else None
|
||||
|
||||
@property
|
||||
def has_sequence(self):
|
||||
return self.records.has_sequence if self.records else False
|
||||
|
||||
def try_append(self, timestamp_ms, key, value, headers, now=None):
|
||||
metadata = self.records.append(timestamp_ms, key, value, headers)
|
||||
if metadata is None:
|
||||
return None
|
||||
|
||||
now = time.time() if now is None else now
|
||||
self.max_record_size = max(self.max_record_size, metadata.size)
|
||||
self.last_append = now
|
||||
future = FutureRecordMetadata(
|
||||
self.produce_future,
|
||||
metadata.offset,
|
||||
metadata.timestamp,
|
||||
metadata.crc,
|
||||
len(key) if key is not None else -1,
|
||||
len(value) if value is not None else -1,
|
||||
sum(len(h_key.encode("utf-8")) + len(h_val) for h_key, h_val in headers) if headers else -1)
|
||||
return future
|
||||
|
||||
def abort(self, exception):
|
||||
"""Abort the batch and complete the future and callbacks."""
|
||||
if self._final_state is not None:
|
||||
raise Errors.IllegalStateError("Batch has already been completed in final state: %s" % self._final_state)
|
||||
self._final_state = FinalState.ABORTED
|
||||
|
||||
log.debug("Aborting batch for partition %s: %s", self.topic_partition, exception)
|
||||
self._complete_future(-1, -1, exception)
|
||||
|
||||
def done(self, base_offset=None, timestamp_ms=None, exception=None):
|
||||
"""
|
||||
Finalize the state of a batch. Final state, once set, is immutable. This function may be called
|
||||
once or twice on a batch. It may be called twice if
|
||||
1. An inflight batch expires before a response from the broker is received. The batch's final
|
||||
state is set to FAILED. But it could succeed on the broker and second time around batch.done() may
|
||||
try to set SUCCEEDED final state.
|
||||
|
||||
2. If a transaction abortion happens or if the producer is closed forcefully, the final state is
|
||||
ABORTED but again it could succeed if broker responds with a success.
|
||||
|
||||
Attempted transitions from [FAILED | ABORTED] --> SUCCEEDED are logged.
|
||||
Attempted transitions from one failure state to the same or a different failed state are ignored.
|
||||
Attempted transitions from SUCCEEDED to the same or a failed state throw an exception.
|
||||
"""
|
||||
final_state = FinalState.SUCCEEDED if exception is None else FinalState.FAILED
|
||||
if self._final_state is None:
|
||||
self._final_state = final_state
|
||||
if final_state is FinalState.SUCCEEDED:
|
||||
log.debug("Successfully produced messages to %s with base offset %s", self.topic_partition, base_offset)
|
||||
else:
|
||||
log.warning("Failed to produce messages to topic-partition %s with base offset %s: %s",
|
||||
self.topic_partition, base_offset, exception)
|
||||
self._complete_future(base_offset, timestamp_ms, exception)
|
||||
return True
|
||||
|
||||
elif self._final_state is not FinalState.SUCCEEDED:
|
||||
if final_state is FinalState.SUCCEEDED:
|
||||
# Log if a previously unsuccessful batch succeeded later on.
|
||||
log.debug("ProduceResponse returned %s for %s after batch with base offset %s had already been %s.",
|
||||
final_state, self.topic_partition, base_offset, self._final_state)
|
||||
else:
|
||||
# FAILED --> FAILED and ABORTED --> FAILED transitions are ignored.
|
||||
log.debug("Ignored state transition %s -> %s for %s batch with base offset %s",
|
||||
self._final_state, final_state, self.topic_partition, base_offset)
|
||||
else:
|
||||
# A SUCCESSFUL batch must not attempt another state change.
|
||||
raise Errors.IllegalStateError("A %s batch must not attempt another state change to %s" % (self._final_state, final_state))
|
||||
return False
|
||||
|
||||
def _complete_future(self, base_offset, timestamp_ms, exception):
|
||||
if self.produce_future.is_done:
|
||||
raise Errors.IllegalStateError('Batch is already closed!')
|
||||
elif exception is None:
|
||||
self.produce_future.success((base_offset, timestamp_ms))
|
||||
else:
|
||||
self.produce_future.failure(exception)
|
||||
|
||||
def has_reached_delivery_timeout(self, delivery_timeout_ms, now=None):
|
||||
now = time.time() if now is None else now
|
||||
return delivery_timeout_ms / 1000 <= now - self.created
|
||||
|
||||
def in_retry(self):
|
||||
return self._retry
|
||||
|
||||
def retry(self, now=None):
|
||||
now = time.time() if now is None else now
|
||||
self._retry = True
|
||||
self.attempts += 1
|
||||
self.last_attempt = now
|
||||
self.last_append = now
|
||||
|
||||
@property
|
||||
def is_done(self):
|
||||
return self.produce_future.is_done
|
||||
|
||||
def __str__(self):
|
||||
return 'ProducerBatch(topic_partition=%s, record_count=%d)' % (
|
||||
self.topic_partition, self.records.next_offset())
|
||||
|
||||
|
||||
class RecordAccumulator(object):
|
||||
"""
|
||||
This class maintains a dequeue per TopicPartition that accumulates messages
|
||||
into MessageSets to be sent to the server.
|
||||
|
||||
The accumulator attempts to bound memory use, and append calls will block
|
||||
when that memory is exhausted.
|
||||
|
||||
Keyword Arguments:
|
||||
batch_size (int): Requests sent to brokers will contain multiple
|
||||
batches, one for each partition with data available to be sent.
|
||||
A small batch size will make batching less common and may reduce
|
||||
throughput (a batch size of zero will disable batching entirely).
|
||||
Default: 16384
|
||||
compression_attrs (int): The compression type for all data generated by
|
||||
the producer. Valid values are gzip(1), snappy(2), lz4(3), or
|
||||
none(0).
|
||||
Compression is of full batches of data, so the efficacy of batching
|
||||
will also impact the compression ratio (more batching means better
|
||||
compression). Default: None.
|
||||
linger_ms (int): An artificial delay time to add before declaring a
|
||||
record batch (that isn't full) ready for sending. This allows
|
||||
time for more records to arrive. Setting a non-zero linger_ms
|
||||
will trade off some latency for potentially better throughput
|
||||
due to more batching (and hence fewer, larger requests).
|
||||
Default: 0
|
||||
retry_backoff_ms (int): An artificial delay time to retry the
|
||||
produce request upon receiving an error. This avoids exhausting
|
||||
all retries in a short period of time. Default: 100
|
||||
"""
|
||||
DEFAULT_CONFIG = {
|
||||
'batch_size': 16384,
|
||||
'compression_attrs': 0,
|
||||
'linger_ms': 0,
|
||||
'request_timeout_ms': 30000,
|
||||
'delivery_timeout_ms': 120000,
|
||||
'retry_backoff_ms': 100,
|
||||
'transaction_manager': None,
|
||||
'message_version': 2,
|
||||
}
|
||||
|
||||
def __init__(self, **configs):
|
||||
self.config = copy.copy(self.DEFAULT_CONFIG)
|
||||
for key in self.config:
|
||||
if key in configs:
|
||||
self.config[key] = configs.pop(key)
|
||||
|
||||
self._closed = False
|
||||
self._transaction_manager = self.config['transaction_manager']
|
||||
self._flushes_in_progress = AtomicInteger()
|
||||
self._appends_in_progress = AtomicInteger()
|
||||
self._batches = collections.defaultdict(collections.deque) # TopicPartition: [ProducerBatch]
|
||||
self._tp_locks = {None: threading.Lock()} # TopicPartition: Lock, plus a lock to add entries
|
||||
self._incomplete = IncompleteProducerBatches()
|
||||
# The following variables should only be accessed by the sender thread,
|
||||
# so we don't need to protect them w/ locking.
|
||||
self.muted = set()
|
||||
self._drain_index = 0
|
||||
self._next_batch_expiry_time_ms = float('inf')
|
||||
|
||||
if self.config['delivery_timeout_ms'] < self.config['linger_ms'] + self.config['request_timeout_ms']:
|
||||
raise Errors.KafkaConfigurationError("Must set delivery_timeout_ms higher than linger_ms + request_timeout_ms")
|
||||
|
||||
@property
|
||||
def delivery_timeout_ms(self):
|
||||
return self.config['delivery_timeout_ms']
|
||||
|
||||
@property
|
||||
def next_expiry_time_ms(self):
|
||||
return self._next_batch_expiry_time_ms
|
||||
|
||||
def _tp_lock(self, tp):
|
||||
if tp not in self._tp_locks:
|
||||
with self._tp_locks[None]:
|
||||
if tp not in self._tp_locks:
|
||||
self._tp_locks[tp] = threading.Lock()
|
||||
return self._tp_locks[tp]
|
||||
|
||||
def append(self, tp, timestamp_ms, key, value, headers, now=None):
|
||||
"""Add a record to the accumulator, return the append result.
|
||||
|
||||
The append result will contain the future metadata, and flag for
|
||||
whether the appended batch is full or a new batch is created
|
||||
|
||||
Arguments:
|
||||
tp (TopicPartition): The topic/partition to which this record is
|
||||
being sent
|
||||
timestamp_ms (int): The timestamp of the record (epoch ms)
|
||||
key (bytes): The key for the record
|
||||
value (bytes): The value for the record
|
||||
headers (List[Tuple[str, bytes]]): The header fields for the record
|
||||
|
||||
Returns:
|
||||
tuple: (future, batch_is_full, new_batch_created)
|
||||
"""
|
||||
assert isinstance(tp, TopicPartition), 'not TopicPartition'
|
||||
assert not self._closed, 'RecordAccumulator is closed'
|
||||
now = time.time() if now is None else now
|
||||
# We keep track of the number of appending thread to make sure we do
|
||||
# not miss batches in abortIncompleteBatches().
|
||||
self._appends_in_progress.increment()
|
||||
try:
|
||||
with self._tp_lock(tp):
|
||||
# check if we have an in-progress batch
|
||||
dq = self._batches[tp]
|
||||
if dq:
|
||||
last = dq[-1]
|
||||
future = last.try_append(timestamp_ms, key, value, headers, now=now)
|
||||
if future is not None:
|
||||
batch_is_full = len(dq) > 1 or last.records.is_full()
|
||||
return future, batch_is_full, False
|
||||
|
||||
with self._tp_lock(tp):
|
||||
# Need to check if producer is closed again after grabbing the
|
||||
# dequeue lock.
|
||||
assert not self._closed, 'RecordAccumulator is closed'
|
||||
|
||||
if dq:
|
||||
last = dq[-1]
|
||||
future = last.try_append(timestamp_ms, key, value, headers, now=now)
|
||||
if future is not None:
|
||||
# Somebody else found us a batch, return the one we
|
||||
# waited for! Hopefully this doesn't happen often...
|
||||
batch_is_full = len(dq) > 1 or last.records.is_full()
|
||||
return future, batch_is_full, False
|
||||
|
||||
if self._transaction_manager and self.config['message_version'] < 2:
|
||||
raise Errors.UnsupportedVersionError("Attempting to use idempotence with a broker which"
|
||||
" does not support the required message format (v2)."
|
||||
" The broker must be version 0.11 or later.")
|
||||
records = MemoryRecordsBuilder(
|
||||
self.config['message_version'],
|
||||
self.config['compression_attrs'],
|
||||
self.config['batch_size']
|
||||
)
|
||||
|
||||
batch = ProducerBatch(tp, records, now=now)
|
||||
future = batch.try_append(timestamp_ms, key, value, headers, now=now)
|
||||
if not future:
|
||||
raise Exception()
|
||||
|
||||
dq.append(batch)
|
||||
self._incomplete.add(batch)
|
||||
batch_is_full = len(dq) > 1 or batch.records.is_full()
|
||||
return future, batch_is_full, True
|
||||
finally:
|
||||
self._appends_in_progress.decrement()
|
||||
|
||||
def reset_next_batch_expiry_time(self):
|
||||
self._next_batch_expiry_time_ms = float('inf')
|
||||
|
||||
def maybe_update_next_batch_expiry_time(self, batch):
|
||||
self._next_batch_expiry_time_ms = min(self._next_batch_expiry_time_ms, batch.created * 1000 + self.delivery_timeout_ms)
|
||||
|
||||
def expired_batches(self, now=None):
|
||||
"""Get a list of batches which have been sitting in the accumulator too long and need to be expired."""
|
||||
expired_batches = []
|
||||
for tp in list(self._batches.keys()):
|
||||
with self._tp_lock(tp):
|
||||
# iterate over the batches and expire them if they have stayed
|
||||
# in accumulator for more than request_timeout_ms
|
||||
dq = self._batches[tp]
|
||||
while dq:
|
||||
batch = dq[0]
|
||||
if batch.has_reached_delivery_timeout(self.delivery_timeout_ms, now=now):
|
||||
dq.popleft()
|
||||
batch.records.close()
|
||||
expired_batches.append(batch)
|
||||
else:
|
||||
# Stop at the first batch that has not expired.
|
||||
self.maybe_update_next_batch_expiry_time(batch)
|
||||
break
|
||||
return expired_batches
|
||||
|
||||
def reenqueue(self, batch, now=None):
|
||||
"""
|
||||
Re-enqueue the given record batch in the accumulator. In Sender._complete_batch method, we check
|
||||
whether the batch has reached delivery_timeout_ms or not. Hence we do not do the delivery timeout check here.
|
||||
"""
|
||||
batch.retry(now=now)
|
||||
with self._tp_lock(batch.topic_partition):
|
||||
dq = self._batches[batch.topic_partition]
|
||||
dq.appendleft(batch)
|
||||
|
||||
def ready(self, cluster, now=None):
|
||||
"""
|
||||
Get a list of nodes whose partitions are ready to be sent, and the
|
||||
earliest time at which any non-sendable partition will be ready;
|
||||
Also return the flag for whether there are any unknown leaders for the
|
||||
accumulated partition batches.
|
||||
|
||||
A destination node is ready to send if:
|
||||
|
||||
* There is at least one partition that is not backing off its send
|
||||
* and those partitions are not muted (to prevent reordering if
|
||||
max_in_flight_requests_per_connection is set to 1)
|
||||
* and any of the following are true:
|
||||
|
||||
* The record set is full
|
||||
* The record set has sat in the accumulator for at least linger_ms
|
||||
milliseconds
|
||||
* The accumulator is out of memory and threads are blocking waiting
|
||||
for data (in this case all partitions are immediately considered
|
||||
ready).
|
||||
* The accumulator has been closed
|
||||
|
||||
Arguments:
|
||||
cluster (ClusterMetadata):
|
||||
|
||||
Returns:
|
||||
tuple:
|
||||
ready_nodes (set): node_ids that have ready batches
|
||||
next_ready_check (float): secs until next ready after backoff
|
||||
unknown_leaders_exist (bool): True if metadata refresh needed
|
||||
"""
|
||||
ready_nodes = set()
|
||||
next_ready_check = 9999999.99
|
||||
unknown_leaders_exist = False
|
||||
now = time.time() if now is None else now
|
||||
|
||||
# several threads are accessing self._batches -- to simplify
|
||||
# concurrent access, we iterate over a snapshot of partitions
|
||||
# and lock each partition separately as needed
|
||||
partitions = list(self._batches.keys())
|
||||
for tp in partitions:
|
||||
leader = cluster.leader_for_partition(tp)
|
||||
if leader is None or leader == -1:
|
||||
unknown_leaders_exist = True
|
||||
continue
|
||||
elif leader in ready_nodes:
|
||||
continue
|
||||
elif tp in self.muted:
|
||||
continue
|
||||
|
||||
with self._tp_lock(tp):
|
||||
dq = self._batches[tp]
|
||||
if not dq:
|
||||
continue
|
||||
batch = dq[0]
|
||||
retry_backoff = self.config['retry_backoff_ms'] / 1000
|
||||
linger = self.config['linger_ms'] / 1000
|
||||
backing_off = bool(batch.attempts > 0
|
||||
and (batch.last_attempt + retry_backoff) > now)
|
||||
waited_time = now - batch.last_attempt
|
||||
time_to_wait = retry_backoff if backing_off else linger
|
||||
time_left = max(time_to_wait - waited_time, 0)
|
||||
full = bool(len(dq) > 1 or batch.records.is_full())
|
||||
expired = bool(waited_time >= time_to_wait)
|
||||
|
||||
sendable = (full or expired or self._closed or
|
||||
self.flush_in_progress())
|
||||
|
||||
if sendable and not backing_off:
|
||||
ready_nodes.add(leader)
|
||||
else:
|
||||
# Note that this results in a conservative estimate since
|
||||
# an un-sendable partition may have a leader that will
|
||||
# later be found to have sendable data. However, this is
|
||||
# good enough since we'll just wake up and then sleep again
|
||||
# for the remaining time.
|
||||
next_ready_check = min(time_left, next_ready_check)
|
||||
|
||||
return ready_nodes, next_ready_check, unknown_leaders_exist
|
||||
|
||||
def has_undrained(self):
|
||||
"""Check whether there are any batches which haven't been drained"""
|
||||
for tp in list(self._batches.keys()):
|
||||
with self._tp_lock(tp):
|
||||
dq = self._batches[tp]
|
||||
if len(dq):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _should_stop_drain_batches_for_partition(self, first, tp):
|
||||
if self._transaction_manager:
|
||||
if not self._transaction_manager.is_send_to_partition_allowed(tp):
|
||||
return True
|
||||
if not self._transaction_manager.producer_id_and_epoch.is_valid:
|
||||
# we cannot send the batch until we have refreshed the PID
|
||||
log.debug("Waiting to send ready batches because transaction producer id is not valid")
|
||||
return True
|
||||
return False
|
||||
|
||||
def drain_batches_for_one_node(self, cluster, node_id, max_size, now=None):
|
||||
now = time.time() if now is None else now
|
||||
size = 0
|
||||
ready = []
|
||||
partitions = list(cluster.partitions_for_broker(node_id))
|
||||
if not partitions:
|
||||
return ready
|
||||
# to make starvation less likely this loop doesn't start at 0
|
||||
self._drain_index %= len(partitions)
|
||||
start = None
|
||||
while start != self._drain_index:
|
||||
tp = partitions[self._drain_index]
|
||||
if start is None:
|
||||
start = self._drain_index
|
||||
self._drain_index += 1
|
||||
self._drain_index %= len(partitions)
|
||||
|
||||
# Only proceed if the partition has no in-flight batches.
|
||||
if tp in self.muted:
|
||||
continue
|
||||
|
||||
if tp not in self._batches:
|
||||
continue
|
||||
|
||||
with self._tp_lock(tp):
|
||||
dq = self._batches[tp]
|
||||
if len(dq) == 0:
|
||||
continue
|
||||
first = dq[0]
|
||||
backoff = bool(first.attempts > 0 and
|
||||
first.last_attempt + self.config['retry_backoff_ms'] / 1000 > now)
|
||||
# Only drain the batch if it is not during backoff
|
||||
if backoff:
|
||||
continue
|
||||
|
||||
if (size + first.records.size_in_bytes() > max_size
|
||||
and len(ready) > 0):
|
||||
# there is a rare case that a single batch
|
||||
# size is larger than the request size due
|
||||
# to compression; in this case we will
|
||||
# still eventually send this batch in a
|
||||
# single request
|
||||
break
|
||||
else:
|
||||
if self._should_stop_drain_batches_for_partition(first, tp):
|
||||
break
|
||||
|
||||
batch = dq.popleft()
|
||||
if self._transaction_manager and not batch.in_retry():
|
||||
# If the batch is in retry, then we should not change the pid and
|
||||
# sequence number, since this may introduce duplicates. In particular,
|
||||
# the previous attempt may actually have been accepted, and if we change
|
||||
# the pid and sequence here, this attempt will also be accepted, causing
|
||||
# a duplicate.
|
||||
sequence_number = self._transaction_manager.sequence_number(batch.topic_partition)
|
||||
log.debug("Dest: %s: %s producer_id=%s epoch=%s sequence=%s",
|
||||
node_id, batch.topic_partition,
|
||||
self._transaction_manager.producer_id_and_epoch.producer_id,
|
||||
self._transaction_manager.producer_id_and_epoch.epoch,
|
||||
sequence_number)
|
||||
batch.records.set_producer_state(
|
||||
self._transaction_manager.producer_id_and_epoch.producer_id,
|
||||
self._transaction_manager.producer_id_and_epoch.epoch,
|
||||
sequence_number,
|
||||
self._transaction_manager.is_transactional()
|
||||
)
|
||||
batch.records.close()
|
||||
size += batch.records.size_in_bytes()
|
||||
ready.append(batch)
|
||||
batch.drained = now
|
||||
return ready
|
||||
|
||||
def drain(self, cluster, nodes, max_size, now=None):
|
||||
"""
|
||||
Drain all the data for the given nodes and collate them into a list of
|
||||
batches that will fit within the specified size on a per-node basis.
|
||||
This method attempts to avoid choosing the same topic-node repeatedly.
|
||||
|
||||
Arguments:
|
||||
cluster (ClusterMetadata): The current cluster metadata
|
||||
nodes (list): list of node_ids to drain
|
||||
max_size (int): maximum number of bytes to drain
|
||||
|
||||
Returns:
|
||||
dict: {node_id: list of ProducerBatch} with total size less than the
|
||||
requested max_size.
|
||||
"""
|
||||
if not nodes:
|
||||
return {}
|
||||
|
||||
now = time.time() if now is None else now
|
||||
batches = {}
|
||||
for node_id in nodes:
|
||||
batches[node_id] = self.drain_batches_for_one_node(cluster, node_id, max_size, now=now)
|
||||
return batches
|
||||
|
||||
def deallocate(self, batch):
|
||||
"""Deallocate the record batch."""
|
||||
self._incomplete.remove(batch)
|
||||
|
||||
def flush_in_progress(self):
|
||||
"""Are there any threads currently waiting on a flush?"""
|
||||
return self._flushes_in_progress.get() > 0
|
||||
|
||||
def begin_flush(self):
|
||||
"""
|
||||
Initiate the flushing of data from the accumulator...this makes all
|
||||
requests immediately ready
|
||||
"""
|
||||
self._flushes_in_progress.increment()
|
||||
|
||||
def await_flush_completion(self, timeout=None):
|
||||
"""
|
||||
Mark all partitions as ready to send and block until the send is complete
|
||||
"""
|
||||
try:
|
||||
for batch in self._incomplete.all():
|
||||
log.debug('Waiting on produce to %s',
|
||||
batch.produce_future.topic_partition)
|
||||
if not batch.produce_future.wait(timeout=timeout):
|
||||
raise Errors.KafkaTimeoutError('Timeout waiting for future')
|
||||
if not batch.produce_future.is_done:
|
||||
raise Errors.UnknownError('Future not done')
|
||||
|
||||
if batch.produce_future.failed():
|
||||
log.warning(batch.produce_future.exception)
|
||||
finally:
|
||||
self._flushes_in_progress.decrement()
|
||||
|
||||
@property
|
||||
def has_incomplete(self):
|
||||
return bool(self._incomplete)
|
||||
|
||||
def abort_incomplete_batches(self):
|
||||
"""
|
||||
This function is only called when sender is closed forcefully. It will fail all the
|
||||
incomplete batches and return.
|
||||
"""
|
||||
# We need to keep aborting the incomplete batch until no thread is trying to append to
|
||||
# 1. Avoid losing batches.
|
||||
# 2. Free up memory in case appending threads are blocked on buffer full.
|
||||
# This is a tight loop but should be able to get through very quickly.
|
||||
error = Errors.IllegalStateError("Producer is closed forcefully.")
|
||||
while True:
|
||||
self._abort_batches(error)
|
||||
if not self._appends_in_progress.get():
|
||||
break
|
||||
# After this point, no thread will append any messages because they will see the close
|
||||
# flag set. We need to do the last abort after no thread was appending in case the there was a new
|
||||
# batch appended by the last appending thread.
|
||||
self._abort_batches(error)
|
||||
self._batches.clear()
|
||||
|
||||
def _abort_batches(self, error):
|
||||
"""Go through incomplete batches and abort them."""
|
||||
for batch in self._incomplete.all():
|
||||
tp = batch.topic_partition
|
||||
# Close the batch before aborting
|
||||
with self._tp_lock(tp):
|
||||
batch.records.close()
|
||||
self._batches[tp].remove(batch)
|
||||
batch.abort(error)
|
||||
self.deallocate(batch)
|
||||
|
||||
def abort_undrained_batches(self, error):
|
||||
for batch in self._incomplete.all():
|
||||
tp = batch.topic_partition
|
||||
with self._tp_lock(tp):
|
||||
aborted = False
|
||||
if not batch.is_done:
|
||||
aborted = True
|
||||
batch.records.close()
|
||||
self._batches[tp].remove(batch)
|
||||
if aborted:
|
||||
batch.abort(error)
|
||||
self.deallocate(batch)
|
||||
|
||||
def close(self):
|
||||
"""Close this accumulator and force all the record buffers to be drained."""
|
||||
self._closed = True
|
||||
|
||||
|
||||
class IncompleteProducerBatches(object):
|
||||
"""A threadsafe helper class to hold ProducerBatches that haven't been ack'd yet"""
|
||||
|
||||
def __init__(self):
|
||||
self._incomplete = set()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def add(self, batch):
|
||||
with self._lock:
|
||||
self._incomplete.add(batch)
|
||||
|
||||
def remove(self, batch):
|
||||
with self._lock:
|
||||
try:
|
||||
self._incomplete.remove(batch)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def all(self):
|
||||
with self._lock:
|
||||
return list(self._incomplete)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self._incomplete)
|
||||
|
||||
|
||||
__nonzero__ = __bool__
|
||||
@@ -0,0 +1,763 @@
|
||||
from __future__ import absolute_import, division
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import heapq
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
from kafka import errors as Errors
|
||||
from kafka.metrics.measurable import AnonMeasurable
|
||||
from kafka.metrics.stats import Avg, Max, Rate
|
||||
from kafka.producer.transaction_manager import ProducerIdAndEpoch
|
||||
from kafka.protocol.init_producer_id import InitProducerIdRequest
|
||||
from kafka.protocol.produce import ProduceRequest
|
||||
from kafka.structs import TopicPartition
|
||||
from kafka.version import __version__
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Sender(threading.Thread):
|
||||
"""
|
||||
The background thread that handles the sending of produce requests to the
|
||||
Kafka cluster. This thread makes metadata requests to renew its view of the
|
||||
cluster and then sends produce requests to the appropriate nodes.
|
||||
"""
|
||||
DEFAULT_CONFIG = {
|
||||
'max_request_size': 1048576,
|
||||
'acks': 1,
|
||||
'retries': float('inf'),
|
||||
'request_timeout_ms': 30000,
|
||||
'retry_backoff_ms': 100,
|
||||
'metrics': None,
|
||||
'guarantee_message_order': False,
|
||||
'transaction_manager': None,
|
||||
'transactional_id': None,
|
||||
'transaction_timeout_ms': 60000,
|
||||
'client_id': 'kafka-python-' + __version__,
|
||||
}
|
||||
|
||||
def __init__(self, client, metadata, accumulator, **configs):
|
||||
super(Sender, self).__init__()
|
||||
self.config = copy.copy(self.DEFAULT_CONFIG)
|
||||
for key in self.config:
|
||||
if key in configs:
|
||||
self.config[key] = configs.pop(key)
|
||||
|
||||
self.name = self.config['client_id'] + '-network-thread'
|
||||
self._client = client
|
||||
self._accumulator = accumulator
|
||||
self._metadata = client.cluster
|
||||
self._running = True
|
||||
self._force_close = False
|
||||
self._topics_to_add = set()
|
||||
if self.config['metrics']:
|
||||
self._sensors = SenderMetrics(self.config['metrics'], self._client, self._metadata)
|
||||
else:
|
||||
self._sensors = None
|
||||
self._transaction_manager = self.config['transaction_manager']
|
||||
# A per-partition queue of batches ordered by creation time for tracking the in-flight batches
|
||||
self._in_flight_batches = collections.defaultdict(list)
|
||||
|
||||
def _maybe_remove_from_inflight_batches(self, batch):
|
||||
try:
|
||||
queue = self._in_flight_batches[batch.topic_partition]
|
||||
except KeyError:
|
||||
return
|
||||
try:
|
||||
idx = queue.index((batch.created, batch))
|
||||
except ValueError:
|
||||
return
|
||||
# https://stackoverflow.com/questions/10162679/python-delete-element-from-heap
|
||||
queue[idx] = queue[-1]
|
||||
queue.pop()
|
||||
heapq.heapify(queue)
|
||||
|
||||
def _get_expired_inflight_batches(self, now=None):
|
||||
"""Get the in-flight batches that has reached delivery timeout."""
|
||||
expired_batches = []
|
||||
to_remove = []
|
||||
for tp, queue in six.iteritems(self._in_flight_batches):
|
||||
while queue:
|
||||
_created_at, batch = queue[0]
|
||||
if batch.has_reached_delivery_timeout(self._accumulator.delivery_timeout_ms):
|
||||
heapq.heappop(queue)
|
||||
if batch.final_state is None:
|
||||
expired_batches.append(batch)
|
||||
else:
|
||||
raise Errors.IllegalStateError("%s batch created at %s gets unexpected final state %s" % (batch.topic_partition, batch.created, batch.final_state))
|
||||
else:
|
||||
self._accumulator.maybe_update_next_batch_expiry_time(batch)
|
||||
break
|
||||
else:
|
||||
# Avoid mutating in_flight_batches during iteration
|
||||
to_remove.append(tp)
|
||||
for tp in to_remove:
|
||||
del self._in_flight_batches[tp]
|
||||
return expired_batches
|
||||
|
||||
def run(self):
|
||||
"""The main run loop for the sender thread."""
|
||||
log.debug("%s: Starting Kafka producer I/O thread.", str(self))
|
||||
|
||||
# main loop, runs until close is called
|
||||
while self._running:
|
||||
try:
|
||||
self.run_once()
|
||||
except Exception:
|
||||
log.exception("%s: Uncaught error in kafka producer I/O thread", str(self))
|
||||
|
||||
log.debug("%s: Beginning shutdown of Kafka producer I/O thread, sending"
|
||||
" remaining records.", str(self))
|
||||
|
||||
# okay we stopped accepting requests but there may still be
|
||||
# requests in the accumulator or waiting for acknowledgment,
|
||||
# wait until these are completed.
|
||||
while (not self._force_close
|
||||
and (self._accumulator.has_undrained()
|
||||
or self._client.in_flight_request_count() > 0)):
|
||||
try:
|
||||
self.run_once()
|
||||
except Exception:
|
||||
log.exception("%s: Uncaught error in kafka producer I/O thread", str(self))
|
||||
|
||||
if self._force_close:
|
||||
# We need to fail all the incomplete batches and wake up the
|
||||
# threads waiting on the futures.
|
||||
self._accumulator.abort_incomplete_batches()
|
||||
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
log.exception("%s: Failed to close network client", str(self))
|
||||
|
||||
log.debug("%s: Shutdown of Kafka producer I/O thread has completed.", str(self))
|
||||
|
||||
def run_once(self):
|
||||
"""Run a single iteration of sending."""
|
||||
while self._topics_to_add:
|
||||
self._client.add_topic(self._topics_to_add.pop())
|
||||
|
||||
if self._transaction_manager:
|
||||
try:
|
||||
if not self._transaction_manager.is_transactional():
|
||||
# this is an idempotent producer, so make sure we have a producer id
|
||||
self._maybe_wait_for_producer_id()
|
||||
elif self._transaction_manager.has_in_flight_transactional_request() or self._maybe_send_transactional_request():
|
||||
# as long as there are outstanding transactional requests, we simply wait for them to return
|
||||
self._client.poll(timeout_ms=self.config['retry_backoff_ms'])
|
||||
return
|
||||
|
||||
# do not continue sending if the transaction manager is in a failed state or if there
|
||||
# is no producer id (for the idempotent case).
|
||||
if self._transaction_manager.has_fatal_error() or not self._transaction_manager.has_producer_id():
|
||||
last_error = self._transaction_manager.last_error
|
||||
if last_error is not None:
|
||||
self._maybe_abort_batches(last_error)
|
||||
self._client.poll(timeout_ms=self.config['retry_backoff_ms'])
|
||||
return
|
||||
elif self._transaction_manager.has_abortable_error():
|
||||
self._accumulator.abort_undrained_batches(self._transaction_manager.last_error)
|
||||
|
||||
except Errors.SaslAuthenticationFailedError as e:
|
||||
# This is already logged as error, but propagated here to perform any clean ups.
|
||||
log.debug("%s: Authentication exception while processing transactional request: %s", str(self), e)
|
||||
self._transaction_manager.authentication_failed(e)
|
||||
|
||||
poll_timeout_ms = self._send_producer_data()
|
||||
self._client.poll(timeout_ms=poll_timeout_ms)
|
||||
|
||||
def _send_producer_data(self, now=None):
|
||||
now = time.time() if now is None else now
|
||||
# get the list of partitions with data ready to send
|
||||
result = self._accumulator.ready(self._metadata, now=now)
|
||||
ready_nodes, next_ready_check_delay, unknown_leaders_exist = result
|
||||
|
||||
# if there are any partitions whose leaders are not known yet, force
|
||||
# metadata update
|
||||
if unknown_leaders_exist:
|
||||
log.debug('%s: Unknown leaders exist, requesting metadata update', str(self))
|
||||
self._metadata.request_update()
|
||||
|
||||
# remove any nodes we aren't ready to send to
|
||||
not_ready_timeout_ms = float('inf')
|
||||
for node in list(ready_nodes):
|
||||
if not self._client.is_ready(node):
|
||||
node_delay_ms = self._client.connection_delay(node)
|
||||
log.debug('%s: Node %s not ready; delaying produce of accumulated batch (%f ms)', str(self), node, node_delay_ms)
|
||||
self._client.maybe_connect(node, wakeup=False)
|
||||
ready_nodes.remove(node)
|
||||
not_ready_timeout_ms = min(not_ready_timeout_ms, node_delay_ms)
|
||||
|
||||
# create produce requests
|
||||
batches_by_node = self._accumulator.drain(
|
||||
self._metadata, ready_nodes, self.config['max_request_size'], now=now)
|
||||
|
||||
for batch_list in six.itervalues(batches_by_node):
|
||||
for batch in batch_list:
|
||||
item = (batch.created, batch)
|
||||
queue = self._in_flight_batches[batch.topic_partition]
|
||||
heapq.heappush(queue, item)
|
||||
|
||||
if self.config['guarantee_message_order']:
|
||||
# Mute all the partitions drained
|
||||
for batch_list in six.itervalues(batches_by_node):
|
||||
for batch in batch_list:
|
||||
self._accumulator.muted.add(batch.topic_partition)
|
||||
|
||||
self._accumulator.reset_next_batch_expiry_time()
|
||||
expired_batches = self._accumulator.expired_batches(now=now)
|
||||
expired_batches.extend(self._get_expired_inflight_batches(now=now))
|
||||
|
||||
if expired_batches:
|
||||
log.debug("%s: Expired %s batches in accumulator", str(self), len(expired_batches))
|
||||
|
||||
# Reset the producer_id if an expired batch has previously been sent to the broker.
|
||||
# See the documentation of `TransactionState.reset_producer_id` to understand why
|
||||
# we need to reset the producer id here.
|
||||
if self._transaction_manager and any([batch.in_retry() for batch in expired_batches]):
|
||||
needs_transaction_state_reset = True
|
||||
else:
|
||||
needs_transaction_state_reset = False
|
||||
|
||||
for expired_batch in expired_batches:
|
||||
error = Errors.KafkaTimeoutError(
|
||||
"Expiring %d record(s) for %s: %s ms has passed since batch creation" % (
|
||||
expired_batch.record_count, expired_batch.topic_partition,
|
||||
int((time.time() - expired_batch.created) * 1000)))
|
||||
self._fail_batch(expired_batch, error, base_offset=-1)
|
||||
|
||||
if self._sensors:
|
||||
self._sensors.update_produce_request_metrics(batches_by_node)
|
||||
|
||||
if needs_transaction_state_reset:
|
||||
self._transaction_manager.reset_producer_id()
|
||||
return 0
|
||||
|
||||
requests = self._create_produce_requests(batches_by_node)
|
||||
# If we have any nodes that are ready to send + have sendable data,
|
||||
# poll with 0 timeout so this can immediately loop and try sending more
|
||||
# data. Otherwise, the timeout will be the smaller value between next
|
||||
# batch expiry time, and the delay time for checking data availability.
|
||||
# Note that the nodes may have data that isn't yet sendable due to
|
||||
# lingering, backing off, etc. This specifically does not include nodes with
|
||||
# sendable data that aren't ready to send since they would cause busy
|
||||
# looping.
|
||||
poll_timeout_ms = min(next_ready_check_delay * 1000,
|
||||
not_ready_timeout_ms,
|
||||
self._accumulator.next_expiry_time_ms - now * 1000)
|
||||
if poll_timeout_ms < 0:
|
||||
poll_timeout_ms = 0
|
||||
|
||||
if ready_nodes:
|
||||
log.debug("%s: Nodes with data ready to send: %s", str(self), ready_nodes) # trace
|
||||
log.debug("%s: Created %d produce requests: %s", str(self), len(requests), requests) # trace
|
||||
# if some partitions are already ready to be sent, the select time
|
||||
# would be 0; otherwise if some partition already has some data
|
||||
# accumulated but not ready yet, the select time will be the time
|
||||
# difference between now and its linger expiry time; otherwise the
|
||||
# select time will be the time difference between now and the
|
||||
# metadata expiry time
|
||||
poll_timeout_ms = 0
|
||||
|
||||
for node_id, request in six.iteritems(requests):
|
||||
batches = batches_by_node[node_id]
|
||||
log.debug('%s: Sending Produce Request: %r', str(self), request)
|
||||
(self._client.send(node_id, request, wakeup=False)
|
||||
.add_callback(
|
||||
self._handle_produce_response, node_id, time.time(), batches)
|
||||
.add_errback(
|
||||
self._failed_produce, batches, node_id))
|
||||
return poll_timeout_ms
|
||||
|
||||
def _maybe_send_transactional_request(self):
|
||||
if self._transaction_manager.is_completing() and self._accumulator.has_incomplete:
|
||||
if self._transaction_manager.is_aborting():
|
||||
self._accumulator.abort_undrained_batches(Errors.KafkaError("Failing batch since transaction was aborted"))
|
||||
# There may still be requests left which are being retried. Since we do not know whether they had
|
||||
# been successfully appended to the broker log, we must resend them until their final status is clear.
|
||||
# If they had been appended and we did not receive the error, then our sequence number would no longer
|
||||
# be correct which would lead to an OutOfSequenceNumberError.
|
||||
if not self._accumulator.flush_in_progress():
|
||||
self._accumulator.begin_flush()
|
||||
|
||||
next_request_handler = self._transaction_manager.next_request_handler(self._accumulator.has_incomplete)
|
||||
if next_request_handler is None:
|
||||
return False
|
||||
|
||||
log.debug("%s: Sending transactional request %s", str(self), next_request_handler.request)
|
||||
while not self._force_close:
|
||||
target_node = None
|
||||
try:
|
||||
if next_request_handler.needs_coordinator():
|
||||
target_node = self._transaction_manager.coordinator(next_request_handler.coordinator_type)
|
||||
if target_node is None:
|
||||
self._transaction_manager.lookup_coordinator_for_request(next_request_handler)
|
||||
break
|
||||
elif not self._client.await_ready(target_node, timeout_ms=self.config['request_timeout_ms']):
|
||||
self._transaction_manager.lookup_coordinator_for_request(next_request_handler)
|
||||
target_node = None
|
||||
break
|
||||
else:
|
||||
target_node = self._client.least_loaded_node()
|
||||
if target_node is not None and not self._client.await_ready(target_node, timeout_ms=self.config['request_timeout_ms']):
|
||||
target_node = None
|
||||
|
||||
if target_node is not None:
|
||||
if next_request_handler.is_retry:
|
||||
time.sleep(self.config['retry_backoff_ms'] / 1000)
|
||||
txn_correlation_id = self._transaction_manager.next_in_flight_request_correlation_id()
|
||||
future = self._client.send(target_node, next_request_handler.request)
|
||||
future.add_both(next_request_handler.on_complete, txn_correlation_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.warn("%s: Got an exception when trying to find a node to send a transactional request to. Going to back off and retry: %s", str(self), e)
|
||||
if next_request_handler.needs_coordinator():
|
||||
self._transaction_manager.lookup_coordinator_for_request(next_request_handler)
|
||||
break
|
||||
|
||||
time.sleep(self.config['retry_backoff_ms'] / 1000)
|
||||
self._metadata.request_update()
|
||||
|
||||
if target_node is None:
|
||||
self._transaction_manager.retry(next_request_handler)
|
||||
|
||||
return True
|
||||
|
||||
def _maybe_abort_batches(self, exc):
|
||||
if self._accumulator.has_incomplete:
|
||||
log.error("%s: Aborting producer batches due to fatal error: %s", str(self), exc)
|
||||
self._accumulator.abort_batches(exc)
|
||||
|
||||
def initiate_close(self):
|
||||
"""Start closing the sender (won't complete until all data is sent)."""
|
||||
self._running = False
|
||||
self._accumulator.close()
|
||||
self.wakeup()
|
||||
|
||||
def force_close(self):
|
||||
"""Closes the sender without sending out any pending messages."""
|
||||
self._force_close = True
|
||||
self.initiate_close()
|
||||
|
||||
def add_topic(self, topic):
|
||||
# This is generally called from a separate thread
|
||||
# so this needs to be a thread-safe operation
|
||||
# we assume that checking set membership across threads
|
||||
# is ok where self._client._topics should never
|
||||
# remove topics for a producer instance, only add them.
|
||||
if topic not in self._client._topics:
|
||||
self._topics_to_add.add(topic)
|
||||
self.wakeup()
|
||||
|
||||
def _maybe_wait_for_producer_id(self):
|
||||
while not self._transaction_manager.has_producer_id():
|
||||
try:
|
||||
node_id = self._client.least_loaded_node()
|
||||
if node_id is None or not self._client.await_ready(node_id):
|
||||
log.debug("%s, Could not find an available broker to send InitProducerIdRequest to." +
|
||||
" Will back off and try again.", str(self))
|
||||
time.sleep(self._client.least_loaded_node_refresh_ms() / 1000)
|
||||
continue
|
||||
version = self._client.api_version(InitProducerIdRequest, max_version=1)
|
||||
request = InitProducerIdRequest[version](
|
||||
transactional_id=self.config['transactional_id'],
|
||||
transaction_timeout_ms=self.config['transaction_timeout_ms'],
|
||||
)
|
||||
response = self._client.send_and_receive(node_id, request)
|
||||
error_type = Errors.for_code(response.error_code)
|
||||
if error_type is Errors.NoError:
|
||||
self._transaction_manager.set_producer_id_and_epoch(ProducerIdAndEpoch(response.producer_id, response.producer_epoch))
|
||||
break
|
||||
elif getattr(error_type, 'retriable', False):
|
||||
log.debug("%s: Retriable error from InitProducerId response: %s", str(self), error_type.__name__)
|
||||
if getattr(error_type, 'invalid_metadata', False):
|
||||
self._metadata.request_update()
|
||||
else:
|
||||
self._transaction_manager.transition_to_fatal_error(error_type())
|
||||
break
|
||||
except Errors.KafkaConnectionError:
|
||||
log.debug("%s: Broker %s disconnected while awaiting InitProducerId response", str(self), node_id)
|
||||
except Errors.RequestTimedOutError:
|
||||
log.debug("%s: InitProducerId request to node %s timed out", str(self), node_id)
|
||||
log.debug("%s: Retry InitProducerIdRequest in %sms.", str(self), self.config['retry_backoff_ms'])
|
||||
time.sleep(self.config['retry_backoff_ms'] / 1000)
|
||||
|
||||
def _failed_produce(self, batches, node_id, error):
|
||||
log.error("%s: Error sending produce request to node %d: %s", str(self), node_id, error) # trace
|
||||
for batch in batches:
|
||||
self._complete_batch(batch, error, -1)
|
||||
|
||||
def _handle_produce_response(self, node_id, send_time, batches, response):
|
||||
"""Handle a produce response."""
|
||||
# if we have a response, parse it
|
||||
log.debug('%s: Parsing produce response: %r', str(self), response)
|
||||
if response:
|
||||
batches_by_partition = dict([(batch.topic_partition, batch)
|
||||
for batch in batches])
|
||||
|
||||
for topic, partitions in response.topics:
|
||||
for partition_info in partitions:
|
||||
if response.API_VERSION < 2:
|
||||
partition, error_code, offset = partition_info
|
||||
ts = None
|
||||
elif 2 <= response.API_VERSION <= 4:
|
||||
partition, error_code, offset, ts = partition_info
|
||||
elif 5 <= response.API_VERSION <= 7:
|
||||
partition, error_code, offset, ts, _log_start_offset = partition_info
|
||||
else:
|
||||
# Currently unused / TODO: KIP-467
|
||||
partition, error_code, offset, ts, _log_start_offset, _record_errors, _global_error = partition_info
|
||||
tp = TopicPartition(topic, partition)
|
||||
error = Errors.for_code(error_code)
|
||||
batch = batches_by_partition[tp]
|
||||
self._complete_batch(batch, error, offset, timestamp_ms=ts)
|
||||
|
||||
else:
|
||||
# this is the acks = 0 case, just complete all requests
|
||||
for batch in batches:
|
||||
self._complete_batch(batch, None, -1)
|
||||
|
||||
def _fail_batch(self, batch, exception, base_offset=None, timestamp_ms=None):
|
||||
exception = exception if type(exception) is not type else exception()
|
||||
if self._transaction_manager:
|
||||
if isinstance(exception, Errors.OutOfOrderSequenceNumberError) and \
|
||||
not self._transaction_manager.is_transactional() and \
|
||||
self._transaction_manager.has_producer_id(batch.producer_id):
|
||||
log.error("%s: The broker received an out of order sequence number for topic-partition %s"
|
||||
" at offset %s. This indicates data loss on the broker, and should be investigated.",
|
||||
str(self), batch.topic_partition, base_offset)
|
||||
|
||||
# Reset the transaction state since we have hit an irrecoverable exception and cannot make any guarantees
|
||||
# about the previously committed message. Note that this will discard the producer id and sequence
|
||||
# numbers for all existing partitions.
|
||||
self._transaction_manager.reset_producer_id()
|
||||
elif isinstance(exception, (Errors.ClusterAuthorizationFailedError,
|
||||
Errors.TransactionalIdAuthorizationFailedError,
|
||||
Errors.ProducerFencedError,
|
||||
Errors.InvalidTxnStateError)):
|
||||
self._transaction_manager.transition_to_fatal_error(exception)
|
||||
elif self._transaction_manager.is_transactional():
|
||||
self._transaction_manager.transition_to_abortable_error(exception)
|
||||
|
||||
if self._sensors:
|
||||
self._sensors.record_errors(batch.topic_partition.topic, batch.record_count)
|
||||
|
||||
if batch.done(base_offset=base_offset, timestamp_ms=timestamp_ms, exception=exception):
|
||||
self._maybe_remove_from_inflight_batches(batch)
|
||||
self._accumulator.deallocate(batch)
|
||||
|
||||
def _complete_batch(self, batch, error, base_offset, timestamp_ms=None):
|
||||
"""Complete or retry the given batch of records.
|
||||
|
||||
Arguments:
|
||||
batch (ProducerBatch): The record batch
|
||||
error (Exception): The error (or None if none)
|
||||
base_offset (int): The base offset assigned to the records if successful
|
||||
timestamp_ms (int, optional): The timestamp returned by the broker for this batch
|
||||
"""
|
||||
# Standardize no-error to None
|
||||
if error is Errors.NoError:
|
||||
error = None
|
||||
|
||||
if error is not None:
|
||||
if self._can_retry(batch, error):
|
||||
# retry
|
||||
log.warning("%s: Got error produce response on topic-partition %s,"
|
||||
" retrying (%s attempts left). Error: %s",
|
||||
str(self), batch.topic_partition,
|
||||
self.config['retries'] - batch.attempts - 1,
|
||||
error)
|
||||
|
||||
# If idempotence is enabled only retry the request if the batch matches our current producer id and epoch
|
||||
if not self._transaction_manager or self._transaction_manager.producer_id_and_epoch.match(batch):
|
||||
log.debug("%s: Retrying batch to topic-partition %s. Sequence number: %s",
|
||||
str(self), batch.topic_partition,
|
||||
self._transaction_manager.sequence_number(batch.topic_partition) if self._transaction_manager else None)
|
||||
self._accumulator.reenqueue(batch)
|
||||
self._maybe_remove_from_inflight_batches(batch)
|
||||
if self._sensors:
|
||||
self._sensors.record_retries(batch.topic_partition.topic, batch.record_count)
|
||||
else:
|
||||
log.warning("%s: Attempted to retry sending a batch but the producer id/epoch changed from %s/%s to %s/%s. This batch will be dropped",
|
||||
str(self), batch.producer_id, batch.producer_epoch,
|
||||
self._transaction_manager.producer_id_and_epoch.producer_id,
|
||||
self._transaction_manager.producer_id_and_epoch.epoch)
|
||||
self._fail_batch(batch, error, base_offset=base_offset, timestamp_ms=timestamp_ms)
|
||||
else:
|
||||
if error is Errors.TopicAuthorizationFailedError:
|
||||
error = error(batch.topic_partition.topic)
|
||||
|
||||
# tell the user the result of their request
|
||||
self._fail_batch(batch, error, base_offset=base_offset, timestamp_ms=timestamp_ms)
|
||||
|
||||
if error is Errors.UnknownTopicOrPartitionError:
|
||||
log.warning("%s: Received unknown topic or partition error in produce request on partition %s."
|
||||
" The topic/partition may not exist or the user may not have Describe access to it",
|
||||
str(self), batch.topic_partition)
|
||||
|
||||
if getattr(error, 'invalid_metadata', False):
|
||||
self._metadata.request_update()
|
||||
|
||||
else:
|
||||
if batch.done(base_offset=base_offset, timestamp_ms=timestamp_ms):
|
||||
self._maybe_remove_from_inflight_batches(batch)
|
||||
self._accumulator.deallocate(batch)
|
||||
|
||||
if self._transaction_manager and self._transaction_manager.producer_id_and_epoch.match(batch):
|
||||
self._transaction_manager.increment_sequence_number(batch.topic_partition, batch.record_count)
|
||||
log.debug("%s: Incremented sequence number for topic-partition %s to %s", str(self), batch.topic_partition,
|
||||
self._transaction_manager.sequence_number(batch.topic_partition))
|
||||
|
||||
# Unmute the completed partition.
|
||||
if self.config['guarantee_message_order']:
|
||||
self._accumulator.muted.remove(batch.topic_partition)
|
||||
|
||||
def _can_retry(self, batch, error):
|
||||
"""
|
||||
We can retry a send if the error is transient and the number of
|
||||
attempts taken is fewer than the maximum allowed
|
||||
"""
|
||||
return (not batch.has_reached_delivery_timeout(self._accumulator.delivery_timeout_ms) and
|
||||
batch.attempts < self.config['retries'] and
|
||||
batch.final_state is None and
|
||||
getattr(error, 'retriable', False))
|
||||
|
||||
def _create_produce_requests(self, collated):
|
||||
"""
|
||||
Transfer the record batches into a list of produce requests on a
|
||||
per-node basis.
|
||||
|
||||
Arguments:
|
||||
collated: {node_id: [ProducerBatch]}
|
||||
|
||||
Returns:
|
||||
dict: {node_id: ProduceRequest} (version depends on client api_versions)
|
||||
"""
|
||||
requests = {}
|
||||
for node_id, batches in six.iteritems(collated):
|
||||
if batches:
|
||||
requests[node_id] = self._produce_request(
|
||||
node_id, self.config['acks'],
|
||||
self.config['request_timeout_ms'], batches)
|
||||
return requests
|
||||
|
||||
def _produce_request(self, node_id, acks, timeout, batches):
|
||||
"""Create a produce request from the given record batches.
|
||||
|
||||
Returns:
|
||||
ProduceRequest (version depends on client api_versions)
|
||||
"""
|
||||
produce_records_by_partition = collections.defaultdict(dict)
|
||||
for batch in batches:
|
||||
topic = batch.topic_partition.topic
|
||||
partition = batch.topic_partition.partition
|
||||
|
||||
buf = batch.records.buffer()
|
||||
produce_records_by_partition[topic][partition] = buf
|
||||
|
||||
version = self._client.api_version(ProduceRequest, max_version=7)
|
||||
topic_partition_data = [
|
||||
(topic, list(partition_info.items()))
|
||||
for topic, partition_info in six.iteritems(produce_records_by_partition)]
|
||||
transactional_id = self._transaction_manager.transactional_id if self._transaction_manager else None
|
||||
if version >= 3:
|
||||
return ProduceRequest[version](
|
||||
transactional_id=transactional_id,
|
||||
required_acks=acks,
|
||||
timeout=timeout,
|
||||
topics=topic_partition_data,
|
||||
)
|
||||
else:
|
||||
if transactional_id is not None:
|
||||
log.warning('%s: Broker does not support ProduceRequest v3+, required for transactional_id', str(self))
|
||||
return ProduceRequest[version](
|
||||
required_acks=acks,
|
||||
timeout=timeout,
|
||||
topics=topic_partition_data,
|
||||
)
|
||||
|
||||
def wakeup(self):
|
||||
"""Wake up the selector associated with this send thread."""
|
||||
self._client.wakeup()
|
||||
|
||||
def bootstrap_connected(self):
|
||||
return self._client.bootstrap_connected()
|
||||
|
||||
def __str__(self):
|
||||
return "<Sender client_id=%s transactional_id=%s>" % (self.config['client_id'], self.config['transactional_id'])
|
||||
|
||||
|
||||
class SenderMetrics(object):
|
||||
|
||||
def __init__(self, metrics, client, metadata):
|
||||
self.metrics = metrics
|
||||
self._client = client
|
||||
self._metadata = metadata
|
||||
|
||||
sensor_name = 'batch-size'
|
||||
self.batch_size_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('batch-size-avg', Avg(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average number of bytes sent per partition per-request.')
|
||||
self.add_metric('batch-size-max', Max(),
|
||||
sensor_name=sensor_name,
|
||||
description='The max number of bytes sent per partition per-request.')
|
||||
|
||||
sensor_name = 'compression-rate'
|
||||
self.compression_rate_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('compression-rate-avg', Avg(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average compression rate of record batches.')
|
||||
|
||||
sensor_name = 'queue-time'
|
||||
self.queue_time_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('record-queue-time-avg', Avg(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average time in ms record batches spent in the record accumulator.')
|
||||
self.add_metric('record-queue-time-max', Max(),
|
||||
sensor_name=sensor_name,
|
||||
description='The maximum time in ms record batches spent in the record accumulator.')
|
||||
|
||||
sensor_name = 'records-per-request'
|
||||
self.records_per_request_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('record-send-rate', Rate(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average number of records sent per second.')
|
||||
self.add_metric('records-per-request-avg', Avg(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average number of records per request.')
|
||||
|
||||
sensor_name = 'bytes'
|
||||
self.byte_rate_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('byte-rate', Rate(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average number of bytes sent per second.')
|
||||
|
||||
sensor_name = 'record-retries'
|
||||
self.retry_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('record-retry-rate', Rate(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average per-second number of retried record sends')
|
||||
|
||||
sensor_name = 'errors'
|
||||
self.error_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('record-error-rate', Rate(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average per-second number of record sends that resulted in errors')
|
||||
|
||||
sensor_name = 'record-size-max'
|
||||
self.max_record_size_sensor = self.metrics.sensor(sensor_name)
|
||||
self.add_metric('record-size-max', Max(),
|
||||
sensor_name=sensor_name,
|
||||
description='The maximum record size across all batches')
|
||||
self.add_metric('record-size-avg', Avg(),
|
||||
sensor_name=sensor_name,
|
||||
description='The average maximum record size per batch')
|
||||
|
||||
self.add_metric('requests-in-flight',
|
||||
AnonMeasurable(lambda *_: self._client.in_flight_request_count()),
|
||||
description='The current number of in-flight requests awaiting a response.')
|
||||
|
||||
self.add_metric('metadata-age',
|
||||
AnonMeasurable(lambda _, now: (now - self._metadata._last_successful_refresh_ms) / 1000),
|
||||
description='The age in seconds of the current producer metadata being used.')
|
||||
|
||||
def add_metric(self, metric_name, measurable, group_name='producer-metrics',
|
||||
description=None, tags=None,
|
||||
sensor_name=None):
|
||||
m = self.metrics
|
||||
metric = m.metric_name(metric_name, group_name, description, tags)
|
||||
if sensor_name:
|
||||
sensor = m.sensor(sensor_name)
|
||||
sensor.add(metric, measurable)
|
||||
else:
|
||||
m.add_metric(metric, measurable)
|
||||
|
||||
def maybe_register_topic_metrics(self, topic):
|
||||
|
||||
def sensor_name(name):
|
||||
return 'topic.{0}.{1}'.format(topic, name)
|
||||
|
||||
# if one sensor of the metrics has been registered for the topic,
|
||||
# then all other sensors should have been registered; and vice versa
|
||||
if not self.metrics.get_sensor(sensor_name('records-per-batch')):
|
||||
|
||||
self.add_metric('record-send-rate', Rate(),
|
||||
sensor_name=sensor_name('records-per-batch'),
|
||||
group_name='producer-topic-metrics.' + topic,
|
||||
description= 'Records sent per second for topic ' + topic)
|
||||
|
||||
self.add_metric('byte-rate', Rate(),
|
||||
sensor_name=sensor_name('bytes'),
|
||||
group_name='producer-topic-metrics.' + topic,
|
||||
description='Bytes per second for topic ' + topic)
|
||||
|
||||
self.add_metric('compression-rate', Avg(),
|
||||
sensor_name=sensor_name('compression-rate'),
|
||||
group_name='producer-topic-metrics.' + topic,
|
||||
description='Average Compression ratio for topic ' + topic)
|
||||
|
||||
self.add_metric('record-retry-rate', Rate(),
|
||||
sensor_name=sensor_name('record-retries'),
|
||||
group_name='producer-topic-metrics.' + topic,
|
||||
description='Record retries per second for topic ' + topic)
|
||||
|
||||
self.add_metric('record-error-rate', Rate(),
|
||||
sensor_name=sensor_name('record-errors'),
|
||||
group_name='producer-topic-metrics.' + topic,
|
||||
description='Record errors per second for topic ' + topic)
|
||||
|
||||
def update_produce_request_metrics(self, batches_map):
|
||||
for node_batch in batches_map.values():
|
||||
records = 0
|
||||
total_bytes = 0
|
||||
for batch in node_batch:
|
||||
# register all per-topic metrics at once
|
||||
topic = batch.topic_partition.topic
|
||||
self.maybe_register_topic_metrics(topic)
|
||||
|
||||
# per-topic record send rate
|
||||
topic_records_count = self.metrics.get_sensor(
|
||||
'topic.' + topic + '.records-per-batch')
|
||||
topic_records_count.record(batch.record_count)
|
||||
|
||||
# per-topic bytes send rate
|
||||
topic_byte_rate = self.metrics.get_sensor(
|
||||
'topic.' + topic + '.bytes')
|
||||
topic_byte_rate.record(batch.records.size_in_bytes())
|
||||
|
||||
# per-topic compression rate
|
||||
topic_compression_rate = self.metrics.get_sensor(
|
||||
'topic.' + topic + '.compression-rate')
|
||||
topic_compression_rate.record(batch.records.compression_rate())
|
||||
|
||||
# global metrics
|
||||
self.batch_size_sensor.record(batch.records.size_in_bytes())
|
||||
if batch.drained:
|
||||
self.queue_time_sensor.record(batch.drained - batch.created)
|
||||
self.compression_rate_sensor.record(batch.records.compression_rate())
|
||||
self.max_record_size_sensor.record(batch.max_record_size)
|
||||
records += batch.record_count
|
||||
total_bytes += batch.records.size_in_bytes()
|
||||
|
||||
if node_batch:
|
||||
self.records_per_request_sensor.record(records)
|
||||
self.byte_rate_sensor.record(total_bytes)
|
||||
|
||||
def record_retries(self, topic, count):
|
||||
self.retry_sensor.record(count)
|
||||
sensor = self.metrics.get_sensor('topic.' + topic + '.record-retries')
|
||||
if sensor:
|
||||
sensor.record(count)
|
||||
|
||||
def record_errors(self, topic, count):
|
||||
self.error_sensor.record(count)
|
||||
sensor = self.metrics.get_sensor('topic.' + topic + '.record-errors')
|
||||
if sensor:
|
||||
sensor.record(count)
|
||||
@@ -0,0 +1,981 @@
|
||||
from __future__ import absolute_import, division
|
||||
|
||||
import abc
|
||||
import collections
|
||||
import heapq
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from kafka.vendor import six
|
||||
|
||||
try:
|
||||
# enum in stdlib as of py3.4
|
||||
from enum import IntEnum # pylint: disable=import-error
|
||||
except ImportError:
|
||||
# vendored backport module
|
||||
from kafka.vendor.enum34 import IntEnum
|
||||
|
||||
import kafka.errors as Errors
|
||||
from kafka.protocol.add_offsets_to_txn import AddOffsetsToTxnRequest
|
||||
from kafka.protocol.add_partitions_to_txn import AddPartitionsToTxnRequest
|
||||
from kafka.protocol.end_txn import EndTxnRequest
|
||||
from kafka.protocol.find_coordinator import FindCoordinatorRequest
|
||||
from kafka.protocol.init_producer_id import InitProducerIdRequest
|
||||
from kafka.protocol.txn_offset_commit import TxnOffsetCommitRequest
|
||||
from kafka.structs import TopicPartition
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
NO_PRODUCER_ID = -1
|
||||
NO_PRODUCER_EPOCH = -1
|
||||
NO_SEQUENCE = -1
|
||||
|
||||
|
||||
class ProducerIdAndEpoch(object):
|
||||
__slots__ = ('producer_id', 'epoch')
|
||||
|
||||
def __init__(self, producer_id, epoch):
|
||||
self.producer_id = producer_id
|
||||
self.epoch = epoch
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return NO_PRODUCER_ID < self.producer_id
|
||||
|
||||
def match(self, batch):
|
||||
return self.producer_id == batch.producer_id and self.epoch == batch.producer_epoch
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, ProducerIdAndEpoch) and self.producer_id == other.producer_id and self.epoch == other.epoch
|
||||
|
||||
def __str__(self):
|
||||
return "ProducerIdAndEpoch(producer_id={}, epoch={})".format(self.producer_id, self.epoch)
|
||||
|
||||
|
||||
class TransactionState(IntEnum):
|
||||
UNINITIALIZED = 0
|
||||
INITIALIZING = 1
|
||||
READY = 2
|
||||
IN_TRANSACTION = 3
|
||||
COMMITTING_TRANSACTION = 4
|
||||
ABORTING_TRANSACTION = 5
|
||||
ABORTABLE_ERROR = 6
|
||||
FATAL_ERROR = 7
|
||||
|
||||
@classmethod
|
||||
def is_transition_valid(cls, source, target):
|
||||
if target == cls.INITIALIZING:
|
||||
return source == cls.UNINITIALIZED
|
||||
elif target == cls.READY:
|
||||
return source in (cls.INITIALIZING, cls.COMMITTING_TRANSACTION, cls.ABORTING_TRANSACTION)
|
||||
elif target == cls.IN_TRANSACTION:
|
||||
return source == cls.READY
|
||||
elif target == cls.COMMITTING_TRANSACTION:
|
||||
return source == cls.IN_TRANSACTION
|
||||
elif target == cls.ABORTING_TRANSACTION:
|
||||
return source in (cls.IN_TRANSACTION, cls.ABORTABLE_ERROR)
|
||||
elif target == cls.ABORTABLE_ERROR:
|
||||
return source in (cls.IN_TRANSACTION, cls.COMMITTING_TRANSACTION, cls.ABORTABLE_ERROR)
|
||||
elif target == cls.UNINITIALIZED:
|
||||
# Disallow transitions to UNITIALIZED
|
||||
return False
|
||||
elif target == cls.FATAL_ERROR:
|
||||
# We can transition to FATAL_ERROR unconditionally.
|
||||
# FATAL_ERROR is never a valid starting state for any transition. So the only option is to close the
|
||||
# producer or do purely non transactional requests.
|
||||
return True
|
||||
|
||||
|
||||
class Priority(IntEnum):
|
||||
# We use the priority to determine the order in which requests need to be sent out. For instance, if we have
|
||||
# a pending FindCoordinator request, that must always go first. Next, If we need a producer id, that must go second.
|
||||
# The endTxn request must always go last.
|
||||
FIND_COORDINATOR = 0
|
||||
INIT_PRODUCER_ID = 1
|
||||
ADD_PARTITIONS_OR_OFFSETS = 2
|
||||
END_TXN = 3
|
||||
|
||||
|
||||
class TransactionManager(object):
|
||||
"""
|
||||
A class which maintains state for transactions. Also keeps the state necessary to ensure idempotent production.
|
||||
"""
|
||||
NO_INFLIGHT_REQUEST_CORRELATION_ID = -1
|
||||
# The retry_backoff_ms is overridden to the following value if the first AddPartitions receives a
|
||||
# CONCURRENT_TRANSACTIONS error.
|
||||
ADD_PARTITIONS_RETRY_BACKOFF_MS = 20
|
||||
|
||||
def __init__(self, transactional_id=None, transaction_timeout_ms=0, retry_backoff_ms=100, api_version=(0, 11), metadata=None):
|
||||
self._api_version = api_version
|
||||
self._metadata = metadata
|
||||
|
||||
self._sequence_numbers = collections.defaultdict(lambda: 0)
|
||||
|
||||
self.transactional_id = transactional_id
|
||||
self.transaction_timeout_ms = transaction_timeout_ms
|
||||
self._transaction_coordinator = None
|
||||
self._consumer_group_coordinator = None
|
||||
self._new_partitions_in_transaction = set()
|
||||
self._pending_partitions_in_transaction = set()
|
||||
self._partitions_in_transaction = set()
|
||||
self._pending_txn_offset_commits = dict()
|
||||
|
||||
self._current_state = TransactionState.UNINITIALIZED
|
||||
self._last_error = None
|
||||
self.producer_id_and_epoch = ProducerIdAndEpoch(NO_PRODUCER_ID, NO_PRODUCER_EPOCH)
|
||||
|
||||
self._transaction_started = False
|
||||
|
||||
self._pending_requests = [] # priority queue via heapq
|
||||
self._pending_requests_sort_id = 0
|
||||
self._in_flight_request_correlation_id = self.NO_INFLIGHT_REQUEST_CORRELATION_ID
|
||||
|
||||
# This is used by the TxnRequestHandlers to control how long to back off before a given request is retried.
|
||||
# For instance, this value is lowered by the AddPartitionsToTxnHandler when it receives a CONCURRENT_TRANSACTIONS
|
||||
# error for the first AddPartitionsRequest in a transaction.
|
||||
self.retry_backoff_ms = retry_backoff_ms
|
||||
self._lock = threading.Condition()
|
||||
|
||||
def initialize_transactions(self):
|
||||
with self._lock:
|
||||
self._ensure_transactional()
|
||||
self._transition_to(TransactionState.INITIALIZING)
|
||||
self.set_producer_id_and_epoch(ProducerIdAndEpoch(NO_PRODUCER_ID, NO_PRODUCER_EPOCH))
|
||||
self._sequence_numbers.clear()
|
||||
handler = InitProducerIdHandler(self, self.transaction_timeout_ms)
|
||||
self._enqueue_request(handler)
|
||||
return handler.result
|
||||
|
||||
def begin_transaction(self):
|
||||
with self._lock:
|
||||
self._ensure_transactional()
|
||||
self._maybe_fail_with_error()
|
||||
self._transition_to(TransactionState.IN_TRANSACTION)
|
||||
|
||||
def begin_commit(self):
|
||||
with self._lock:
|
||||
self._ensure_transactional()
|
||||
self._maybe_fail_with_error()
|
||||
self._transition_to(TransactionState.COMMITTING_TRANSACTION)
|
||||
return self._begin_completing_transaction(True)
|
||||
|
||||
def begin_abort(self):
|
||||
with self._lock:
|
||||
self._ensure_transactional()
|
||||
if self._current_state != TransactionState.ABORTABLE_ERROR:
|
||||
self._maybe_fail_with_error()
|
||||
self._transition_to(TransactionState.ABORTING_TRANSACTION)
|
||||
|
||||
# We're aborting the transaction, so there should be no need to add new partitions
|
||||
self._new_partitions_in_transaction.clear()
|
||||
return self._begin_completing_transaction(False)
|
||||
|
||||
def _begin_completing_transaction(self, committed):
|
||||
if self._new_partitions_in_transaction:
|
||||
self._enqueue_request(self._add_partitions_to_transaction_handler())
|
||||
handler = EndTxnHandler(self, committed)
|
||||
self._enqueue_request(handler)
|
||||
return handler.result
|
||||
|
||||
def send_offsets_to_transaction(self, offsets, consumer_group_id):
|
||||
with self._lock:
|
||||
self._ensure_transactional()
|
||||
self._maybe_fail_with_error()
|
||||
if self._current_state != TransactionState.IN_TRANSACTION:
|
||||
raise Errors.KafkaError("Cannot send offsets to transaction because the producer is not in an active transaction")
|
||||
|
||||
log.debug("Begin adding offsets %s for consumer group %s to transaction", offsets, consumer_group_id)
|
||||
handler = AddOffsetsToTxnHandler(self, consumer_group_id, offsets)
|
||||
self._enqueue_request(handler)
|
||||
return handler.result
|
||||
|
||||
def maybe_add_partition_to_transaction(self, topic_partition):
|
||||
with self._lock:
|
||||
self._fail_if_not_ready_for_send()
|
||||
|
||||
if self.is_partition_added(topic_partition) or self.is_partition_pending_add(topic_partition):
|
||||
return
|
||||
|
||||
log.debug("Begin adding new partition %s to transaction", topic_partition)
|
||||
self._new_partitions_in_transaction.add(topic_partition)
|
||||
|
||||
def _fail_if_not_ready_for_send(self):
|
||||
with self._lock:
|
||||
if self.has_error():
|
||||
raise Errors.KafkaError(
|
||||
"Cannot perform send because at least one previous transactional or"
|
||||
" idempotent request has failed with errors.", self._last_error)
|
||||
|
||||
if self.is_transactional():
|
||||
if not self.has_producer_id():
|
||||
raise Errors.IllegalStateError(
|
||||
"Cannot perform a 'send' before completing a call to init_transactions"
|
||||
" when transactions are enabled.")
|
||||
|
||||
if self._current_state != TransactionState.IN_TRANSACTION:
|
||||
raise Errors.IllegalStateError("Cannot call send in state %s" % (self._current_state.name,))
|
||||
|
||||
def is_send_to_partition_allowed(self, tp):
|
||||
with self._lock:
|
||||
if self.has_fatal_error():
|
||||
return False
|
||||
return not self.is_transactional() or tp in self._partitions_in_transaction
|
||||
|
||||
def has_producer_id(self, producer_id=None):
|
||||
if producer_id is None:
|
||||
return self.producer_id_and_epoch.is_valid
|
||||
else:
|
||||
return self.producer_id_and_epoch.producer_id == producer_id
|
||||
|
||||
def is_transactional(self):
|
||||
return self.transactional_id is not None
|
||||
|
||||
def has_partitions_to_add(self):
|
||||
with self._lock:
|
||||
return bool(self._new_partitions_in_transaction) or bool(self._pending_partitions_in_transaction)
|
||||
|
||||
def is_completing(self):
|
||||
with self._lock:
|
||||
return self._current_state in (
|
||||
TransactionState.COMMITTING_TRANSACTION,
|
||||
TransactionState.ABORTING_TRANSACTION)
|
||||
|
||||
@property
|
||||
def last_error(self):
|
||||
return self._last_error
|
||||
|
||||
def has_error(self):
|
||||
with self._lock:
|
||||
return self._current_state in (
|
||||
TransactionState.ABORTABLE_ERROR,
|
||||
TransactionState.FATAL_ERROR)
|
||||
|
||||
def is_aborting(self):
|
||||
with self._lock:
|
||||
return self._current_state == TransactionState.ABORTING_TRANSACTION
|
||||
|
||||
def transition_to_abortable_error(self, exc):
|
||||
with self._lock:
|
||||
if self._current_state == TransactionState.ABORTING_TRANSACTION:
|
||||
log.debug("Skipping transition to abortable error state since the transaction is already being "
|
||||
" aborted. Underlying exception: %s", exc)
|
||||
return
|
||||
self._transition_to(TransactionState.ABORTABLE_ERROR, error=exc)
|
||||
|
||||
def transition_to_fatal_error(self, exc):
|
||||
with self._lock:
|
||||
self._transition_to(TransactionState.FATAL_ERROR, error=exc)
|
||||
|
||||
def is_partition_added(self, partition):
|
||||
with self._lock:
|
||||
return partition in self._partitions_in_transaction
|
||||
|
||||
def is_partition_pending_add(self, partition):
|
||||
return partition in self._new_partitions_in_transaction or partition in self._pending_partitions_in_transaction
|
||||
|
||||
def has_producer_id_and_epoch(self, producer_id, producer_epoch):
|
||||
return (
|
||||
self.producer_id_and_epoch.producer_id == producer_id and
|
||||
self.producer_id_and_epoch.epoch == producer_epoch
|
||||
)
|
||||
|
||||
def set_producer_id_and_epoch(self, producer_id_and_epoch):
|
||||
if not isinstance(producer_id_and_epoch, ProducerIdAndEpoch):
|
||||
raise TypeError("ProducerAndIdEpoch type required")
|
||||
log.info("ProducerId set to %s with epoch %s",
|
||||
producer_id_and_epoch.producer_id, producer_id_and_epoch.epoch)
|
||||
self.producer_id_and_epoch = producer_id_and_epoch
|
||||
|
||||
def reset_producer_id(self):
|
||||
"""
|
||||
This method is used when the producer needs to reset its internal state because of an irrecoverable exception
|
||||
from the broker.
|
||||
|
||||
We need to reset the producer id and associated state when we have sent a batch to the broker, but we either get
|
||||
a non-retriable exception or we run out of retries, or the batch expired in the producer queue after it was already
|
||||
sent to the broker.
|
||||
|
||||
In all of these cases, we don't know whether batch was actually committed on the broker, and hence whether the
|
||||
sequence number was actually updated. If we don't reset the producer state, we risk the chance that all future
|
||||
messages will return an OutOfOrderSequenceNumberError.
|
||||
|
||||
Note that we can't reset the producer state for the transactional producer as this would mean bumping the epoch
|
||||
for the same producer id. This might involve aborting the ongoing transaction during the initProducerIdRequest,
|
||||
and the user would not have any way of knowing this happened. So for the transactional producer,
|
||||
it's best to return the produce error to the user and let them abort the transaction and close the producer explicitly.
|
||||
"""
|
||||
with self._lock:
|
||||
if self.is_transactional():
|
||||
raise Errors.IllegalStateError(
|
||||
"Cannot reset producer state for a transactional producer."
|
||||
" You must either abort the ongoing transaction or"
|
||||
" reinitialize the transactional producer instead")
|
||||
self.set_producer_id_and_epoch(ProducerIdAndEpoch(NO_PRODUCER_ID, NO_PRODUCER_EPOCH))
|
||||
self._sequence_numbers.clear()
|
||||
|
||||
def sequence_number(self, tp):
|
||||
with self._lock:
|
||||
return self._sequence_numbers[tp]
|
||||
|
||||
def increment_sequence_number(self, tp, increment):
|
||||
with self._lock:
|
||||
if tp not in self._sequence_numbers:
|
||||
raise Errors.IllegalStateError("Attempt to increment sequence number for a partition with no current sequence.")
|
||||
# Sequence number wraps at java max int
|
||||
base = self._sequence_numbers[tp]
|
||||
if base > (2147483647 - increment):
|
||||
self._sequence_numbers[tp] = increment - (2147483647 - base) - 1
|
||||
else:
|
||||
self._sequence_numbers[tp] += increment
|
||||
|
||||
def next_request_handler(self, has_incomplete_batches):
|
||||
with self._lock:
|
||||
if self._new_partitions_in_transaction:
|
||||
self._enqueue_request(self._add_partitions_to_transaction_handler())
|
||||
|
||||
if not self._pending_requests:
|
||||
return None
|
||||
|
||||
_, _, next_request_handler = self._pending_requests[0]
|
||||
# Do not send the EndTxn until all batches have been flushed
|
||||
if isinstance(next_request_handler, EndTxnHandler) and has_incomplete_batches:
|
||||
return None
|
||||
|
||||
heapq.heappop(self._pending_requests)
|
||||
if self._maybe_terminate_request_with_error(next_request_handler):
|
||||
log.debug("Not sending transactional request %s because we are in an error state",
|
||||
next_request_handler.request)
|
||||
return None
|
||||
|
||||
if isinstance(next_request_handler, EndTxnHandler) and not self._transaction_started:
|
||||
next_request_handler.result.done()
|
||||
if self._current_state != TransactionState.FATAL_ERROR:
|
||||
log.debug("Not sending EndTxn for completed transaction since no partitions"
|
||||
" or offsets were successfully added")
|
||||
self._complete_transaction()
|
||||
try:
|
||||
_, _, next_request_handler = heapq.heappop(self._pending_requests)
|
||||
except IndexError:
|
||||
next_request_handler = None
|
||||
|
||||
if next_request_handler:
|
||||
log.debug("Request %s dequeued for sending", next_request_handler.request)
|
||||
|
||||
return next_request_handler
|
||||
|
||||
def retry(self, request):
|
||||
with self._lock:
|
||||
request.set_retry()
|
||||
self._enqueue_request(request)
|
||||
|
||||
def authentication_failed(self, exc):
|
||||
with self._lock:
|
||||
for _, _, request in self._pending_requests:
|
||||
request.fatal_error(exc)
|
||||
|
||||
def coordinator(self, coord_type):
|
||||
if coord_type == 'group':
|
||||
return self._consumer_group_coordinator
|
||||
elif coord_type == 'transaction':
|
||||
return self._transaction_coordinator
|
||||
else:
|
||||
raise Errors.IllegalStateError("Received an invalid coordinator type: %s" % (coord_type,))
|
||||
|
||||
def lookup_coordinator_for_request(self, request):
|
||||
self._lookup_coordinator(request.coordinator_type, request.coordinator_key)
|
||||
|
||||
def next_in_flight_request_correlation_id(self):
|
||||
self._in_flight_request_correlation_id += 1
|
||||
return self._in_flight_request_correlation_id
|
||||
|
||||
def clear_in_flight_transactional_request_correlation_id(self):
|
||||
self._in_flight_request_correlation_id = self.NO_INFLIGHT_REQUEST_CORRELATION_ID
|
||||
|
||||
def has_in_flight_transactional_request(self):
|
||||
return self._in_flight_request_correlation_id != self.NO_INFLIGHT_REQUEST_CORRELATION_ID
|
||||
|
||||
def has_fatal_error(self):
|
||||
return self._current_state == TransactionState.FATAL_ERROR
|
||||
|
||||
def has_abortable_error(self):
|
||||
return self._current_state == TransactionState.ABORTABLE_ERROR
|
||||
|
||||
# visible for testing
|
||||
def _test_transaction_contains_partition(self, tp):
|
||||
with self._lock:
|
||||
return tp in self._partitions_in_transaction
|
||||
|
||||
# visible for testing
|
||||
def _test_has_pending_offset_commits(self):
|
||||
return bool(self._pending_txn_offset_commits)
|
||||
|
||||
# visible for testing
|
||||
def _test_has_ongoing_transaction(self):
|
||||
with self._lock:
|
||||
# transactions are considered ongoing once started until completion or a fatal error
|
||||
return self._current_state == TransactionState.IN_TRANSACTION or self.is_completing() or self.has_abortable_error()
|
||||
|
||||
# visible for testing
|
||||
def _test_is_ready(self):
|
||||
with self._lock:
|
||||
return self.is_transactional() and self._current_state == TransactionState.READY
|
||||
|
||||
def _transition_to(self, target, error=None):
|
||||
with self._lock:
|
||||
if not self._current_state.is_transition_valid(self._current_state, target):
|
||||
raise Errors.KafkaError("TransactionalId %s: Invalid transition attempted from state %s to state %s" % (
|
||||
self.transactional_id, self._current_state.name, target.name))
|
||||
|
||||
if target in (TransactionState.FATAL_ERROR, TransactionState.ABORTABLE_ERROR):
|
||||
if error is None:
|
||||
raise Errors.IllegalArgumentError("Cannot transition to %s with an None exception" % (target.name,))
|
||||
self._last_error = error
|
||||
else:
|
||||
self._last_error = None
|
||||
|
||||
if self._last_error is not None:
|
||||
log.debug("Transition from state %s to error state %s (%s)", self._current_state.name, target.name, self._last_error)
|
||||
else:
|
||||
log.debug("Transition from state %s to %s", self._current_state, target)
|
||||
self._current_state = target
|
||||
|
||||
def _ensure_transactional(self):
|
||||
if not self.is_transactional():
|
||||
raise Errors.IllegalStateError("Transactional method invoked on a non-transactional producer.")
|
||||
|
||||
def _maybe_fail_with_error(self):
|
||||
if self.has_error():
|
||||
raise Errors.KafkaError("Cannot execute transactional method because we are in an error state: %s" % (self._last_error,))
|
||||
|
||||
def _maybe_terminate_request_with_error(self, request_handler):
|
||||
if self.has_error():
|
||||
if self.has_abortable_error() and isinstance(request_handler, FindCoordinatorHandler):
|
||||
# No harm letting the FindCoordinator request go through if we're expecting to abort
|
||||
return False
|
||||
request_handler.fail(self._last_error)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _next_pending_requests_sort_id(self):
|
||||
self._pending_requests_sort_id += 1
|
||||
return self._pending_requests_sort_id
|
||||
|
||||
def _enqueue_request(self, request_handler):
|
||||
log.debug("Enqueuing transactional request %s", request_handler.request)
|
||||
heapq.heappush(
|
||||
self._pending_requests,
|
||||
(
|
||||
request_handler.priority, # keep lowest priority at head of queue
|
||||
self._next_pending_requests_sort_id(), # break ties
|
||||
request_handler
|
||||
)
|
||||
)
|
||||
|
||||
def _lookup_coordinator(self, coord_type, coord_key):
|
||||
with self._lock:
|
||||
if coord_type == 'group':
|
||||
self._consumer_group_coordinator = None
|
||||
elif coord_type == 'transaction':
|
||||
self._transaction_coordinator = None
|
||||
else:
|
||||
raise Errors.IllegalStateError("Invalid coordinator type: %s" % (coord_type,))
|
||||
self._enqueue_request(FindCoordinatorHandler(self, coord_type, coord_key))
|
||||
|
||||
def _complete_transaction(self):
|
||||
with self._lock:
|
||||
self._transition_to(TransactionState.READY)
|
||||
self._transaction_started = False
|
||||
self._new_partitions_in_transaction.clear()
|
||||
self._pending_partitions_in_transaction.clear()
|
||||
self._partitions_in_transaction.clear()
|
||||
|
||||
def _add_partitions_to_transaction_handler(self):
|
||||
with self._lock:
|
||||
self._pending_partitions_in_transaction.update(self._new_partitions_in_transaction)
|
||||
self._new_partitions_in_transaction.clear()
|
||||
return AddPartitionsToTxnHandler(self, self._pending_partitions_in_transaction)
|
||||
|
||||
|
||||
class TransactionalRequestResult(object):
|
||||
def __init__(self):
|
||||
self._latch = threading.Event()
|
||||
self._error = None
|
||||
|
||||
def done(self, error=None):
|
||||
self._error = error
|
||||
self._latch.set()
|
||||
|
||||
def wait(self, timeout_ms=None):
|
||||
timeout = timeout_ms / 1000 if timeout_ms is not None else None
|
||||
success = self._latch.wait(timeout)
|
||||
if self._error:
|
||||
raise self._error
|
||||
return success
|
||||
|
||||
@property
|
||||
def is_done(self):
|
||||
return self._latch.is_set()
|
||||
|
||||
@property
|
||||
def succeeded(self):
|
||||
return self._latch.is_set() and self._error is None
|
||||
|
||||
@property
|
||||
def failed(self):
|
||||
return self._latch.is_set() and self._error is not None
|
||||
|
||||
@property
|
||||
def exception(self):
|
||||
return self._error
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class TxnRequestHandler(object):
|
||||
def __init__(self, transaction_manager, result=None):
|
||||
self.transaction_manager = transaction_manager
|
||||
self.retry_backoff_ms = transaction_manager.retry_backoff_ms
|
||||
self.request = None
|
||||
self._result = result or TransactionalRequestResult()
|
||||
self._is_retry = False
|
||||
|
||||
@property
|
||||
def transactional_id(self):
|
||||
return self.transaction_manager.transactional_id
|
||||
|
||||
@property
|
||||
def producer_id(self):
|
||||
return self.transaction_manager.producer_id_and_epoch.producer_id
|
||||
|
||||
@property
|
||||
def producer_epoch(self):
|
||||
return self.transaction_manager.producer_id_and_epoch.epoch
|
||||
|
||||
def fatal_error(self, exc):
|
||||
self.transaction_manager.transition_to_fatal_error(exc)
|
||||
self._result.done(error=exc)
|
||||
|
||||
def abortable_error(self, exc):
|
||||
self.transaction_manager.transition_to_abortable_error(exc)
|
||||
self._result.done(error=exc)
|
||||
|
||||
def fail(self, exc):
|
||||
self._result.done(error=exc)
|
||||
|
||||
def reenqueue(self):
|
||||
with self.transaction_manager._lock:
|
||||
self._is_retry = True
|
||||
self.transaction_manager._enqueue_request(self)
|
||||
|
||||
def on_complete(self, correlation_id, response_or_exc):
|
||||
if correlation_id != self.transaction_manager._in_flight_request_correlation_id:
|
||||
self.fatal_error(RuntimeError("Detected more than one in-flight transactional request."))
|
||||
else:
|
||||
self.transaction_manager.clear_in_flight_transactional_request_correlation_id()
|
||||
if isinstance(response_or_exc, Errors.KafkaConnectionError):
|
||||
log.debug("Disconnected from node. Will retry.")
|
||||
if self.needs_coordinator():
|
||||
self.transaction_manager._lookup_coordinator(self.coordinator_type, self.coordinator_key)
|
||||
self.reenqueue()
|
||||
elif isinstance(response_or_exc, Errors.UnsupportedVersionError):
|
||||
self.fatal_error(response_or_exc)
|
||||
elif not isinstance(response_or_exc, (Exception, type(None))):
|
||||
log.debug("Received transactional response %s for request %s", response_or_exc, self.request)
|
||||
with self.transaction_manager._lock:
|
||||
self.handle_response(response_or_exc)
|
||||
else:
|
||||
self.fatal_error(Errors.KafkaError("Could not execute transactional request for unknown reasons: %s" % response_or_exc))
|
||||
|
||||
def needs_coordinator(self):
|
||||
return self.coordinator_type is not None
|
||||
|
||||
@property
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
@property
|
||||
def coordinator_type(self):
|
||||
return 'transaction'
|
||||
|
||||
@property
|
||||
def coordinator_key(self):
|
||||
return self.transaction_manager.transactional_id
|
||||
|
||||
def set_retry(self):
|
||||
self._is_retry = True
|
||||
|
||||
@property
|
||||
def is_retry(self):
|
||||
return self._is_retry
|
||||
|
||||
@abc.abstractmethod
|
||||
def handle_response(self, response):
|
||||
pass
|
||||
|
||||
@abc.abstractproperty
|
||||
def priority(self):
|
||||
pass
|
||||
|
||||
|
||||
class InitProducerIdHandler(TxnRequestHandler):
|
||||
def __init__(self, transaction_manager, transaction_timeout_ms):
|
||||
super(InitProducerIdHandler, self).__init__(transaction_manager)
|
||||
|
||||
if transaction_manager._api_version >= (2, 0):
|
||||
version = 1
|
||||
else:
|
||||
version = 0
|
||||
self.request = InitProducerIdRequest[version](
|
||||
transactional_id=self.transactional_id,
|
||||
transaction_timeout_ms=transaction_timeout_ms)
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return Priority.INIT_PRODUCER_ID
|
||||
|
||||
def handle_response(self, response):
|
||||
error = Errors.for_code(response.error_code)
|
||||
|
||||
if error is Errors.NoError:
|
||||
self.transaction_manager.set_producer_id_and_epoch(ProducerIdAndEpoch(response.producer_id, response.producer_epoch))
|
||||
self.transaction_manager._transition_to(TransactionState.READY)
|
||||
self._result.done()
|
||||
elif error in (Errors.NotCoordinatorError, Errors.CoordinatorNotAvailableError):
|
||||
self.transaction_manager._lookup_coordinator('transaction', self.transactional_id)
|
||||
self.reenqueue()
|
||||
elif error in (Errors.CoordinatorLoadInProgressError, Errors.ConcurrentTransactionsError):
|
||||
self.reenqueue()
|
||||
elif error is Errors.TransactionalIdAuthorizationFailedError:
|
||||
self.fatal_error(error())
|
||||
else:
|
||||
self.fatal_error(Errors.KafkaError("Unexpected error in InitProducerIdResponse: %s" % (error())))
|
||||
|
||||
class AddPartitionsToTxnHandler(TxnRequestHandler):
|
||||
def __init__(self, transaction_manager, topic_partitions):
|
||||
super(AddPartitionsToTxnHandler, self).__init__(transaction_manager)
|
||||
|
||||
if transaction_manager._api_version >= (2, 7):
|
||||
version = 2
|
||||
elif transaction_manager._api_version >= (2, 0):
|
||||
version = 1
|
||||
else:
|
||||
version = 0
|
||||
topic_data = collections.defaultdict(list)
|
||||
for tp in topic_partitions:
|
||||
topic_data[tp.topic].append(tp.partition)
|
||||
self.request = AddPartitionsToTxnRequest[version](
|
||||
transactional_id=self.transactional_id,
|
||||
producer_id=self.producer_id,
|
||||
producer_epoch=self.producer_epoch,
|
||||
topics=list(topic_data.items()))
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return Priority.ADD_PARTITIONS_OR_OFFSETS
|
||||
|
||||
def handle_response(self, response):
|
||||
has_partition_errors = False
|
||||
unauthorized_topics = set()
|
||||
self.retry_backoff_ms = self.transaction_manager.retry_backoff_ms
|
||||
|
||||
results = {TopicPartition(topic, partition): Errors.for_code(error_code)
|
||||
for topic, partition_data in response.results
|
||||
for partition, error_code in partition_data}
|
||||
|
||||
for tp, error in six.iteritems(results):
|
||||
if error is Errors.NoError:
|
||||
continue
|
||||
elif error in (Errors.CoordinatorNotAvailableError, Errors.NotCoordinatorError):
|
||||
self.transaction_manager._lookup_coordinator('transaction', self.transactional_id)
|
||||
self.reenqueue()
|
||||
return
|
||||
elif error is Errors.ConcurrentTransactionsError:
|
||||
self.maybe_override_retry_backoff_ms()
|
||||
self.reenqueue()
|
||||
return
|
||||
elif error in (Errors.CoordinatorLoadInProgressError, Errors.UnknownTopicOrPartitionError):
|
||||
self.reenqueue()
|
||||
return
|
||||
elif error is Errors.InvalidProducerEpochError:
|
||||
self.fatal_error(error())
|
||||
return
|
||||
elif error is Errors.TransactionalIdAuthorizationFailedError:
|
||||
self.fatal_error(error())
|
||||
return
|
||||
elif error in (Errors.InvalidProducerIdMappingError, Errors.InvalidTxnStateError):
|
||||
self.fatal_error(Errors.KafkaError(error()))
|
||||
return
|
||||
elif error is Errors.TopicAuthorizationFailedError:
|
||||
unauthorized_topics.add(tp.topic)
|
||||
elif error is Errors.OperationNotAttemptedError:
|
||||
log.debug("Did not attempt to add partition %s to transaction because other partitions in the"
|
||||
" batch had errors.", tp)
|
||||
has_partition_errors = True
|
||||
else:
|
||||
log.error("Could not add partition %s due to unexpected error %s", tp, error())
|
||||
has_partition_errors = True
|
||||
|
||||
partitions = set(results)
|
||||
|
||||
# Remove the partitions from the pending set regardless of the result. We use the presence
|
||||
# of partitions in the pending set to know when it is not safe to send batches. However, if
|
||||
# the partitions failed to be added and we enter an error state, we expect the batches to be
|
||||
# aborted anyway. In this case, we must be able to continue sending the batches which are in
|
||||
# retry for partitions that were successfully added.
|
||||
self.transaction_manager._pending_partitions_in_transaction -= partitions
|
||||
|
||||
if unauthorized_topics:
|
||||
self.abortable_error(Errors.TopicAuthorizationFailedError(unauthorized_topics))
|
||||
elif has_partition_errors:
|
||||
self.abortable_error(Errors.KafkaError("Could not add partitions to transaction due to errors: %s" % (results)))
|
||||
else:
|
||||
log.debug("Successfully added partitions %s to transaction", partitions)
|
||||
self.transaction_manager._partitions_in_transaction.update(partitions)
|
||||
self.transaction_manager._transaction_started = True
|
||||
self._result.done()
|
||||
|
||||
def maybe_override_retry_backoff_ms(self):
|
||||
# We only want to reduce the backoff when retrying the first AddPartition which errored out due to a
|
||||
# CONCURRENT_TRANSACTIONS error since this means that the previous transaction is still completing and
|
||||
# we don't want to wait too long before trying to start the new one.
|
||||
#
|
||||
# This is only a temporary fix, the long term solution is being tracked in
|
||||
# https://issues.apache.org/jira/browse/KAFKA-5482
|
||||
if not self.transaction_manager._partitions_in_transaction:
|
||||
self.retry_backoff_ms = min(self.transaction_manager.ADD_PARTITIONS_RETRY_BACKOFF_MS, self.retry_backoff_ms)
|
||||
|
||||
|
||||
class FindCoordinatorHandler(TxnRequestHandler):
|
||||
def __init__(self, transaction_manager, coord_type, coord_key):
|
||||
super(FindCoordinatorHandler, self).__init__(transaction_manager)
|
||||
|
||||
self._coord_type = coord_type
|
||||
self._coord_key = coord_key
|
||||
if transaction_manager._api_version >= (2, 0):
|
||||
version = 2
|
||||
else:
|
||||
version = 1
|
||||
if coord_type == 'group':
|
||||
coord_type_int8 = 0
|
||||
elif coord_type == 'transaction':
|
||||
coord_type_int8 = 1
|
||||
else:
|
||||
raise ValueError("Unrecognized coordinator type: %s" % (coord_type,))
|
||||
self.request = FindCoordinatorRequest[version](
|
||||
coordinator_key=coord_key,
|
||||
coordinator_type=coord_type_int8,
|
||||
)
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return Priority.FIND_COORDINATOR
|
||||
|
||||
@property
|
||||
def coordinator_type(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def coordinator_key(self):
|
||||
return None
|
||||
|
||||
def handle_response(self, response):
|
||||
error = Errors.for_code(response.error_code)
|
||||
|
||||
if error is Errors.NoError:
|
||||
coordinator_id = self.transaction_manager._metadata.add_coordinator(
|
||||
response, self._coord_type, self._coord_key)
|
||||
if self._coord_type == 'group':
|
||||
self.transaction_manager._consumer_group_coordinator = coordinator_id
|
||||
elif self._coord_type == 'transaction':
|
||||
self.transaction_manager._transaction_coordinator = coordinator_id
|
||||
self._result.done()
|
||||
elif error is Errors.CoordinatorNotAvailableError:
|
||||
self.reenqueue()
|
||||
elif error is Errors.TransactionalIdAuthorizationFailedError:
|
||||
self.fatal_error(error())
|
||||
elif error is Errors.GroupAuthorizationFailedError:
|
||||
self.abortable_error(error(self._coord_key))
|
||||
else:
|
||||
self.fatal_error(Errors.KafkaError(
|
||||
"Could not find a coordinator with type %s with key %s due to"
|
||||
" unexpected error: %s" % (self._coord_type, self._coord_key, error())))
|
||||
|
||||
|
||||
class EndTxnHandler(TxnRequestHandler):
|
||||
def __init__(self, transaction_manager, committed):
|
||||
super(EndTxnHandler, self).__init__(transaction_manager)
|
||||
|
||||
if self.transaction_manager._api_version >= (2, 7):
|
||||
version = 2
|
||||
elif self.transaction_manager._api_version >= (2, 0):
|
||||
version = 1
|
||||
else:
|
||||
version = 0
|
||||
self.request = EndTxnRequest[version](
|
||||
transactional_id=self.transactional_id,
|
||||
producer_id=self.producer_id,
|
||||
producer_epoch=self.producer_epoch,
|
||||
committed=committed)
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return Priority.END_TXN
|
||||
|
||||
def handle_response(self, response):
|
||||
error = Errors.for_code(response.error_code)
|
||||
|
||||
if error is Errors.NoError:
|
||||
self.transaction_manager._complete_transaction()
|
||||
self._result.done()
|
||||
elif error in (Errors.CoordinatorNotAvailableError, Errors.NotCoordinatorError):
|
||||
self.transaction_manager._lookup_coordinator('transaction', self.transactional_id)
|
||||
self.reenqueue()
|
||||
elif error in (Errors.CoordinatorLoadInProgressError, Errors.ConcurrentTransactionsError):
|
||||
self.reenqueue()
|
||||
elif error is Errors.InvalidProducerEpochError:
|
||||
self.fatal_error(error())
|
||||
elif error is Errors.TransactionalIdAuthorizationFailedError:
|
||||
self.fatal_error(error())
|
||||
elif error is Errors.InvalidTxnStateError:
|
||||
self.fatal_error(error())
|
||||
else:
|
||||
self.fatal_error(Errors.KafkaError("Unhandled error in EndTxnResponse: %s" % (error())))
|
||||
|
||||
|
||||
class AddOffsetsToTxnHandler(TxnRequestHandler):
|
||||
def __init__(self, transaction_manager, consumer_group_id, offsets):
|
||||
super(AddOffsetsToTxnHandler, self).__init__(transaction_manager)
|
||||
|
||||
self.consumer_group_id = consumer_group_id
|
||||
self.offsets = offsets
|
||||
if self.transaction_manager._api_version >= (2, 7):
|
||||
version = 2
|
||||
elif self.transaction_manager._api_version >= (2, 0):
|
||||
version = 1
|
||||
else:
|
||||
version = 0
|
||||
self.request = AddOffsetsToTxnRequest[version](
|
||||
transactional_id=self.transactional_id,
|
||||
producer_id=self.producer_id,
|
||||
producer_epoch=self.producer_epoch,
|
||||
group_id=consumer_group_id)
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return Priority.ADD_PARTITIONS_OR_OFFSETS
|
||||
|
||||
def handle_response(self, response):
|
||||
error = Errors.for_code(response.error_code)
|
||||
|
||||
if error is Errors.NoError:
|
||||
log.debug("Successfully added partition for consumer group %s to transaction", self.consumer_group_id)
|
||||
|
||||
# note the result is not completed until the TxnOffsetCommit returns
|
||||
for tp, offset in six.iteritems(self.offsets):
|
||||
self.transaction_manager._pending_txn_offset_commits[tp] = offset
|
||||
handler = TxnOffsetCommitHandler(self.transaction_manager, self.consumer_group_id,
|
||||
self.transaction_manager._pending_txn_offset_commits, self._result)
|
||||
self.transaction_manager._enqueue_request(handler)
|
||||
self.transaction_manager._transaction_started = True
|
||||
elif error in (Errors.CoordinatorNotAvailableError, Errors.NotCoordinatorError):
|
||||
self.transaction_manager._lookup_coordinator('transaction', self.transactional_id)
|
||||
self.reenqueue()
|
||||
elif error in (Errors.CoordinatorLoadInProgressError, Errors.ConcurrentTransactionsError):
|
||||
self.reenqueue()
|
||||
elif error is Errors.InvalidProducerEpochError:
|
||||
self.fatal_error(error())
|
||||
elif error is Errors.TransactionalIdAuthorizationFailedError:
|
||||
self.fatal_error(error())
|
||||
elif error is Errors.GroupAuthorizationFailedError:
|
||||
self.abortable_error(error(self.consumer_group_id))
|
||||
else:
|
||||
self.fatal_error(Errors.KafkaError("Unexpected error in AddOffsetsToTxnResponse: %s" % (error())))
|
||||
|
||||
|
||||
class TxnOffsetCommitHandler(TxnRequestHandler):
|
||||
def __init__(self, transaction_manager, consumer_group_id, offsets, result):
|
||||
super(TxnOffsetCommitHandler, self).__init__(transaction_manager, result=result)
|
||||
|
||||
self.consumer_group_id = consumer_group_id
|
||||
self.offsets = offsets
|
||||
self.request = self._build_request()
|
||||
|
||||
def _build_request(self):
|
||||
if self.transaction_manager._api_version >= (2, 1):
|
||||
version = 2
|
||||
elif self.transaction_manager._api_version >= (2, 0):
|
||||
version = 1
|
||||
else:
|
||||
version = 0
|
||||
|
||||
topic_data = collections.defaultdict(list)
|
||||
for tp, offset in six.iteritems(self.offsets):
|
||||
if version >= 2:
|
||||
partition_data = (tp.partition, offset.offset, offset.leader_epoch, offset.metadata)
|
||||
else:
|
||||
partition_data = (tp.partition, offset.offset, offset.metadata)
|
||||
topic_data[tp.topic].append(partition_data)
|
||||
|
||||
return TxnOffsetCommitRequest[version](
|
||||
transactional_id=self.transactional_id,
|
||||
group_id=self.consumer_group_id,
|
||||
producer_id=self.producer_id,
|
||||
producer_epoch=self.producer_epoch,
|
||||
topics=list(topic_data.items()))
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return Priority.ADD_PARTITIONS_OR_OFFSETS
|
||||
|
||||
@property
|
||||
def coordinator_type(self):
|
||||
return 'group'
|
||||
|
||||
@property
|
||||
def coordinator_key(self):
|
||||
return self.consumer_group_id
|
||||
|
||||
def handle_response(self, response):
|
||||
lookup_coordinator = False
|
||||
retriable_failure = False
|
||||
|
||||
errors = {TopicPartition(topic, partition): Errors.for_code(error_code)
|
||||
for topic, partition_data in response.topics
|
||||
for partition, error_code in partition_data}
|
||||
|
||||
for tp, error in six.iteritems(errors):
|
||||
if error is Errors.NoError:
|
||||
log.debug("Successfully added offsets for %s from consumer group %s to transaction.",
|
||||
tp, self.consumer_group_id)
|
||||
del self.transaction_manager._pending_txn_offset_commits[tp]
|
||||
elif error in (errors.CoordinatorNotAvailableError, Errors.NotCoordinatorError, Errors.RequestTimedOutError):
|
||||
retriable_failure = True
|
||||
lookup_coordinator = True
|
||||
elif error is Errors.UnknownTopicOrPartitionError:
|
||||
retriable_failure = True
|
||||
elif error is Errors.GroupAuthorizationFailedError:
|
||||
self.abortable_error(error(self.consumer_group_id))
|
||||
return
|
||||
elif error in (Errors.TransactionalIdAuthorizationFailedError,
|
||||
Errors.InvalidProducerEpochError,
|
||||
Errors.UnsupportedForMessageFormatError):
|
||||
self.fatal_error(error())
|
||||
return
|
||||
else:
|
||||
self.fatal_error(Errors.KafkaError("Unexpected error in TxnOffsetCommitResponse: %s" % (error())))
|
||||
return
|
||||
|
||||
if lookup_coordinator:
|
||||
self.transaction_manager._lookup_coordinator('group', self.consumer_group_id)
|
||||
|
||||
if not retriable_failure:
|
||||
# all attempted partitions were either successful, or there was a fatal failure.
|
||||
# either way, we are not retrying, so complete the request.
|
||||
self.result.done()
|
||||
|
||||
# retry the commits which failed with a retriable error.
|
||||
elif self.transaction_manager._pending_txn_offset_commits:
|
||||
self.offsets = self.transaction_manager._pending_txn_offset_commits
|
||||
self.request = self._build_request()
|
||||
self.reenqueue()
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
API_KEYS = {
|
||||
0: 'Produce',
|
||||
1: 'Fetch',
|
||||
2: 'ListOffsets',
|
||||
3: 'Metadata',
|
||||
4: 'LeaderAndIsr',
|
||||
5: 'StopReplica',
|
||||
6: 'UpdateMetadata',
|
||||
7: 'ControlledShutdown',
|
||||
8: 'OffsetCommit',
|
||||
9: 'OffsetFetch',
|
||||
10: 'FindCoordinator',
|
||||
11: 'JoinGroup',
|
||||
12: 'Heartbeat',
|
||||
13: 'LeaveGroup',
|
||||
14: 'SyncGroup',
|
||||
15: 'DescribeGroups',
|
||||
16: 'ListGroups',
|
||||
17: 'SaslHandshake',
|
||||
18: 'ApiVersions',
|
||||
19: 'CreateTopics',
|
||||
20: 'DeleteTopics',
|
||||
21: 'DeleteRecords',
|
||||
22: 'InitProducerId',
|
||||
23: 'OffsetForLeaderEpoch',
|
||||
24: 'AddPartitionsToTxn',
|
||||
25: 'AddOffsetsToTxn',
|
||||
26: 'EndTxn',
|
||||
27: 'WriteTxnMarkers',
|
||||
28: 'TxnOffsetCommit',
|
||||
29: 'DescribeAcls',
|
||||
30: 'CreateAcls',
|
||||
31: 'DeleteAcls',
|
||||
32: 'DescribeConfigs',
|
||||
33: 'AlterConfigs',
|
||||
36: 'SaslAuthenticate',
|
||||
37: 'CreatePartitions',
|
||||
38: 'CreateDelegationToken',
|
||||
39: 'RenewDelegationToken',
|
||||
40: 'ExpireDelegationToken',
|
||||
41: 'DescribeDelegationToken',
|
||||
42: 'DeleteGroups',
|
||||
45: 'AlterPartitionReassignments',
|
||||
46: 'ListPartitionReassignments',
|
||||
48: 'DescribeClientQuotas',
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class AbstractType(object):
|
||||
@abc.abstractmethod
|
||||
def encode(cls, value): # pylint: disable=no-self-argument
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def decode(cls, data): # pylint: disable=no-self-argument
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def repr(cls, value):
|
||||
return repr(value)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Int16, Int32, Int64, Schema, String
|
||||
|
||||
|
||||
class AddOffsetsToTxnResponse_v0(Response):
|
||||
API_KEY = 25
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
)
|
||||
|
||||
|
||||
class AddOffsetsToTxnResponse_v1(Response):
|
||||
API_KEY = 25
|
||||
API_VERSION = 1
|
||||
SCHEMA = AddOffsetsToTxnResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class AddOffsetsToTxnResponse_v2(Response):
|
||||
API_KEY = 25
|
||||
API_VERSION = 2
|
||||
SCHEMA = AddOffsetsToTxnResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class AddOffsetsToTxnRequest_v0(Request):
|
||||
API_KEY = 25
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = AddOffsetsToTxnResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('transactional_id', String('utf-8')),
|
||||
('producer_id', Int64),
|
||||
('producer_epoch', Int16),
|
||||
('group_id', String('utf-8')),
|
||||
)
|
||||
|
||||
|
||||
class AddOffsetsToTxnRequest_v1(Request):
|
||||
API_KEY = 25
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = AddOffsetsToTxnResponse_v1
|
||||
SCHEMA = AddOffsetsToTxnRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class AddOffsetsToTxnRequest_v2(Request):
|
||||
API_KEY = 25
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = AddOffsetsToTxnResponse_v2
|
||||
SCHEMA = AddOffsetsToTxnRequest_v1.SCHEMA
|
||||
|
||||
|
||||
AddOffsetsToTxnRequest = [
|
||||
AddOffsetsToTxnRequest_v0, AddOffsetsToTxnRequest_v1, AddOffsetsToTxnRequest_v2,
|
||||
]
|
||||
AddOffsetsToTxnResponse = [
|
||||
AddOffsetsToTxnResponse_v0, AddOffsetsToTxnResponse_v1, AddOffsetsToTxnResponse_v2,
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, Int16, Int32, Int64, Schema, String
|
||||
|
||||
|
||||
class AddPartitionsToTxnResponse_v0(Response):
|
||||
API_KEY = 24
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('results', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16))))))
|
||||
|
||||
|
||||
class AddPartitionsToTxnResponse_v1(Response):
|
||||
API_KEY = 24
|
||||
API_VERSION = 1
|
||||
SCHEMA = AddPartitionsToTxnResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class AddPartitionsToTxnResponse_v2(Response):
|
||||
API_KEY = 24
|
||||
API_VERSION = 2
|
||||
SCHEMA = AddPartitionsToTxnResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class AddPartitionsToTxnRequest_v0(Request):
|
||||
API_KEY = 24
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = AddPartitionsToTxnResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('transactional_id', String('utf-8')),
|
||||
('producer_id', Int64),
|
||||
('producer_epoch', Int16),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(Int32)))))
|
||||
|
||||
|
||||
class AddPartitionsToTxnRequest_v1(Request):
|
||||
API_KEY = 24
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = AddPartitionsToTxnResponse_v1
|
||||
SCHEMA = AddPartitionsToTxnRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class AddPartitionsToTxnRequest_v2(Request):
|
||||
API_KEY = 24
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = AddPartitionsToTxnResponse_v2
|
||||
SCHEMA = AddPartitionsToTxnRequest_v1.SCHEMA
|
||||
|
||||
|
||||
AddPartitionsToTxnRequest = [
|
||||
AddPartitionsToTxnRequest_v0, AddPartitionsToTxnRequest_v1, AddPartitionsToTxnRequest_v2,
|
||||
]
|
||||
AddPartitionsToTxnResponse = [
|
||||
AddPartitionsToTxnResponse_v0, AddPartitionsToTxnResponse_v1, AddPartitionsToTxnResponse_v2,
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.protocol.struct import Struct
|
||||
from kafka.protocol.types import Int16, Int32, String, Schema, Array, TaggedFields
|
||||
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
class RequestHeader(Struct):
|
||||
SCHEMA = Schema(
|
||||
('api_key', Int16),
|
||||
('api_version', Int16),
|
||||
('correlation_id', Int32),
|
||||
('client_id', String('utf-8'))
|
||||
)
|
||||
|
||||
def __init__(self, request, correlation_id=0, client_id='kafka-python'):
|
||||
super(RequestHeader, self).__init__(
|
||||
request.API_KEY, request.API_VERSION, correlation_id, client_id
|
||||
)
|
||||
|
||||
|
||||
class RequestHeaderV2(Struct):
|
||||
# Flexible response / request headers end in field buffer
|
||||
SCHEMA = Schema(
|
||||
('api_key', Int16),
|
||||
('api_version', Int16),
|
||||
('correlation_id', Int32),
|
||||
('client_id', String('utf-8')),
|
||||
('tags', TaggedFields),
|
||||
)
|
||||
|
||||
def __init__(self, request, correlation_id=0, client_id='kafka-python', tags=None):
|
||||
super(RequestHeaderV2, self).__init__(
|
||||
request.API_KEY, request.API_VERSION, correlation_id, client_id, tags or {}
|
||||
)
|
||||
|
||||
|
||||
class ResponseHeader(Struct):
|
||||
SCHEMA = Schema(
|
||||
('correlation_id', Int32),
|
||||
)
|
||||
|
||||
|
||||
class ResponseHeaderV2(Struct):
|
||||
SCHEMA = Schema(
|
||||
('correlation_id', Int32),
|
||||
('tags', TaggedFields),
|
||||
)
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class Request(Struct):
|
||||
FLEXIBLE_VERSION = False
|
||||
|
||||
@abc.abstractproperty
|
||||
def API_KEY(self):
|
||||
"""Integer identifier for api request"""
|
||||
pass
|
||||
|
||||
@abc.abstractproperty
|
||||
def API_VERSION(self):
|
||||
"""Integer of api request version"""
|
||||
pass
|
||||
|
||||
@abc.abstractproperty
|
||||
def SCHEMA(self):
|
||||
"""An instance of Schema() representing the request structure"""
|
||||
pass
|
||||
|
||||
@abc.abstractproperty
|
||||
def RESPONSE_TYPE(self):
|
||||
"""The Response class associated with the api request"""
|
||||
pass
|
||||
|
||||
def expect_response(self):
|
||||
"""Override this method if an api request does not always generate a response"""
|
||||
return True
|
||||
|
||||
def to_object(self):
|
||||
return _to_object(self.SCHEMA, self)
|
||||
|
||||
def build_header(self, correlation_id, client_id):
|
||||
if self.FLEXIBLE_VERSION:
|
||||
return RequestHeaderV2(self, correlation_id=correlation_id, client_id=client_id)
|
||||
return RequestHeader(self, correlation_id=correlation_id, client_id=client_id)
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class Response(Struct):
|
||||
FLEXIBLE_VERSION = False
|
||||
|
||||
@abc.abstractproperty
|
||||
def API_KEY(self):
|
||||
"""Integer identifier for api request/response"""
|
||||
pass
|
||||
|
||||
@abc.abstractproperty
|
||||
def API_VERSION(self):
|
||||
"""Integer of api request/response version"""
|
||||
pass
|
||||
|
||||
@abc.abstractproperty
|
||||
def SCHEMA(self):
|
||||
"""An instance of Schema() representing the response structure"""
|
||||
pass
|
||||
|
||||
def to_object(self):
|
||||
return _to_object(self.SCHEMA, self)
|
||||
|
||||
@classmethod
|
||||
def parse_header(cls, read_buffer):
|
||||
if cls.FLEXIBLE_VERSION:
|
||||
return ResponseHeaderV2.decode(read_buffer)
|
||||
return ResponseHeader.decode(read_buffer)
|
||||
|
||||
|
||||
def _to_object(schema, data):
|
||||
obj = {}
|
||||
for idx, (name, _type) in enumerate(zip(schema.names, schema.fields)):
|
||||
if isinstance(data, Struct):
|
||||
val = data.get_item(name)
|
||||
else:
|
||||
val = data[idx]
|
||||
|
||||
if isinstance(_type, Schema):
|
||||
obj[name] = _to_object(_type, val)
|
||||
elif isinstance(_type, Array):
|
||||
if isinstance(_type.array_of, (Array, Schema)):
|
||||
obj[name] = [
|
||||
_to_object(_type.array_of, x)
|
||||
for x in val
|
||||
]
|
||||
else:
|
||||
obj[name] = val
|
||||
else:
|
||||
obj[name] = val
|
||||
|
||||
return obj
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, CompactArray, CompactString, Int16, Int32, Schema, TaggedFields
|
||||
|
||||
|
||||
class BaseApiVersionsResponse(Response):
|
||||
API_KEY = 18
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('api_versions', Array(
|
||||
('api_key', Int16),
|
||||
('min_version', Int16),
|
||||
('max_version', Int16)))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
if isinstance(data, bytes):
|
||||
data = BytesIO(data)
|
||||
# Check error_code, decode as v0 if any error
|
||||
curr = data.tell()
|
||||
err = Int16.decode(data)
|
||||
data.seek(curr)
|
||||
if err != 0:
|
||||
return ApiVersionsResponse_v0.decode(data)
|
||||
return super(BaseApiVersionsResponse, cls).decode(data)
|
||||
|
||||
|
||||
class ApiVersionsResponse_v0(Response):
|
||||
API_KEY = 18
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('api_versions', Array(
|
||||
('api_key', Int16),
|
||||
('min_version', Int16),
|
||||
('max_version', Int16)))
|
||||
)
|
||||
|
||||
|
||||
class ApiVersionsResponse_v1(BaseApiVersionsResponse):
|
||||
API_KEY = 18
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('api_versions', Array(
|
||||
('api_key', Int16),
|
||||
('min_version', Int16),
|
||||
('max_version', Int16))),
|
||||
('throttle_time_ms', Int32)
|
||||
)
|
||||
|
||||
|
||||
class ApiVersionsResponse_v2(BaseApiVersionsResponse):
|
||||
API_KEY = 18
|
||||
API_VERSION = 2
|
||||
SCHEMA = ApiVersionsResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class ApiVersionsResponse_v3(BaseApiVersionsResponse):
|
||||
API_KEY = 18
|
||||
API_VERSION = 3
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('api_versions', CompactArray(
|
||||
('api_key', Int16),
|
||||
('min_version', Int16),
|
||||
('max_version', Int16),
|
||||
('_tagged_fields', TaggedFields))),
|
||||
('throttle_time_ms', Int32),
|
||||
('_tagged_fields', TaggedFields)
|
||||
)
|
||||
# Note: ApiVersions Response does not send FLEXIBLE_VERSION header!
|
||||
|
||||
|
||||
class ApiVersionsResponse_v4(BaseApiVersionsResponse):
|
||||
API_KEY = 18
|
||||
API_VERSION = 4
|
||||
SCHEMA = ApiVersionsResponse_v3.SCHEMA
|
||||
|
||||
|
||||
class ApiVersionsRequest_v0(Request):
|
||||
API_KEY = 18
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = ApiVersionsResponse_v0
|
||||
SCHEMA = Schema()
|
||||
|
||||
|
||||
class ApiVersionsRequest_v1(Request):
|
||||
API_KEY = 18
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = ApiVersionsResponse_v1
|
||||
SCHEMA = ApiVersionsRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class ApiVersionsRequest_v2(Request):
|
||||
API_KEY = 18
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = ApiVersionsResponse_v2
|
||||
SCHEMA = ApiVersionsRequest_v1.SCHEMA
|
||||
|
||||
|
||||
class ApiVersionsRequest_v3(Request):
|
||||
API_KEY = 18
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = ApiVersionsResponse_v3
|
||||
SCHEMA = Schema(
|
||||
('client_software_name', CompactString('utf-8')),
|
||||
('client_software_version', CompactString('utf-8')),
|
||||
('_tagged_fields', TaggedFields)
|
||||
)
|
||||
FLEXIBLE_VERSION = True
|
||||
|
||||
|
||||
class ApiVersionsRequest_v4(Request):
|
||||
API_KEY = 18
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = ApiVersionsResponse_v4
|
||||
SCHEMA = ApiVersionsRequest_v3.SCHEMA
|
||||
FLEXIBLE_VERSION = True
|
||||
|
||||
|
||||
ApiVersionsRequest = [
|
||||
ApiVersionsRequest_v0, ApiVersionsRequest_v1, ApiVersionsRequest_v2,
|
||||
ApiVersionsRequest_v3, ApiVersionsRequest_v4,
|
||||
]
|
||||
ApiVersionsResponse = [
|
||||
ApiVersionsResponse_v0, ApiVersionsResponse_v1, ApiVersionsResponse_v2,
|
||||
ApiVersionsResponse_v3, ApiVersionsResponse_v4,
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
BROKER_API_VERSIONS = {
|
||||
# api_versions responses prior to (0, 10) are synthesized for compatibility
|
||||
(0, 8, 0): {0: (0, 0), 1: (0, 0), 2: (0, 0), 3: (0, 0)},
|
||||
# adds offset commit + fetch
|
||||
(0, 8, 1): {0: (0, 0), 1: (0, 0), 2: (0, 0), 3: (0, 0), 8: (0, 0), 9: (0, 0)},
|
||||
# adds find coordinator
|
||||
(0, 8, 2): {0: (0, 0), 1: (0, 0), 2: (0, 0), 3: (0, 0), 8: (0, 1), 9: (0, 1), 10: (0, 0)},
|
||||
# adds group management (join/sync/leave/heartbeat)
|
||||
(0, 9): {0: (0, 1), 1: (0, 1), 2: (0, 0), 3: (0, 0), 8: (0, 2), 9: (0, 1), 10: (0, 0), 11: (0, 0), 12: (0, 0), 13: (0, 0), 14: (0, 0), 15: (0, 0), 16: (0, 0)},
|
||||
# adds message format v1, sasl, and api versions api
|
||||
(0, 10, 0): {0: (0, 2), 1: (0, 2), 2: (0, 0), 3: (0, 1), 4: (0, 0), 5: (0, 0), 6: (0, 2), 7: (1, 1), 8: (0, 2), 9: (0, 1), 10: (0, 0), 11: (0, 0), 12: (0, 0), 13: (0, 0), 14: (0, 0), 15: (0, 0), 16: (0, 0), 17: (0, 0), 18: (0, 0)},
|
||||
|
||||
# All data below is copied from brokers via api_versions_response (see make servers/*/api_versions)
|
||||
# adds admin apis create/delete topics, and bumps fetch/listoffsets/metadata/joingroup
|
||||
(0, 10, 1): {0: (0, 2), 1: (0, 3), 2: (0, 1), 3: (0, 2), 4: (0, 0), 5: (0, 0), 6: (0, 2), 7: (1, 1), 8: (0, 2), 9: (0, 1), 10: (0, 0), 11: (0, 1), 12: (0, 0), 13: (0, 0), 14: (0, 0), 15: (0, 0), 16: (0, 0), 17: (0, 0), 18: (0, 0), 19: (0, 0), 20: (0, 0)},
|
||||
|
||||
# bumps offsetfetch/create-topics
|
||||
(0, 10, 2): {0: (0, 2), 1: (0, 3), 2: (0, 1), 3: (0, 2), 4: (0, 0), 5: (0, 0), 6: (0, 3), 7: (1, 1), 8: (0, 2), 9: (0, 2), 10: (0, 0), 11: (0, 1), 12: (0, 0), 13: (0, 0), 14: (0, 0), 15: (0, 0), 16: (0, 0), 17: (0, 0), 18: (0, 0), 19: (0, 1), 20: (0, 0)},
|
||||
|
||||
# Adds message format v2, and more admin apis (describe/create/delete acls, describe/alter configs, etc)
|
||||
(0, 11): {0: (0, 3), 1: (0, 5), 2: (0, 2), 3: (0, 4), 4: (0, 0), 5: (0, 0), 6: (0, 3), 7: (1, 1), 8: (0, 3), 9: (0, 3), 10: (0, 1), 11: (0, 2), 12: (0, 1), 13: (0, 1), 14: (0, 1), 15: (0, 1), 16: (0, 1), 17: (0, 0), 18: (0, 1), 19: (0, 2), 20: (0, 1), 21: (0, 0), 22: (0, 0), 23: (0, 0), 24: (0, 0), 25: (0, 0), 26: (0, 0), 27: (0, 0), 28: (0, 0), 29: (0, 0), 30: (0, 0), 31: (0, 0), 32: (0, 0), 33: (0, 0)},
|
||||
|
||||
# Adds Sasl Authenticate, and additional admin apis (describe/alter log dirs, etc)
|
||||
(1, 0): {0: (0, 5), 1: (0, 6), 2: (0, 2), 3: (0, 5), 4: (0, 1), 5: (0, 0), 6: (0, 4), 7: (0, 1), 8: (0, 3), 9: (0, 3), 10: (0, 1), 11: (0, 2), 12: (0, 1), 13: (0, 1), 14: (0, 1), 15: (0, 1), 16: (0, 1), 17: (0, 1), 18: (0, 1), 19: (0, 2), 20: (0, 1), 21: (0, 0), 22: (0, 0), 23: (0, 0), 24: (0, 0), 25: (0, 0), 26: (0, 0), 27: (0, 0), 28: (0, 0), 29: (0, 0), 30: (0, 0), 31: (0, 0), 32: (0, 0), 33: (0, 0), 34: (0, 0), 35: (0, 0), 36: (0, 0), 37: (0, 0)},
|
||||
|
||||
(1, 1): {0: (0, 5), 1: (0, 7), 2: (0, 2), 3: (0, 5), 4: (0, 1), 5: (0, 0), 6: (0, 4), 7: (0, 1), 8: (0, 3), 9: (0, 3), 10: (0, 1), 11: (0, 2), 12: (0, 1), 13: (0, 1), 14: (0, 1), 15: (0, 1), 16: (0, 1), 17: (0, 1), 18: (0, 1), 19: (0, 2), 20: (0, 1), 21: (0, 0), 22: (0, 0), 23: (0, 0), 24: (0, 0), 25: (0, 0), 26: (0, 0), 27: (0, 0), 28: (0, 0), 29: (0, 0), 30: (0, 0), 31: (0, 0), 32: (0, 1), 33: (0, 0), 34: (0, 0), 35: (0, 0), 36: (0, 0), 37: (0, 0), 38: (0, 0), 39: (0, 0), 40: (0, 0), 41: (0, 0), 42: (0, 0)},
|
||||
|
||||
(2, 0): {0: (0, 6), 1: (0, 8), 2: (0, 3), 3: (0, 6), 4: (0, 1), 5: (0, 0), 6: (0, 4), 7: (0, 1), 8: (0, 4), 9: (0, 4), 10: (0, 2), 11: (0, 3), 12: (0, 2), 13: (0, 2), 14: (0, 2), 15: (0, 2), 16: (0, 2), 17: (0, 1), 18: (0, 2), 19: (0, 3), 20: (0, 2), 21: (0, 1), 22: (0, 1), 23: (0, 1), 24: (0, 1), 25: (0, 1), 26: (0, 1), 27: (0, 0), 28: (0, 1), 29: (0, 1), 30: (0, 1), 31: (0, 1), 32: (0, 2), 33: (0, 1), 34: (0, 1), 35: (0, 1), 36: (0, 0), 37: (0, 1), 38: (0, 1), 39: (0, 1), 40: (0, 1), 41: (0, 1), 42: (0, 1)},
|
||||
|
||||
(2, 1): {0: (0, 7), 1: (0, 10), 2: (0, 4), 3: (0, 7), 4: (0, 1), 5: (0, 0), 6: (0, 4), 7: (0, 1), 8: (0, 6), 9: (0, 5), 10: (0, 2), 11: (0, 3), 12: (0, 2), 13: (0, 2), 14: (0, 2), 15: (0, 2), 16: (0, 2), 17: (0, 1), 18: (0, 2), 19: (0, 3), 20: (0, 3), 21: (0, 1), 22: (0, 1), 23: (0, 2), 24: (0, 1), 25: (0, 1), 26: (0, 1), 27: (0, 0), 28: (0, 2), 29: (0, 1), 30: (0, 1), 31: (0, 1), 32: (0, 2), 33: (0, 1), 34: (0, 1), 35: (0, 1), 36: (0, 0), 37: (0, 1), 38: (0, 1), 39: (0, 1), 40: (0, 1), 41: (0, 1), 42: (0, 1)},
|
||||
|
||||
(2, 2): {0: (0, 7), 1: (0, 10), 2: (0, 5), 3: (0, 7), 4: (0, 2), 5: (0, 1), 6: (0, 5), 7: (0, 2), 8: (0, 6), 9: (0, 5), 10: (0, 2), 11: (0, 4), 12: (0, 2), 13: (0, 2), 14: (0, 2), 15: (0, 2), 16: (0, 2), 17: (0, 1), 18: (0, 2), 19: (0, 3), 20: (0, 3), 21: (0, 1), 22: (0, 1), 23: (0, 2), 24: (0, 1), 25: (0, 1), 26: (0, 1), 27: (0, 0), 28: (0, 2), 29: (0, 1), 30: (0, 1), 31: (0, 1), 32: (0, 2), 33: (0, 1), 34: (0, 1), 35: (0, 1), 36: (0, 1), 37: (0, 1), 38: (0, 1), 39: (0, 1), 40: (0, 1), 41: (0, 1), 42: (0, 1), 43: (0, 0)},
|
||||
|
||||
(2, 3): {0: (0, 7), 1: (0, 11), 2: (0, 5), 3: (0, 8), 4: (0, 2), 5: (0, 1), 6: (0, 5), 7: (0, 2), 8: (0, 7), 9: (0, 5), 10: (0, 2), 11: (0, 5), 12: (0, 3), 13: (0, 2), 14: (0, 3), 15: (0, 3), 16: (0, 2), 17: (0, 1), 18: (0, 2), 19: (0, 3), 20: (0, 3), 21: (0, 1), 22: (0, 1), 23: (0, 3), 24: (0, 1), 25: (0, 1), 26: (0, 1), 27: (0, 0), 28: (0, 2), 29: (0, 1), 30: (0, 1), 31: (0, 1), 32: (0, 2), 33: (0, 1), 34: (0, 1), 35: (0, 1), 36: (0, 1), 37: (0, 1), 38: (0, 1), 39: (0, 1), 40: (0, 1), 41: (0, 1), 42: (0, 1), 43: (0, 0), 44: (0, 0)},
|
||||
|
||||
(2, 4): {0: (0, 8), 1: (0, 11), 2: (0, 5), 3: (0, 9), 4: (0, 4), 5: (0, 2), 6: (0, 6), 7: (0, 3), 8: (0, 8), 9: (0, 6), 10: (0, 3), 11: (0, 6), 12: (0, 4), 13: (0, 4), 14: (0, 4), 15: (0, 5), 16: (0, 3), 17: (0, 1), 18: (0, 3), 19: (0, 5), 20: (0, 4), 21: (0, 1), 22: (0, 2), 23: (0, 3), 24: (0, 1), 25: (0, 1), 26: (0, 1), 27: (0, 0), 28: (0, 2), 29: (0, 1), 30: (0, 1), 31: (0, 1), 32: (0, 2), 33: (0, 1), 34: (0, 1), 35: (0, 1), 36: (0, 1), 37: (0, 1), 38: (0, 2), 39: (0, 1), 40: (0, 1), 41: (0, 1), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0)},
|
||||
|
||||
(2, 5): {0: (0, 8), 1: (0, 11), 2: (0, 5), 3: (0, 9), 4: (0, 4), 5: (0, 2), 6: (0, 6), 7: (0, 3), 8: (0, 8), 9: (0, 7), 10: (0, 3), 11: (0, 7), 12: (0, 4), 13: (0, 4), 14: (0, 5), 15: (0, 5), 16: (0, 3), 17: (0, 1), 18: (0, 3), 19: (0, 5), 20: (0, 4), 21: (0, 1), 22: (0, 3), 23: (0, 3), 24: (0, 1), 25: (0, 1), 26: (0, 1), 27: (0, 0), 28: (0, 3), 29: (0, 2), 30: (0, 2), 31: (0, 2), 32: (0, 2), 33: (0, 1), 34: (0, 1), 35: (0, 1), 36: (0, 2), 37: (0, 2), 38: (0, 2), 39: (0, 2), 40: (0, 2), 41: (0, 2), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0)},
|
||||
|
||||
(2, 6): {0: (0, 8), 1: (0, 11), 2: (0, 5), 3: (0, 9), 4: (0, 4), 5: (0, 3), 6: (0, 6), 7: (0, 3), 8: (0, 8), 9: (0, 7), 10: (0, 3), 11: (0, 7), 12: (0, 4), 13: (0, 4), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 5), 20: (0, 4), 21: (0, 2), 22: (0, 3), 23: (0, 3), 24: (0, 1), 25: (0, 1), 26: (0, 1), 27: (0, 0), 28: (0, 3), 29: (0, 2), 30: (0, 2), 31: (0, 2), 32: (0, 3), 33: (0, 1), 34: (0, 1), 35: (0, 2), 36: (0, 2), 37: (0, 2), 38: (0, 2), 39: (0, 2), 40: (0, 2), 41: (0, 2), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 0), 49: (0, 0)},
|
||||
|
||||
(2, 7): {0: (0, 8), 1: (0, 12), 2: (0, 5), 3: (0, 9), 4: (0, 4), 5: (0, 3), 6: (0, 6), 7: (0, 3), 8: (0, 8), 9: (0, 7), 10: (0, 3), 11: (0, 7), 12: (0, 4), 13: (0, 4), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 6), 20: (0, 5), 21: (0, 2), 22: (0, 4), 23: (0, 3), 24: (0, 2), 25: (0, 2), 26: (0, 2), 27: (0, 0), 28: (0, 3), 29: (0, 2), 30: (0, 2), 31: (0, 2), 32: (0, 3), 33: (0, 1), 34: (0, 1), 35: (0, 2), 36: (0, 2), 37: (0, 3), 38: (0, 2), 39: (0, 2), 40: (0, 2), 41: (0, 2), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 0), 49: (0, 0), 50: (0, 0), 51: (0, 0), 56: (0, 0), 57: (0, 0)},
|
||||
|
||||
(2, 8): {0: (0, 9), 1: (0, 12), 2: (0, 6), 3: (0, 11), 4: (0, 5), 5: (0, 3), 6: (0, 7), 7: (0, 3), 8: (0, 8), 9: (0, 7), 10: (0, 3), 11: (0, 7), 12: (0, 4), 13: (0, 4), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 3), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 2), 30: (0, 2), 31: (0, 2), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 2), 36: (0, 2), 37: (0, 3), 38: (0, 2), 39: (0, 2), 40: (0, 2), 41: (0, 2), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 0), 57: (0, 0), 60: (0, 0), 61: (0, 0)},
|
||||
|
||||
(3, 0): {0: (0, 9), 1: (0, 12), 2: (0, 7), 3: (0, 11), 4: (0, 5), 5: (0, 3), 6: (0, 7), 7: (0, 3), 8: (0, 8), 9: (0, 8), 10: (0, 4), 11: (0, 7), 12: (0, 4), 13: (0, 4), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 3), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 2), 30: (0, 2), 31: (0, 2), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 2), 36: (0, 2), 37: (0, 3), 38: (0, 2), 39: (0, 2), 40: (0, 2), 41: (0, 2), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 0), 57: (0, 0), 60: (0, 0), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0)},
|
||||
|
||||
(3, 1): {0: (0, 9), 1: (0, 13), 2: (0, 7), 3: (0, 12), 4: (0, 5), 5: (0, 3), 6: (0, 7), 7: (0, 3), 8: (0, 8), 9: (0, 8), 10: (0, 4), 11: (0, 7), 12: (0, 4), 13: (0, 4), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 3), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 2), 30: (0, 2), 31: (0, 2), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 2), 36: (0, 2), 37: (0, 3), 38: (0, 2), 39: (0, 2), 40: (0, 2), 41: (0, 2), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 0), 57: (0, 0), 60: (0, 0), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0)},
|
||||
|
||||
(3, 2): {0: (0, 9), 1: (0, 13), 2: (0, 7), 3: (0, 12), 4: (0, 6), 5: (0, 3), 6: (0, 7), 7: (0, 3), 8: (0, 8), 9: (0, 8), 10: (0, 4), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 3), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 2), 30: (0, 2), 31: (0, 2), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 3), 36: (0, 2), 37: (0, 3), 38: (0, 2), 39: (0, 2), 40: (0, 2), 41: (0, 2), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 1), 57: (0, 0), 60: (0, 0), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0)},
|
||||
|
||||
(3, 3): {0: (0, 9), 1: (0, 13), 2: (0, 7), 3: (0, 12), 4: (0, 6), 5: (0, 3), 6: (0, 7), 7: (0, 3), 8: (0, 8), 9: (0, 8), 10: (0, 4), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 3), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 3), 30: (0, 3), 31: (0, 3), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 4), 36: (0, 2), 37: (0, 3), 38: (0, 3), 39: (0, 2), 40: (0, 2), 41: (0, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 2), 57: (0, 1), 60: (0, 0), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0)},
|
||||
|
||||
(3, 4): {0: (0, 9), 1: (0, 13), 2: (0, 7), 3: (0, 12), 4: (0, 7), 5: (0, 4), 6: (0, 8), 7: (0, 3), 8: (0, 8), 9: (0, 8), 10: (0, 4), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 3), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 3), 30: (0, 3), 31: (0, 3), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 4), 36: (0, 2), 37: (0, 3), 38: (0, 3), 39: (0, 2), 40: (0, 2), 41: (0, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 2), 57: (0, 1), 58: (0, 0), 60: (0, 0), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0)},
|
||||
|
||||
(3, 5): {0: (0, 9), 1: (0, 15), 2: (0, 8), 3: (0, 12), 4: (0, 7), 5: (0, 4), 6: (0, 8), 7: (0, 3), 8: (0, 8), 9: (0, 8), 10: (0, 4), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 3), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 3), 30: (0, 3), 31: (0, 3), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 4), 36: (0, 2), 37: (0, 3), 38: (0, 3), 39: (0, 2), 40: (0, 2), 41: (0, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 3), 57: (0, 1), 58: (0, 0), 60: (0, 0), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0)},
|
||||
|
||||
(3, 6): {0: (0, 9), 1: (0, 15), 2: (0, 8), 3: (0, 12), 4: (0, 7), 5: (0, 4), 6: (0, 8), 7: (0, 3), 8: (0, 8), 9: (0, 8), 10: (0, 4), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 4), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 3), 30: (0, 3), 31: (0, 3), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 4), 36: (0, 2), 37: (0, 3), 38: (0, 3), 39: (0, 2), 40: (0, 2), 41: (0, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 3), 57: (0, 1), 58: (0, 0), 60: (0, 0), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0)},
|
||||
|
||||
(3, 7): {0: (0, 10), 1: (0, 16), 2: (0, 8), 3: (0, 12), 4: (0, 7), 5: (0, 4), 6: (0, 8), 7: (0, 3), 8: (0, 9), 9: (0, 9), 10: (0, 4), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 4), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 4), 23: (0, 4), 24: (0, 4), 25: (0, 3), 26: (0, 3), 27: (0, 1), 28: (0, 3), 29: (0, 3), 30: (0, 3), 31: (0, 3), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 4), 36: (0, 2), 37: (0, 3), 38: (0, 3), 39: (0, 2), 40: (0, 2), 41: (0, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 3), 57: (0, 1), 58: (0, 0), 60: (0, 1), 61: (0, 0), 65: (0, 0), 66: (0, 0), 67: (0, 0), 68: (0, 0)},
|
||||
|
||||
(3, 8): {0: (0, 11), 1: (0, 16), 2: (0, 8), 3: (0, 12), 4: (0, 7), 5: (0, 4), 6: (0, 8), 7: (0, 3), 8: (0, 9), 9: (0, 9), 10: (0, 5), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 5), 17: (0, 1), 18: (0, 3), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 5), 23: (0, 4), 24: (0, 5), 25: (0, 4), 26: (0, 4), 27: (0, 1), 28: (0, 4), 29: (0, 3), 30: (0, 3), 31: (0, 3), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 4), 36: (0, 2), 37: (0, 3), 38: (0, 3), 39: (0, 2), 40: (0, 2), 41: (0, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 3), 57: (0, 1), 58: (0, 0), 60: (0, 1), 61: (0, 0), 65: (0, 0), 66: (0, 1), 67: (0, 0), 68: (0, 0), 69: (0, 0)},
|
||||
|
||||
(3, 9): {0: (0, 11), 1: (0, 17), 2: (0, 9), 3: (0, 12), 4: (0, 7), 5: (0, 4), 6: (0, 8), 7: (0, 3), 8: (0, 9), 9: (0, 9), 10: (0, 6), 11: (0, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 5), 16: (0, 5), 17: (0, 1), 18: (0, 4), 19: (0, 7), 20: (0, 6), 21: (0, 2), 22: (0, 5), 23: (0, 4), 24: (0, 5), 25: (0, 4), 26: (0, 4), 27: (0, 1), 28: (0, 4), 29: (0, 3), 30: (0, 3), 31: (0, 3), 32: (0, 4), 33: (0, 2), 34: (0, 2), 35: (0, 4), 36: (0, 2), 37: (0, 3), 38: (0, 3), 39: (0, 2), 40: (0, 2), 41: (0, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 56: (0, 3), 57: (0, 1), 58: (0, 0), 60: (0, 1), 61: (0, 0), 65: (0, 0), 66: (0, 1), 67: (0, 0), 68: (0, 0), 69: (0, 0)},
|
||||
|
||||
(4, 0): {0: (0, 12), 1: (4, 17), 2: (1, 10), 3: (0, 13), 8: (2, 9), 9: (1, 9), 10: (0, 6), 11: (2, 9), 12: (0, 4), 13: (0, 5), 14: (0, 5), 15: (0, 6), 16: (0, 5), 17: (0, 1), 18: (0, 4), 19: (2, 7), 20: (1, 6), 21: (0, 2), 22: (0, 5), 23: (2, 4), 24: (0, 5), 25: (0, 4), 26: (0, 5), 27: (1, 1), 28: (0, 5), 29: (1, 3), 30: (1, 3), 31: (1, 3), 32: (1, 4), 33: (0, 2), 34: (1, 2), 35: (1, 4), 36: (0, 2), 37: (0, 3), 38: (1, 3), 39: (1, 2), 40: (1, 2), 41: (1, 3), 42: (0, 2), 43: (0, 2), 44: (0, 1), 45: (0, 0), 46: (0, 0), 47: (0, 0), 48: (0, 1), 49: (0, 1), 50: (0, 0), 51: (0, 0), 55: (0, 2), 57: (0, 2), 60: (0, 2), 61: (0, 0), 64: (0, 0), 65: (0, 0), 66: (0, 1), 68: (0, 1), 69: (0, 1), 74: (0, 0), 75: (0, 0), 80: (0, 0), 81: (0, 0)},
|
||||
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, Int16, Int32, Int64, Schema, String
|
||||
|
||||
|
||||
class OffsetCommitResponse_v0(Response):
|
||||
API_KEY = 8
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16)))))
|
||||
)
|
||||
|
||||
|
||||
class OffsetCommitResponse_v1(Response):
|
||||
API_KEY = 8
|
||||
API_VERSION = 1
|
||||
SCHEMA = OffsetCommitResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class OffsetCommitResponse_v2(Response):
|
||||
API_KEY = 8
|
||||
API_VERSION = 2
|
||||
SCHEMA = OffsetCommitResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class OffsetCommitResponse_v3(Response):
|
||||
API_KEY = 8
|
||||
API_VERSION = 3
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16)))))
|
||||
)
|
||||
|
||||
|
||||
class OffsetCommitResponse_v4(Response):
|
||||
API_KEY = 8
|
||||
API_VERSION = 4
|
||||
SCHEMA = OffsetCommitResponse_v3.SCHEMA
|
||||
|
||||
|
||||
class OffsetCommitResponse_v5(Response):
|
||||
API_KEY = 8
|
||||
API_VERSION = 5
|
||||
SCHEMA = OffsetCommitResponse_v4.SCHEMA
|
||||
|
||||
|
||||
class OffsetCommitResponse_v6(Response):
|
||||
API_KEY = 8
|
||||
API_VERSION = 6
|
||||
SCHEMA = OffsetCommitResponse_v5.SCHEMA
|
||||
|
||||
|
||||
class OffsetCommitRequest_v0(Request):
|
||||
API_KEY = 8
|
||||
API_VERSION = 0 # Zookeeper-backed storage
|
||||
RESPONSE_TYPE = OffsetCommitResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('consumer_group', String('utf-8')),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('metadata', String('utf-8'))))))
|
||||
)
|
||||
|
||||
|
||||
class OffsetCommitRequest_v1(Request):
|
||||
API_KEY = 8
|
||||
API_VERSION = 1 # Kafka-backed storage
|
||||
RESPONSE_TYPE = OffsetCommitResponse_v1
|
||||
SCHEMA = Schema(
|
||||
('consumer_group', String('utf-8')),
|
||||
('consumer_group_generation_id', Int32),
|
||||
('consumer_id', String('utf-8')),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('timestamp', Int64),
|
||||
('metadata', String('utf-8'))))))
|
||||
)
|
||||
|
||||
|
||||
class OffsetCommitRequest_v2(Request):
|
||||
API_KEY = 8
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = OffsetCommitResponse_v2
|
||||
SCHEMA = Schema(
|
||||
('consumer_group', String('utf-8')),
|
||||
('consumer_group_generation_id', Int32),
|
||||
('consumer_id', String('utf-8')),
|
||||
('retention_time', Int64), # added retention_time, dropped timestamp
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('metadata', String('utf-8'))))))
|
||||
)
|
||||
DEFAULT_RETENTION_TIME = -1
|
||||
|
||||
|
||||
class OffsetCommitRequest_v3(Request):
|
||||
API_KEY = 8
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = OffsetCommitResponse_v3
|
||||
SCHEMA = OffsetCommitRequest_v2.SCHEMA
|
||||
DEFAULT_RETENTION_TIME = -1
|
||||
|
||||
|
||||
class OffsetCommitRequest_v4(Request):
|
||||
API_KEY = 8
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = OffsetCommitResponse_v4
|
||||
SCHEMA = OffsetCommitRequest_v3.SCHEMA
|
||||
DEFAULT_RETENTION_TIME = -1
|
||||
|
||||
|
||||
class OffsetCommitRequest_v5(Request):
|
||||
API_KEY = 8
|
||||
API_VERSION = 5 # drops retention_time
|
||||
RESPONSE_TYPE = OffsetCommitResponse_v5
|
||||
SCHEMA = Schema(
|
||||
('consumer_group', String('utf-8')),
|
||||
('consumer_group_generation_id', Int32),
|
||||
('consumer_id', String('utf-8')),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('metadata', String('utf-8'))))))
|
||||
)
|
||||
|
||||
|
||||
class OffsetCommitRequest_v6(Request):
|
||||
API_KEY = 8
|
||||
API_VERSION = 6
|
||||
RESPONSE_TYPE = OffsetCommitResponse_v6
|
||||
SCHEMA = Schema(
|
||||
('consumer_group', String('utf-8')),
|
||||
('consumer_group_generation_id', Int32),
|
||||
('consumer_id', String('utf-8')),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('leader_epoch', Int32), # added for fencing / kip-320. default -1
|
||||
('metadata', String('utf-8'))))))
|
||||
)
|
||||
|
||||
|
||||
OffsetCommitRequest = [
|
||||
OffsetCommitRequest_v0, OffsetCommitRequest_v1,
|
||||
OffsetCommitRequest_v2, OffsetCommitRequest_v3,
|
||||
OffsetCommitRequest_v4, OffsetCommitRequest_v5,
|
||||
OffsetCommitRequest_v6,
|
||||
]
|
||||
OffsetCommitResponse = [
|
||||
OffsetCommitResponse_v0, OffsetCommitResponse_v1,
|
||||
OffsetCommitResponse_v2, OffsetCommitResponse_v3,
|
||||
OffsetCommitResponse_v4, OffsetCommitResponse_v5,
|
||||
OffsetCommitResponse_v6,
|
||||
]
|
||||
|
||||
|
||||
class OffsetFetchResponse_v0(Response):
|
||||
API_KEY = 9
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('metadata', String('utf-8')),
|
||||
('error_code', Int16)))))
|
||||
)
|
||||
|
||||
|
||||
class OffsetFetchResponse_v1(Response):
|
||||
API_KEY = 9
|
||||
API_VERSION = 1
|
||||
SCHEMA = OffsetFetchResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class OffsetFetchResponse_v2(Response):
|
||||
# Added in KIP-88
|
||||
API_KEY = 9
|
||||
API_VERSION = 2
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('metadata', String('utf-8')),
|
||||
('error_code', Int16))))),
|
||||
('error_code', Int16)
|
||||
)
|
||||
|
||||
|
||||
class OffsetFetchResponse_v3(Response):
|
||||
API_KEY = 9
|
||||
API_VERSION = 3
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('metadata', String('utf-8')),
|
||||
('error_code', Int16))))),
|
||||
('error_code', Int16)
|
||||
)
|
||||
|
||||
|
||||
class OffsetFetchResponse_v4(Response):
|
||||
API_KEY = 9
|
||||
API_VERSION = 4
|
||||
SCHEMA = OffsetFetchResponse_v3.SCHEMA
|
||||
|
||||
|
||||
class OffsetFetchResponse_v5(Response):
|
||||
API_KEY = 9
|
||||
API_VERSION = 5
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('leader_epoch', Int32),
|
||||
('metadata', String('utf-8')),
|
||||
('error_code', Int16))))),
|
||||
('error_code', Int16)
|
||||
)
|
||||
|
||||
|
||||
class OffsetFetchRequest_v0(Request):
|
||||
API_KEY = 9
|
||||
API_VERSION = 0 # zookeeper-backed storage
|
||||
RESPONSE_TYPE = OffsetFetchResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('consumer_group', String('utf-8')),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(Int32))))
|
||||
)
|
||||
|
||||
|
||||
class OffsetFetchRequest_v1(Request):
|
||||
API_KEY = 9
|
||||
API_VERSION = 1 # kafka-backed storage
|
||||
RESPONSE_TYPE = OffsetFetchResponse_v1
|
||||
SCHEMA = OffsetFetchRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class OffsetFetchRequest_v2(Request):
|
||||
# KIP-88: Allows passing null topics to return offsets for all partitions
|
||||
# that the consumer group has a stored offset for, even if no consumer in
|
||||
# the group is currently consuming that partition.
|
||||
API_KEY = 9
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = OffsetFetchResponse_v2
|
||||
SCHEMA = OffsetFetchRequest_v1.SCHEMA
|
||||
|
||||
|
||||
class OffsetFetchRequest_v3(Request):
|
||||
API_KEY = 9
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = OffsetFetchResponse_v3
|
||||
SCHEMA = OffsetFetchRequest_v2.SCHEMA
|
||||
|
||||
|
||||
class OffsetFetchRequest_v4(Request):
|
||||
API_KEY = 9
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = OffsetFetchResponse_v4
|
||||
SCHEMA = OffsetFetchRequest_v3.SCHEMA
|
||||
|
||||
|
||||
class OffsetFetchRequest_v5(Request):
|
||||
API_KEY = 9
|
||||
API_VERSION = 5
|
||||
RESPONSE_TYPE = OffsetFetchResponse_v5
|
||||
SCHEMA = OffsetFetchRequest_v4.SCHEMA
|
||||
|
||||
|
||||
OffsetFetchRequest = [
|
||||
OffsetFetchRequest_v0, OffsetFetchRequest_v1,
|
||||
OffsetFetchRequest_v2, OffsetFetchRequest_v3,
|
||||
OffsetFetchRequest_v4, OffsetFetchRequest_v5,
|
||||
]
|
||||
OffsetFetchResponse = [
|
||||
OffsetFetchResponse_v0, OffsetFetchResponse_v1,
|
||||
OffsetFetchResponse_v2, OffsetFetchResponse_v3,
|
||||
OffsetFetchResponse_v4, OffsetFetchResponse_v5,
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Boolean, Int16, Int32, Int64, Schema, String
|
||||
|
||||
|
||||
class EndTxnResponse_v0(Response):
|
||||
API_KEY = 26
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
)
|
||||
|
||||
|
||||
class EndTxnResponse_v1(Response):
|
||||
API_KEY = 26
|
||||
API_VERSION = 1
|
||||
SCHEMA = EndTxnResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class EndTxnResponse_v2(Response):
|
||||
API_KEY = 26
|
||||
API_VERSION = 2
|
||||
SCHEMA = EndTxnResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class EndTxnRequest_v0(Request):
|
||||
API_KEY = 26
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = EndTxnResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('transactional_id', String('utf-8')),
|
||||
('producer_id', Int64),
|
||||
('producer_epoch', Int16),
|
||||
('committed', Boolean))
|
||||
|
||||
|
||||
class EndTxnRequest_v1(Request):
|
||||
API_KEY = 26
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = EndTxnResponse_v1
|
||||
SCHEMA = EndTxnRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class EndTxnRequest_v2(Request):
|
||||
API_KEY = 26
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = EndTxnResponse_v2
|
||||
SCHEMA = EndTxnRequest_v1.SCHEMA
|
||||
|
||||
|
||||
EndTxnRequest = [
|
||||
EndTxnRequest_v0, EndTxnRequest_v1, EndTxnRequest_v2,
|
||||
]
|
||||
EndTxnResponse = [
|
||||
EndTxnResponse_v0, EndTxnResponse_v1, EndTxnResponse_v2,
|
||||
]
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, Int8, Int16, Int32, Int64, Schema, String, Bytes
|
||||
|
||||
|
||||
AbortedTransaction = collections.namedtuple("AbortedTransaction",
|
||||
["producer_id", "first_offset"])
|
||||
|
||||
|
||||
class FetchResponse_v0(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topics', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('highwater_offset', Int64),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchResponse_v1(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topics', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('highwater_offset', Int64),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchResponse_v2(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 2
|
||||
SCHEMA = FetchResponse_v1.SCHEMA # message format changed internally
|
||||
|
||||
|
||||
class FetchResponse_v3(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 3
|
||||
SCHEMA = FetchResponse_v2.SCHEMA
|
||||
|
||||
|
||||
class FetchResponse_v4(Response):
|
||||
# Adds message format v2
|
||||
API_KEY = 1
|
||||
API_VERSION = 4
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topics', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('highwater_offset', Int64),
|
||||
('last_stable_offset', Int64),
|
||||
('aborted_transactions', Array(
|
||||
('producer_id', Int64),
|
||||
('first_offset', Int64))),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchResponse_v5(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 5
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topics', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('highwater_offset', Int64),
|
||||
('last_stable_offset', Int64),
|
||||
('log_start_offset', Int64),
|
||||
('aborted_transactions', Array(
|
||||
('producer_id', Int64),
|
||||
('first_offset', Int64))),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchResponse_v6(Response):
|
||||
"""
|
||||
Same as FetchResponse_v5. The version number is bumped up to indicate that the client supports KafkaStorageException.
|
||||
The KafkaStorageException will be translated to NotLeaderForPartitionException in the response if version <= 5
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 6
|
||||
SCHEMA = FetchResponse_v5.SCHEMA
|
||||
|
||||
|
||||
class FetchResponse_v7(Response):
|
||||
"""
|
||||
Add error_code and session_id to response
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 7
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
('session_id', Int32),
|
||||
('topics', Array(
|
||||
('topics', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('highwater_offset', Int64),
|
||||
('last_stable_offset', Int64),
|
||||
('log_start_offset', Int64),
|
||||
('aborted_transactions', Array(
|
||||
('producer_id', Int64),
|
||||
('first_offset', Int64))),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchResponse_v8(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 8
|
||||
SCHEMA = FetchResponse_v7.SCHEMA
|
||||
|
||||
|
||||
class FetchResponse_v9(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 9
|
||||
SCHEMA = FetchResponse_v7.SCHEMA
|
||||
|
||||
|
||||
class FetchResponse_v10(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 10
|
||||
SCHEMA = FetchResponse_v7.SCHEMA
|
||||
|
||||
|
||||
class FetchResponse_v11(Response):
|
||||
API_KEY = 1
|
||||
API_VERSION = 11
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
('session_id', Int32),
|
||||
('topics', Array(
|
||||
('topics', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('highwater_offset', Int64),
|
||||
('last_stable_offset', Int64),
|
||||
('log_start_offset', Int64),
|
||||
('aborted_transactions', Array(
|
||||
('producer_id', Int64),
|
||||
('first_offset', Int64))),
|
||||
('preferred_read_replica', Int32),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchRequest_v0(Request):
|
||||
API_KEY = 1
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = FetchResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('max_wait_time', Int32),
|
||||
('min_bytes', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('max_bytes', Int32)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchRequest_v1(Request):
|
||||
API_KEY = 1
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = FetchResponse_v1
|
||||
SCHEMA = FetchRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class FetchRequest_v2(Request):
|
||||
API_KEY = 1
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = FetchResponse_v2
|
||||
SCHEMA = FetchRequest_v1.SCHEMA
|
||||
|
||||
|
||||
class FetchRequest_v3(Request):
|
||||
API_KEY = 1
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = FetchResponse_v3
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('max_wait_time', Int32),
|
||||
('min_bytes', Int32),
|
||||
('max_bytes', Int32), # This new field is only difference from FR_v2
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('max_bytes', Int32)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchRequest_v4(Request):
|
||||
# Adds isolation_level field
|
||||
# Adds message format v2
|
||||
API_KEY = 1
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = FetchResponse_v4
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('max_wait_time', Int32),
|
||||
('min_bytes', Int32),
|
||||
('max_bytes', Int32),
|
||||
('isolation_level', Int8),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('max_bytes', Int32)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchRequest_v5(Request):
|
||||
# This may only be used in broker-broker api calls
|
||||
API_KEY = 1
|
||||
API_VERSION = 5
|
||||
RESPONSE_TYPE = FetchResponse_v5
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('max_wait_time', Int32),
|
||||
('min_bytes', Int32),
|
||||
('max_bytes', Int32),
|
||||
('isolation_level', Int8),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('fetch_offset', Int64),
|
||||
('log_start_offset', Int64),
|
||||
('max_bytes', Int32)))))
|
||||
)
|
||||
|
||||
|
||||
class FetchRequest_v6(Request):
|
||||
"""
|
||||
The body of FETCH_REQUEST_V6 is the same as FETCH_REQUEST_V5.
|
||||
The version number is bumped up to indicate that the client supports KafkaStorageException.
|
||||
The KafkaStorageException will be translated to NotLeaderForPartitionException in the response if version <= 5
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 6
|
||||
RESPONSE_TYPE = FetchResponse_v6
|
||||
SCHEMA = FetchRequest_v5.SCHEMA
|
||||
|
||||
|
||||
class FetchRequest_v7(Request):
|
||||
"""
|
||||
Add incremental fetch requests (see KIP-227)
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 7
|
||||
RESPONSE_TYPE = FetchResponse_v7
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('max_wait_time', Int32),
|
||||
('min_bytes', Int32),
|
||||
('max_bytes', Int32),
|
||||
('isolation_level', Int8),
|
||||
('session_id', Int32),
|
||||
('session_epoch', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('fetch_offset', Int64),
|
||||
('log_start_offset', Int64),
|
||||
('max_bytes', Int32))))),
|
||||
('forgotten_topics_data', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(Int32))
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
class FetchRequest_v8(Request):
|
||||
"""
|
||||
bump used to indicate that on quota violation brokers send out responses before throttling.
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 8
|
||||
RESPONSE_TYPE = FetchResponse_v8
|
||||
SCHEMA = FetchRequest_v7.SCHEMA
|
||||
|
||||
|
||||
class FetchRequest_v9(Request):
|
||||
"""
|
||||
adds the current leader epoch (see KIP-320)
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 9
|
||||
RESPONSE_TYPE = FetchResponse_v9
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('max_wait_time', Int32),
|
||||
('min_bytes', Int32),
|
||||
('max_bytes', Int32),
|
||||
('isolation_level', Int8),
|
||||
('session_id', Int32),
|
||||
('session_epoch', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('current_leader_epoch', Int32),
|
||||
('fetch_offset', Int64),
|
||||
('log_start_offset', Int64),
|
||||
('max_bytes', Int32))))),
|
||||
('forgotten_topics_data', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(Int32)),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
class FetchRequest_v10(Request):
|
||||
"""
|
||||
bumped up to indicate ZStandard capability. (see KIP-110)
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 10
|
||||
RESPONSE_TYPE = FetchResponse_v10
|
||||
SCHEMA = FetchRequest_v9.SCHEMA
|
||||
|
||||
|
||||
class FetchRequest_v11(Request):
|
||||
"""
|
||||
added rack ID to support read from followers (KIP-392)
|
||||
"""
|
||||
API_KEY = 1
|
||||
API_VERSION = 11
|
||||
RESPONSE_TYPE = FetchResponse_v11
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('max_wait_time', Int32),
|
||||
('min_bytes', Int32),
|
||||
('max_bytes', Int32),
|
||||
('isolation_level', Int8),
|
||||
('session_id', Int32),
|
||||
('session_epoch', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('current_leader_epoch', Int32),
|
||||
('fetch_offset', Int64),
|
||||
('log_start_offset', Int64),
|
||||
('max_bytes', Int32))))),
|
||||
('forgotten_topics_data', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(Int32))
|
||||
)),
|
||||
('rack_id', String('utf-8')),
|
||||
)
|
||||
|
||||
|
||||
FetchRequest = [
|
||||
FetchRequest_v0, FetchRequest_v1, FetchRequest_v2,
|
||||
FetchRequest_v3, FetchRequest_v4, FetchRequest_v5,
|
||||
FetchRequest_v6, FetchRequest_v7, FetchRequest_v8,
|
||||
FetchRequest_v9, FetchRequest_v10, FetchRequest_v11,
|
||||
]
|
||||
FetchResponse = [
|
||||
FetchResponse_v0, FetchResponse_v1, FetchResponse_v2,
|
||||
FetchResponse_v3, FetchResponse_v4, FetchResponse_v5,
|
||||
FetchResponse_v6, FetchResponse_v7, FetchResponse_v8,
|
||||
FetchResponse_v9, FetchResponse_v10, FetchResponse_v11,
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Int8, Int16, Int32, Schema, String
|
||||
|
||||
|
||||
class FindCoordinatorResponse_v0(Response):
|
||||
API_KEY = 10
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('coordinator_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32)
|
||||
)
|
||||
|
||||
|
||||
class FindCoordinatorResponse_v1(Response):
|
||||
API_KEY = 10
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
('error_message', String('utf-8')),
|
||||
('coordinator_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32)
|
||||
)
|
||||
|
||||
|
||||
class FindCoordinatorResponse_v2(Response):
|
||||
API_KEY = 10
|
||||
API_VERSION = 2
|
||||
SCHEMA = FindCoordinatorResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class FindCoordinatorRequest_v0(Request):
|
||||
API_KEY = 10
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = FindCoordinatorResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('consumer_group', String('utf-8'))
|
||||
)
|
||||
|
||||
|
||||
class FindCoordinatorRequest_v1(Request):
|
||||
API_KEY = 10
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = FindCoordinatorResponse_v1
|
||||
SCHEMA = Schema(
|
||||
('coordinator_key', String('utf-8')),
|
||||
('coordinator_type', Int8) # 0: consumer, 1: transaction
|
||||
)
|
||||
|
||||
|
||||
class FindCoordinatorRequest_v2(Request):
|
||||
API_KEY = 10
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = FindCoordinatorResponse_v2
|
||||
SCHEMA = FindCoordinatorRequest_v1.SCHEMA
|
||||
|
||||
|
||||
FindCoordinatorRequest = [FindCoordinatorRequest_v0, FindCoordinatorRequest_v1, FindCoordinatorRequest_v2]
|
||||
FindCoordinatorResponse = [FindCoordinatorResponse_v0, FindCoordinatorResponse_v1, FindCoordinatorResponse_v2]
|
||||
@@ -0,0 +1,30 @@
|
||||
class KafkaBytes(bytearray):
|
||||
def __init__(self, size):
|
||||
super(KafkaBytes, self).__init__(size)
|
||||
self._idx = 0
|
||||
|
||||
def read(self, nbytes=None):
|
||||
if nbytes is None:
|
||||
nbytes = len(self) - self._idx
|
||||
start = self._idx
|
||||
self._idx += nbytes
|
||||
if self._idx > len(self):
|
||||
self._idx = len(self)
|
||||
return bytes(self[start:self._idx])
|
||||
|
||||
def write(self, data):
|
||||
start = self._idx
|
||||
self._idx += len(data)
|
||||
self[start:self._idx] = data
|
||||
|
||||
def seek(self, idx):
|
||||
self._idx = idx
|
||||
|
||||
def tell(self):
|
||||
return self._idx
|
||||
|
||||
def __str__(self):
|
||||
return 'KafkaBytes(%d)' % len(self)
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
@@ -0,0 +1,298 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.struct import Struct
|
||||
from kafka.protocol.types import Array, Bytes, Int16, Int32, Schema, String
|
||||
|
||||
|
||||
DEFAULT_GENERATION_ID = -1
|
||||
UNKNOWN_MEMBER_ID = ''
|
||||
|
||||
|
||||
class JoinGroupResponse_v0(Response):
|
||||
API_KEY = 11
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('generation_id', Int32),
|
||||
('group_protocol', String('utf-8')),
|
||||
('leader_id', String('utf-8')),
|
||||
('member_id', String('utf-8')),
|
||||
('members', Array(
|
||||
('member_id', String('utf-8')),
|
||||
('member_metadata', Bytes)))
|
||||
)
|
||||
|
||||
|
||||
class JoinGroupResponse_v1(Response):
|
||||
API_KEY = 11
|
||||
API_VERSION = 1
|
||||
SCHEMA = JoinGroupResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class JoinGroupResponse_v2(Response):
|
||||
API_KEY = 11
|
||||
API_VERSION = 2
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
('generation_id', Int32),
|
||||
('group_protocol', String('utf-8')),
|
||||
('leader_id', String('utf-8')),
|
||||
('member_id', String('utf-8')),
|
||||
('members', Array(
|
||||
('member_id', String('utf-8')),
|
||||
('member_metadata', Bytes)))
|
||||
)
|
||||
|
||||
|
||||
class JoinGroupResponse_v3(Response):
|
||||
API_KEY = 11
|
||||
API_VERSION = 3
|
||||
SCHEMA = JoinGroupResponse_v2.SCHEMA
|
||||
|
||||
|
||||
class JoinGroupResponse_v4(Response):
|
||||
API_KEY = 11
|
||||
API_VERSION = 4
|
||||
SCHEMA = JoinGroupResponse_v3.SCHEMA
|
||||
|
||||
|
||||
class JoinGroupRequest_v0(Request):
|
||||
API_KEY = 11
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = JoinGroupResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('group', String('utf-8')),
|
||||
('session_timeout', Int32),
|
||||
('member_id', String('utf-8')),
|
||||
('protocol_type', String('utf-8')),
|
||||
('group_protocols', Array(
|
||||
('protocol_name', String('utf-8')),
|
||||
('protocol_metadata', Bytes)))
|
||||
)
|
||||
|
||||
|
||||
class JoinGroupRequest_v1(Request):
|
||||
API_KEY = 11
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = JoinGroupResponse_v1
|
||||
SCHEMA = Schema(
|
||||
('group', String('utf-8')),
|
||||
('session_timeout', Int32),
|
||||
('rebalance_timeout', Int32),
|
||||
('member_id', String('utf-8')),
|
||||
('protocol_type', String('utf-8')),
|
||||
('group_protocols', Array(
|
||||
('protocol_name', String('utf-8')),
|
||||
('protocol_metadata', Bytes)))
|
||||
)
|
||||
|
||||
|
||||
class JoinGroupRequest_v2(Request):
|
||||
API_KEY = 11
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = JoinGroupResponse_v2
|
||||
SCHEMA = JoinGroupRequest_v1.SCHEMA
|
||||
|
||||
|
||||
class JoinGroupRequest_v3(Request):
|
||||
API_KEY = 11
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = JoinGroupResponse_v3
|
||||
SCHEMA = JoinGroupRequest_v2.SCHEMA
|
||||
|
||||
|
||||
class JoinGroupRequest_v4(Request):
|
||||
API_KEY = 11
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = JoinGroupResponse_v4
|
||||
SCHEMA = JoinGroupRequest_v3.SCHEMA
|
||||
|
||||
|
||||
JoinGroupRequest = [
|
||||
JoinGroupRequest_v0, JoinGroupRequest_v1, JoinGroupRequest_v2,
|
||||
JoinGroupRequest_v3, JoinGroupRequest_v4,
|
||||
]
|
||||
JoinGroupResponse = [
|
||||
JoinGroupResponse_v0, JoinGroupResponse_v1, JoinGroupResponse_v2,
|
||||
JoinGroupResponse_v3, JoinGroupResponse_v4,
|
||||
]
|
||||
|
||||
|
||||
class ProtocolMetadata(Struct):
|
||||
SCHEMA = Schema(
|
||||
('version', Int16),
|
||||
('subscription', Array(String('utf-8'))), # topics list
|
||||
('user_data', Bytes)
|
||||
)
|
||||
|
||||
|
||||
class SyncGroupResponse_v0(Response):
|
||||
API_KEY = 14
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('member_assignment', Bytes)
|
||||
)
|
||||
|
||||
|
||||
class SyncGroupResponse_v1(Response):
|
||||
API_KEY = 14
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
('member_assignment', Bytes)
|
||||
)
|
||||
|
||||
|
||||
class SyncGroupResponse_v2(Response):
|
||||
API_KEY = 14
|
||||
API_VERSION = 2
|
||||
SCHEMA = SyncGroupResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class SyncGroupRequest_v0(Request):
|
||||
API_KEY = 14
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = SyncGroupResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('group', String('utf-8')),
|
||||
('generation_id', Int32),
|
||||
('member_id', String('utf-8')),
|
||||
('group_assignment', Array(
|
||||
('member_id', String('utf-8')),
|
||||
('member_metadata', Bytes)))
|
||||
)
|
||||
|
||||
|
||||
class SyncGroupRequest_v1(Request):
|
||||
API_KEY = 14
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = SyncGroupResponse_v1
|
||||
SCHEMA = SyncGroupRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class SyncGroupRequest_v2(Request):
|
||||
API_KEY = 14
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = SyncGroupResponse_v2
|
||||
SCHEMA = SyncGroupRequest_v1.SCHEMA
|
||||
|
||||
|
||||
SyncGroupRequest = [SyncGroupRequest_v0, SyncGroupRequest_v1, SyncGroupRequest_v2]
|
||||
SyncGroupResponse = [SyncGroupResponse_v0, SyncGroupResponse_v1, SyncGroupResponse_v2]
|
||||
|
||||
|
||||
class MemberAssignment(Struct):
|
||||
SCHEMA = Schema(
|
||||
('version', Int16),
|
||||
('assignment', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(Int32)))),
|
||||
('user_data', Bytes)
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatResponse_v0(Response):
|
||||
API_KEY = 12
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16)
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatResponse_v1(Response):
|
||||
API_KEY = 12
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16)
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatResponse_v2(Response):
|
||||
API_KEY = 12
|
||||
API_VERSION = 2
|
||||
SCHEMA = HeartbeatResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class HeartbeatRequest_v0(Request):
|
||||
API_KEY = 12
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = HeartbeatResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('group', String('utf-8')),
|
||||
('generation_id', Int32),
|
||||
('member_id', String('utf-8'))
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatRequest_v1(Request):
|
||||
API_KEY = 12
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = HeartbeatResponse_v1
|
||||
SCHEMA = HeartbeatRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class HeartbeatRequest_v2(Request):
|
||||
API_KEY = 12
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = HeartbeatResponse_v2
|
||||
SCHEMA = HeartbeatRequest_v1.SCHEMA
|
||||
|
||||
|
||||
HeartbeatRequest = [HeartbeatRequest_v0, HeartbeatRequest_v1, HeartbeatRequest_v2]
|
||||
HeartbeatResponse = [HeartbeatResponse_v0, HeartbeatResponse_v1, HeartbeatResponse_v2]
|
||||
|
||||
|
||||
class LeaveGroupResponse_v0(Response):
|
||||
API_KEY = 13
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16)
|
||||
)
|
||||
|
||||
|
||||
class LeaveGroupResponse_v1(Response):
|
||||
API_KEY = 13
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16)
|
||||
)
|
||||
|
||||
|
||||
class LeaveGroupResponse_v2(Response):
|
||||
API_KEY = 13
|
||||
API_VERSION = 2
|
||||
SCHEMA = LeaveGroupResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class LeaveGroupRequest_v0(Request):
|
||||
API_KEY = 13
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = LeaveGroupResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('group', String('utf-8')),
|
||||
('member_id', String('utf-8'))
|
||||
)
|
||||
|
||||
|
||||
class LeaveGroupRequest_v1(Request):
|
||||
API_KEY = 13
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = LeaveGroupResponse_v1
|
||||
SCHEMA = LeaveGroupRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class LeaveGroupRequest_v2(Request):
|
||||
API_KEY = 13
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = LeaveGroupResponse_v2
|
||||
SCHEMA = LeaveGroupRequest_v1.SCHEMA
|
||||
|
||||
|
||||
LeaveGroupRequest = [LeaveGroupRequest_v0, LeaveGroupRequest_v1, LeaveGroupRequest_v2]
|
||||
LeaveGroupResponse = [LeaveGroupResponse_v0, LeaveGroupResponse_v1, LeaveGroupResponse_v2]
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Int16, Int32, Int64, Schema, String
|
||||
|
||||
|
||||
class InitProducerIdResponse_v0(Response):
|
||||
API_KEY = 22
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('error_code', Int16),
|
||||
('producer_id', Int64),
|
||||
('producer_epoch', Int16),
|
||||
)
|
||||
|
||||
|
||||
class InitProducerIdResponse_v1(Response):
|
||||
API_KEY = 22
|
||||
API_VERSION = 1
|
||||
SCHEMA = InitProducerIdResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class InitProducerIdRequest_v0(Request):
|
||||
API_KEY = 22
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = InitProducerIdResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('transactional_id', String('utf-8')),
|
||||
('transaction_timeout_ms', Int32),
|
||||
)
|
||||
|
||||
|
||||
class InitProducerIdRequest_v1(Request):
|
||||
API_KEY = 22
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = InitProducerIdResponse_v1
|
||||
SCHEMA = InitProducerIdRequest_v0.SCHEMA
|
||||
|
||||
|
||||
InitProducerIdRequest = [
|
||||
InitProducerIdRequest_v0, InitProducerIdRequest_v1,
|
||||
]
|
||||
InitProducerIdResponse = [
|
||||
InitProducerIdResponse_v0, InitProducerIdResponse_v1,
|
||||
]
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, Int8, Int16, Int32, Int64, Schema, String
|
||||
|
||||
UNKNOWN_OFFSET = -1
|
||||
|
||||
|
||||
class OffsetResetStrategy(object):
|
||||
LATEST = -1
|
||||
EARLIEST = -2
|
||||
NONE = 0
|
||||
|
||||
|
||||
class ListOffsetsResponse_v0(Response):
|
||||
API_KEY = 2
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('offsets', Array(Int64))))))
|
||||
)
|
||||
|
||||
class ListOffsetsResponse_v1(Response):
|
||||
API_KEY = 2
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('timestamp', Int64),
|
||||
('offset', Int64)))))
|
||||
)
|
||||
|
||||
|
||||
class ListOffsetsResponse_v2(Response):
|
||||
API_KEY = 2
|
||||
API_VERSION = 2
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('timestamp', Int64),
|
||||
('offset', Int64)))))
|
||||
)
|
||||
|
||||
|
||||
class ListOffsetsResponse_v3(Response):
|
||||
"""
|
||||
on quota violation, brokers send out responses before throttling
|
||||
"""
|
||||
API_KEY = 2
|
||||
API_VERSION = 3
|
||||
SCHEMA = ListOffsetsResponse_v2.SCHEMA
|
||||
|
||||
|
||||
class ListOffsetsResponse_v4(Response):
|
||||
"""
|
||||
Add leader_epoch to response
|
||||
"""
|
||||
API_KEY = 2
|
||||
API_VERSION = 4
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('timestamp', Int64),
|
||||
('offset', Int64),
|
||||
('leader_epoch', Int32)))))
|
||||
)
|
||||
|
||||
|
||||
class ListOffsetsResponse_v5(Response):
|
||||
"""
|
||||
adds a new error code, OFFSET_NOT_AVAILABLE
|
||||
"""
|
||||
API_KEY = 2
|
||||
API_VERSION = 5
|
||||
SCHEMA = ListOffsetsResponse_v4.SCHEMA
|
||||
|
||||
|
||||
class ListOffsetsRequest_v0(Request):
|
||||
API_KEY = 2
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = ListOffsetsResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('timestamp', Int64),
|
||||
('max_offsets', Int32)))))
|
||||
)
|
||||
DEFAULTS = {
|
||||
'replica_id': -1
|
||||
}
|
||||
|
||||
class ListOffsetsRequest_v1(Request):
|
||||
API_KEY = 2
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = ListOffsetsResponse_v1
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('timestamp', Int64)))))
|
||||
)
|
||||
DEFAULTS = {
|
||||
'replica_id': -1
|
||||
}
|
||||
|
||||
|
||||
class ListOffsetsRequest_v2(Request):
|
||||
API_KEY = 2
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = ListOffsetsResponse_v2
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('isolation_level', Int8), # <- added isolation_level
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('timestamp', Int64)))))
|
||||
)
|
||||
DEFAULTS = {
|
||||
'replica_id': -1
|
||||
}
|
||||
|
||||
|
||||
class ListOffsetsRequest_v3(Request):
|
||||
API_KEY = 2
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = ListOffsetsResponse_v3
|
||||
SCHEMA = ListOffsetsRequest_v2.SCHEMA
|
||||
DEFAULTS = {
|
||||
'replica_id': -1
|
||||
}
|
||||
|
||||
|
||||
class ListOffsetsRequest_v4(Request):
|
||||
"""
|
||||
Add current_leader_epoch to request
|
||||
"""
|
||||
API_KEY = 2
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = ListOffsetsResponse_v4
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('isolation_level', Int8), # <- added isolation_level
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('current_leader_epoch', Int32),
|
||||
('timestamp', Int64)))))
|
||||
)
|
||||
DEFAULTS = {
|
||||
'replica_id': -1
|
||||
}
|
||||
|
||||
|
||||
class ListOffsetsRequest_v5(Request):
|
||||
API_KEY = 2
|
||||
API_VERSION = 5
|
||||
RESPONSE_TYPE = ListOffsetsResponse_v5
|
||||
SCHEMA = ListOffsetsRequest_v4.SCHEMA
|
||||
DEFAULTS = {
|
||||
'replica_id': -1
|
||||
}
|
||||
|
||||
|
||||
ListOffsetsRequest = [
|
||||
ListOffsetsRequest_v0, ListOffsetsRequest_v1, ListOffsetsRequest_v2,
|
||||
ListOffsetsRequest_v3, ListOffsetsRequest_v4, ListOffsetsRequest_v5,
|
||||
]
|
||||
ListOffsetsResponse = [
|
||||
ListOffsetsResponse_v0, ListOffsetsResponse_v1, ListOffsetsResponse_v2,
|
||||
ListOffsetsResponse_v3, ListOffsetsResponse_v4, ListOffsetsResponse_v5,
|
||||
]
|
||||
@@ -0,0 +1,216 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import io
|
||||
import time
|
||||
|
||||
from kafka.codec import (has_gzip, has_snappy, has_lz4, has_zstd,
|
||||
gzip_decode, snappy_decode, zstd_decode,
|
||||
lz4_decode, lz4_decode_old_kafka)
|
||||
from kafka.protocol.frame import KafkaBytes
|
||||
from kafka.protocol.struct import Struct
|
||||
from kafka.protocol.types import (
|
||||
Int8, Int32, Int64, Bytes, Schema, AbstractType
|
||||
)
|
||||
from kafka.util import crc32, WeakMethod
|
||||
|
||||
|
||||
class Message(Struct):
|
||||
SCHEMAS = [
|
||||
Schema(
|
||||
('crc', Int32),
|
||||
('magic', Int8),
|
||||
('attributes', Int8),
|
||||
('key', Bytes),
|
||||
('value', Bytes)),
|
||||
Schema(
|
||||
('crc', Int32),
|
||||
('magic', Int8),
|
||||
('attributes', Int8),
|
||||
('timestamp', Int64),
|
||||
('key', Bytes),
|
||||
('value', Bytes)),
|
||||
]
|
||||
SCHEMA = SCHEMAS[1]
|
||||
CODEC_MASK = 0x07
|
||||
CODEC_GZIP = 0x01
|
||||
CODEC_SNAPPY = 0x02
|
||||
CODEC_LZ4 = 0x03
|
||||
CODEC_ZSTD = 0x04
|
||||
TIMESTAMP_TYPE_MASK = 0x08
|
||||
HEADER_SIZE = 22 # crc(4), magic(1), attributes(1), timestamp(8), key+value size(4*2)
|
||||
|
||||
def __init__(self, value, key=None, magic=0, attributes=0, crc=0,
|
||||
timestamp=None):
|
||||
assert value is None or isinstance(value, bytes), 'value must be bytes'
|
||||
assert key is None or isinstance(key, bytes), 'key must be bytes'
|
||||
assert magic > 0 or timestamp is None, 'timestamp not supported in v0'
|
||||
|
||||
# Default timestamp to now for v1 messages
|
||||
if magic > 0 and timestamp is None:
|
||||
timestamp = int(time.time() * 1000)
|
||||
self.timestamp = timestamp
|
||||
self.crc = crc
|
||||
self._validated_crc = None
|
||||
self.magic = magic
|
||||
self.attributes = attributes
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.encode = WeakMethod(self._encode_self)
|
||||
|
||||
@property
|
||||
def timestamp_type(self):
|
||||
"""0 for CreateTime; 1 for LogAppendTime; None if unsupported.
|
||||
|
||||
Value is determined by broker; produced messages should always set to 0
|
||||
Requires Kafka >= 0.10 / message version >= 1
|
||||
"""
|
||||
if self.magic == 0:
|
||||
return None
|
||||
elif self.attributes & self.TIMESTAMP_TYPE_MASK:
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def _encode_self(self, recalc_crc=True):
|
||||
version = self.magic
|
||||
if version == 1:
|
||||
fields = (self.crc, self.magic, self.attributes, self.timestamp, self.key, self.value)
|
||||
elif version == 0:
|
||||
fields = (self.crc, self.magic, self.attributes, self.key, self.value)
|
||||
else:
|
||||
raise ValueError('Unrecognized message version: %s' % (version,))
|
||||
message = Message.SCHEMAS[version].encode(fields)
|
||||
if not recalc_crc:
|
||||
return message
|
||||
self.crc = crc32(message[4:])
|
||||
crc_field = self.SCHEMAS[version].fields[0]
|
||||
return crc_field.encode(self.crc) + message[4:]
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
_validated_crc = None
|
||||
if isinstance(data, bytes):
|
||||
_validated_crc = crc32(data[4:])
|
||||
data = io.BytesIO(data)
|
||||
# Partial decode required to determine message version
|
||||
base_fields = cls.SCHEMAS[0].fields[0:3]
|
||||
crc, magic, attributes = [field.decode(data) for field in base_fields]
|
||||
remaining = cls.SCHEMAS[magic].fields[3:]
|
||||
fields = [field.decode(data) for field in remaining]
|
||||
if magic == 1:
|
||||
timestamp = fields[0]
|
||||
else:
|
||||
timestamp = None
|
||||
msg = cls(fields[-1], key=fields[-2],
|
||||
magic=magic, attributes=attributes, crc=crc,
|
||||
timestamp=timestamp)
|
||||
msg._validated_crc = _validated_crc
|
||||
return msg
|
||||
|
||||
def validate_crc(self):
|
||||
if self._validated_crc is None:
|
||||
raw_msg = self._encode_self(recalc_crc=False)
|
||||
self._validated_crc = crc32(raw_msg[4:])
|
||||
if self.crc == self._validated_crc:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_compressed(self):
|
||||
return self.attributes & self.CODEC_MASK != 0
|
||||
|
||||
def decompress(self):
|
||||
codec = self.attributes & self.CODEC_MASK
|
||||
assert codec in (self.CODEC_GZIP, self.CODEC_SNAPPY, self.CODEC_LZ4, self.CODEC_ZSTD)
|
||||
if codec == self.CODEC_GZIP:
|
||||
assert has_gzip(), 'Gzip decompression unsupported'
|
||||
raw_bytes = gzip_decode(self.value)
|
||||
elif codec == self.CODEC_SNAPPY:
|
||||
assert has_snappy(), 'Snappy decompression unsupported'
|
||||
raw_bytes = snappy_decode(self.value)
|
||||
elif codec == self.CODEC_LZ4:
|
||||
assert has_lz4(), 'LZ4 decompression unsupported'
|
||||
if self.magic == 0:
|
||||
raw_bytes = lz4_decode_old_kafka(self.value)
|
||||
else:
|
||||
raw_bytes = lz4_decode(self.value)
|
||||
elif codec == self.CODEC_ZSTD:
|
||||
assert has_zstd(), "ZSTD decompression unsupported"
|
||||
raw_bytes = zstd_decode(self.value)
|
||||
else:
|
||||
raise Exception('This should be impossible')
|
||||
|
||||
return MessageSet.decode(raw_bytes, bytes_to_read=len(raw_bytes))
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._encode_self(recalc_crc=False))
|
||||
|
||||
|
||||
class PartialMessage(bytes):
|
||||
def __repr__(self):
|
||||
return 'PartialMessage(%s)' % (self,)
|
||||
|
||||
|
||||
class MessageSet(AbstractType):
|
||||
ITEM = Schema(
|
||||
('offset', Int64),
|
||||
('message', Bytes)
|
||||
)
|
||||
HEADER_SIZE = 12 # offset + message_size
|
||||
|
||||
@classmethod
|
||||
def encode(cls, items, prepend_size=True):
|
||||
# RecordAccumulator encodes messagesets internally
|
||||
if isinstance(items, (io.BytesIO, KafkaBytes)):
|
||||
size = Int32.decode(items)
|
||||
if prepend_size:
|
||||
# rewind and return all the bytes
|
||||
items.seek(items.tell() - 4)
|
||||
size += 4
|
||||
return items.read(size)
|
||||
|
||||
encoded_values = []
|
||||
for (offset, message) in items:
|
||||
encoded_values.append(Int64.encode(offset))
|
||||
encoded_values.append(Bytes.encode(message))
|
||||
encoded = b''.join(encoded_values)
|
||||
if prepend_size:
|
||||
return Bytes.encode(encoded)
|
||||
else:
|
||||
return encoded
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data, bytes_to_read=None):
|
||||
"""Compressed messages should pass in bytes_to_read (via message size)
|
||||
otherwise, we decode from data as Int32
|
||||
"""
|
||||
if isinstance(data, bytes):
|
||||
data = io.BytesIO(data)
|
||||
if bytes_to_read is None:
|
||||
bytes_to_read = Int32.decode(data)
|
||||
|
||||
# if FetchRequest max_bytes is smaller than the available message set
|
||||
# the server returns partial data for the final message
|
||||
# So create an internal buffer to avoid over-reading
|
||||
raw = io.BytesIO(data.read(bytes_to_read))
|
||||
|
||||
items = []
|
||||
while bytes_to_read:
|
||||
try:
|
||||
offset = Int64.decode(raw)
|
||||
msg_bytes = Bytes.decode(raw)
|
||||
bytes_to_read -= 8 + 4 + len(msg_bytes)
|
||||
items.append((offset, len(msg_bytes), Message.decode(msg_bytes)))
|
||||
except ValueError:
|
||||
# PartialMessage to signal that max_bytes may be too small
|
||||
items.append((None, None, PartialMessage()))
|
||||
break
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def repr(cls, messages):
|
||||
if isinstance(messages, (KafkaBytes, io.BytesIO)):
|
||||
offset = messages.tell()
|
||||
decoded = cls.decode(messages)
|
||||
messages.seek(offset)
|
||||
messages = decoded
|
||||
return str([cls.ITEM.repr(m) for m in messages])
|
||||
@@ -0,0 +1,257 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, Boolean, Int16, Int32, Schema, String
|
||||
|
||||
|
||||
class MetadataResponse_v0(Response):
|
||||
API_KEY = 3
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('brokers', Array(
|
||||
('node_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32))),
|
||||
('topics', Array(
|
||||
('error_code', Int16),
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader', Int32),
|
||||
('replicas', Array(Int32)),
|
||||
('isr', Array(Int32))))))
|
||||
)
|
||||
|
||||
|
||||
class MetadataResponse_v1(Response):
|
||||
API_KEY = 3
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('brokers', Array(
|
||||
('node_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32),
|
||||
('rack', String('utf-8')))),
|
||||
('controller_id', Int32),
|
||||
('topics', Array(
|
||||
('error_code', Int16),
|
||||
('topic', String('utf-8')),
|
||||
('is_internal', Boolean),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader', Int32),
|
||||
('replicas', Array(Int32)),
|
||||
('isr', Array(Int32))))))
|
||||
)
|
||||
|
||||
|
||||
class MetadataResponse_v2(Response):
|
||||
API_KEY = 3
|
||||
API_VERSION = 2
|
||||
SCHEMA = Schema(
|
||||
('brokers', Array(
|
||||
('node_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32),
|
||||
('rack', String('utf-8')))),
|
||||
('cluster_id', String('utf-8')), # <-- Added cluster_id field in v2
|
||||
('controller_id', Int32),
|
||||
('topics', Array(
|
||||
('error_code', Int16),
|
||||
('topic', String('utf-8')),
|
||||
('is_internal', Boolean),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader', Int32),
|
||||
('replicas', Array(Int32)),
|
||||
('isr', Array(Int32))))))
|
||||
)
|
||||
|
||||
|
||||
class MetadataResponse_v3(Response):
|
||||
API_KEY = 3
|
||||
API_VERSION = 3
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('brokers', Array(
|
||||
('node_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32),
|
||||
('rack', String('utf-8')))),
|
||||
('cluster_id', String('utf-8')),
|
||||
('controller_id', Int32),
|
||||
('topics', Array(
|
||||
('error_code', Int16),
|
||||
('topic', String('utf-8')),
|
||||
('is_internal', Boolean),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader', Int32),
|
||||
('replicas', Array(Int32)),
|
||||
('isr', Array(Int32))))))
|
||||
)
|
||||
|
||||
|
||||
class MetadataResponse_v4(Response):
|
||||
API_KEY = 3
|
||||
API_VERSION = 4
|
||||
SCHEMA = MetadataResponse_v3.SCHEMA
|
||||
|
||||
|
||||
class MetadataResponse_v5(Response):
|
||||
API_KEY = 3
|
||||
API_VERSION = 5
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('brokers', Array(
|
||||
('node_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32),
|
||||
('rack', String('utf-8')))),
|
||||
('cluster_id', String('utf-8')),
|
||||
('controller_id', Int32),
|
||||
('topics', Array(
|
||||
('error_code', Int16),
|
||||
('topic', String('utf-8')),
|
||||
('is_internal', Boolean),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader', Int32),
|
||||
('replicas', Array(Int32)),
|
||||
('isr', Array(Int32)),
|
||||
('offline_replicas', Array(Int32))))))
|
||||
)
|
||||
|
||||
|
||||
class MetadataResponse_v6(Response):
|
||||
"""Metadata Request/Response v6 is the same as v5,
|
||||
but on quota violation, brokers send out responses before throttling."""
|
||||
API_KEY = 3
|
||||
API_VERSION = 6
|
||||
SCHEMA = MetadataResponse_v5.SCHEMA
|
||||
|
||||
|
||||
class MetadataResponse_v7(Response):
|
||||
"""v7 adds per-partition leader_epoch field"""
|
||||
API_KEY = 3
|
||||
API_VERSION = 7
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('brokers', Array(
|
||||
('node_id', Int32),
|
||||
('host', String('utf-8')),
|
||||
('port', Int32),
|
||||
('rack', String('utf-8')))),
|
||||
('cluster_id', String('utf-8')),
|
||||
('controller_id', Int32),
|
||||
('topics', Array(
|
||||
('error_code', Int16),
|
||||
('topic', String('utf-8')),
|
||||
('is_internal', Boolean),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader', Int32),
|
||||
('leader_epoch', Int32),
|
||||
('replicas', Array(Int32)),
|
||||
('isr', Array(Int32)),
|
||||
('offline_replicas', Array(Int32))))))
|
||||
)
|
||||
|
||||
|
||||
class MetadataRequest_v0(Request):
|
||||
API_KEY = 3
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = MetadataResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(String('utf-8')))
|
||||
)
|
||||
ALL_TOPICS = [] # Empty Array (len 0) for topics returns all topics
|
||||
NO_TOPICS = [] # v0 does not support a 'no topics' request, so we'll just ask for ALL
|
||||
|
||||
|
||||
class MetadataRequest_v1(Request):
|
||||
API_KEY = 3
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = MetadataResponse_v1
|
||||
SCHEMA = MetadataRequest_v0.SCHEMA
|
||||
ALL_TOPICS = None # Null Array (len -1) for topics returns all topics
|
||||
NO_TOPICS = [] # Empty array (len 0) for topics returns no topics
|
||||
|
||||
|
||||
class MetadataRequest_v2(Request):
|
||||
API_KEY = 3
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = MetadataResponse_v2
|
||||
SCHEMA = MetadataRequest_v1.SCHEMA
|
||||
ALL_TOPICS = None
|
||||
NO_TOPICS = []
|
||||
|
||||
|
||||
class MetadataRequest_v3(Request):
|
||||
API_KEY = 3
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = MetadataResponse_v3
|
||||
SCHEMA = MetadataRequest_v1.SCHEMA
|
||||
ALL_TOPICS = None
|
||||
NO_TOPICS = []
|
||||
|
||||
|
||||
class MetadataRequest_v4(Request):
|
||||
API_KEY = 3
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = MetadataResponse_v4
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(String('utf-8'))),
|
||||
('allow_auto_topic_creation', Boolean)
|
||||
)
|
||||
ALL_TOPICS = None
|
||||
NO_TOPICS = []
|
||||
|
||||
|
||||
class MetadataRequest_v5(Request):
|
||||
"""
|
||||
The v5 metadata request is the same as v4.
|
||||
An additional field for offline_replicas has been added to the v5 metadata response
|
||||
"""
|
||||
API_KEY = 3
|
||||
API_VERSION = 5
|
||||
RESPONSE_TYPE = MetadataResponse_v5
|
||||
SCHEMA = MetadataRequest_v4.SCHEMA
|
||||
ALL_TOPICS = None
|
||||
NO_TOPICS = []
|
||||
|
||||
|
||||
class MetadataRequest_v6(Request):
|
||||
API_KEY = 3
|
||||
API_VERSION = 6
|
||||
RESPONSE_TYPE = MetadataResponse_v6
|
||||
SCHEMA = MetadataRequest_v5.SCHEMA
|
||||
ALL_TOPICS = None
|
||||
NO_TOPICS = []
|
||||
|
||||
|
||||
class MetadataRequest_v7(Request):
|
||||
API_KEY = 3
|
||||
API_VERSION = 7
|
||||
RESPONSE_TYPE = MetadataResponse_v7
|
||||
SCHEMA = MetadataRequest_v6.SCHEMA
|
||||
ALL_TOPICS = None
|
||||
NO_TOPICS = []
|
||||
|
||||
|
||||
MetadataRequest = [
|
||||
MetadataRequest_v0, MetadataRequest_v1, MetadataRequest_v2,
|
||||
MetadataRequest_v3, MetadataRequest_v4, MetadataRequest_v5,
|
||||
MetadataRequest_v6, MetadataRequest_v7,
|
||||
]
|
||||
MetadataResponse = [
|
||||
MetadataResponse_v0, MetadataResponse_v1, MetadataResponse_v2,
|
||||
MetadataResponse_v3, MetadataResponse_v4, MetadataResponse_v5,
|
||||
MetadataResponse_v6, MetadataResponse_v7,
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, CompactArray, CompactString, Int16, Int32, Int64, Schema, String, TaggedFields
|
||||
|
||||
|
||||
class OffsetForLeaderEpochResponse_v0(Response):
|
||||
API_KEY = 23
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('end_offset', Int64))))))
|
||||
|
||||
|
||||
class OffsetForLeaderEpochResponse_v1(Response):
|
||||
API_KEY = 23
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader_epoch', Int32),
|
||||
('end_offset', Int64))))))
|
||||
|
||||
|
||||
class OffsetForLeaderEpochResponse_v2(Response):
|
||||
API_KEY = 23
|
||||
API_VERSION = 2
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader_epoch', Int32),
|
||||
('end_offset', Int64))))))
|
||||
|
||||
|
||||
class OffsetForLeaderEpochResponse_v3(Response):
|
||||
API_KEY = 23
|
||||
API_VERSION = 3
|
||||
SCHEMA = OffsetForLeaderEpochResponse_v2.SCHEMA
|
||||
|
||||
|
||||
class OffsetForLeaderEpochResponse_v4(Response):
|
||||
API_KEY = 23
|
||||
API_VERSION = 4
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', CompactArray(
|
||||
('topic', CompactString('utf-8')),
|
||||
('partitions', CompactArray(
|
||||
('error_code', Int16),
|
||||
('partition', Int32),
|
||||
('leader_epoch', Int32),
|
||||
('end_offset', Int64),
|
||||
('tags', TaggedFields))),
|
||||
('tags', TaggedFields))),
|
||||
('tags', TaggedFields))
|
||||
|
||||
|
||||
class OffsetForLeaderEpochRequest_v0(Request):
|
||||
API_KEY = 23
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = OffsetForLeaderEpochResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('leader_epoch', Int32))))))
|
||||
|
||||
|
||||
class OffsetForLeaderEpochRequest_v1(Request):
|
||||
API_KEY = 23
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = OffsetForLeaderEpochResponse_v1
|
||||
SCHEMA = OffsetForLeaderEpochRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class OffsetForLeaderEpochRequest_v2(Request):
|
||||
API_KEY = 23
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = OffsetForLeaderEpochResponse_v2
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('current_leader_epoch', Int32),
|
||||
('leader_epoch', Int32))))))
|
||||
|
||||
|
||||
class OffsetForLeaderEpochRequest_v3(Request):
|
||||
API_KEY = 23
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = OffsetForLeaderEpochResponse_v3
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('current_leader_epoch', Int32),
|
||||
('leader_epoch', Int32))))))
|
||||
|
||||
|
||||
class OffsetForLeaderEpochRequest_v4(Request):
|
||||
API_KEY = 23
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = OffsetForLeaderEpochResponse_v4
|
||||
SCHEMA = Schema(
|
||||
('replica_id', Int32),
|
||||
('topics', CompactArray(
|
||||
('topic', CompactString('utf-8')),
|
||||
('partitions', CompactArray(
|
||||
('partition', Int32),
|
||||
('current_leader_epoch', Int32),
|
||||
('leader_epoch', Int32),
|
||||
('tags', TaggedFields))),
|
||||
('tags', TaggedFields))),
|
||||
('tags', TaggedFields))
|
||||
|
||||
OffsetForLeaderEpochRequest = [
|
||||
OffsetForLeaderEpochRequest_v0, OffsetForLeaderEpochRequest_v1,
|
||||
OffsetForLeaderEpochRequest_v2, OffsetForLeaderEpochRequest_v3,
|
||||
OffsetForLeaderEpochRequest_v4,
|
||||
]
|
||||
OffsetForLeaderEpochResponse = [
|
||||
OffsetForLeaderEpochResponse_v0, OffsetForLeaderEpochResponse_v1,
|
||||
OffsetForLeaderEpochResponse_v2, OffsetForLeaderEpochResponse_v3,
|
||||
OffsetForLeaderEpochResponse_v4,
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
import logging
|
||||
|
||||
import kafka.errors as Errors
|
||||
from kafka.protocol.find_coordinator import FindCoordinatorResponse
|
||||
from kafka.protocol.frame import KafkaBytes
|
||||
from kafka.protocol.types import Int32, TaggedFields
|
||||
from kafka.version import __version__
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KafkaProtocol(object):
|
||||
"""Manage the kafka network protocol
|
||||
|
||||
Use an instance of KafkaProtocol to manage bytes send/recv'd
|
||||
from a network socket to a broker.
|
||||
|
||||
Arguments:
|
||||
client_id (str): identifier string to be included in each request
|
||||
api_version (tuple): Optional tuple to specify api_version to use.
|
||||
Currently only used to check for 0.8.2 protocol quirks, but
|
||||
may be used for more in the future.
|
||||
"""
|
||||
def __init__(self, client_id=None, api_version=None):
|
||||
if client_id is None:
|
||||
client_id = self._gen_client_id()
|
||||
self._client_id = client_id
|
||||
self._api_version = api_version
|
||||
self._correlation_id = 0
|
||||
self._header = KafkaBytes(4)
|
||||
self._rbuffer = None
|
||||
self._receiving = False
|
||||
self.in_flight_requests = collections.deque()
|
||||
self.bytes_to_send = []
|
||||
|
||||
def _next_correlation_id(self):
|
||||
self._correlation_id = (self._correlation_id + 1) % 2**31
|
||||
return self._correlation_id
|
||||
|
||||
def _gen_client_id(self):
|
||||
return 'kafka-python' + __version__
|
||||
|
||||
def send_request(self, request, correlation_id=None):
|
||||
"""Encode and queue a kafka api request for sending.
|
||||
|
||||
Arguments:
|
||||
request (object): An un-encoded kafka request.
|
||||
correlation_id (int, optional): Optionally specify an ID to
|
||||
correlate requests with responses. If not provided, an ID will
|
||||
be generated automatically.
|
||||
|
||||
Returns:
|
||||
correlation_id
|
||||
"""
|
||||
log.debug('Sending request %s', request)
|
||||
if correlation_id is None:
|
||||
correlation_id = self._next_correlation_id()
|
||||
|
||||
header = request.build_header(correlation_id=correlation_id, client_id=self._client_id)
|
||||
message = b''.join([header.encode(), request.encode()])
|
||||
size = Int32.encode(len(message))
|
||||
data = size + message
|
||||
self.bytes_to_send.append(data)
|
||||
if request.expect_response():
|
||||
ifr = (correlation_id, request)
|
||||
self.in_flight_requests.append(ifr)
|
||||
return correlation_id
|
||||
|
||||
def send_bytes(self):
|
||||
"""Retrieve all pending bytes to send on the network"""
|
||||
data = b''.join(self.bytes_to_send)
|
||||
self.bytes_to_send = []
|
||||
return data
|
||||
|
||||
def receive_bytes(self, data):
|
||||
"""Process bytes received from the network.
|
||||
|
||||
Arguments:
|
||||
data (bytes): any length bytes received from a network connection
|
||||
to a kafka broker.
|
||||
|
||||
Returns:
|
||||
responses (list of (correlation_id, response)): any/all completed
|
||||
responses, decoded from bytes to python objects.
|
||||
|
||||
Raises:
|
||||
KafkaProtocolError: if the bytes received could not be decoded.
|
||||
CorrelationIdError: if the response does not match the request
|
||||
correlation id.
|
||||
"""
|
||||
i = 0
|
||||
n = len(data)
|
||||
responses = []
|
||||
while i < n:
|
||||
|
||||
# Not receiving is the state of reading the payload header
|
||||
if not self._receiving:
|
||||
bytes_to_read = min(4 - self._header.tell(), n - i)
|
||||
self._header.write(data[i:i+bytes_to_read])
|
||||
i += bytes_to_read
|
||||
|
||||
if self._header.tell() == 4:
|
||||
self._header.seek(0)
|
||||
nbytes = Int32.decode(self._header)
|
||||
# reset buffer and switch state to receiving payload bytes
|
||||
self._rbuffer = KafkaBytes(nbytes)
|
||||
self._receiving = True
|
||||
elif self._header.tell() > 4:
|
||||
raise Errors.KafkaError('this should not happen - are you threading?')
|
||||
|
||||
if self._receiving:
|
||||
total_bytes = len(self._rbuffer)
|
||||
staged_bytes = self._rbuffer.tell()
|
||||
bytes_to_read = min(total_bytes - staged_bytes, n - i)
|
||||
self._rbuffer.write(data[i:i+bytes_to_read])
|
||||
i += bytes_to_read
|
||||
|
||||
staged_bytes = self._rbuffer.tell()
|
||||
if staged_bytes > total_bytes:
|
||||
raise Errors.KafkaError('Receive buffer has more bytes than expected?')
|
||||
|
||||
if staged_bytes != total_bytes:
|
||||
break
|
||||
|
||||
self._receiving = False
|
||||
self._rbuffer.seek(0)
|
||||
resp = self._process_response(self._rbuffer)
|
||||
responses.append(resp)
|
||||
self._reset_buffer()
|
||||
return responses
|
||||
|
||||
def _process_response(self, read_buffer):
|
||||
if not self.in_flight_requests:
|
||||
raise Errors.CorrelationIdError('No in-flight-request found for server response')
|
||||
(correlation_id, request) = self.in_flight_requests.popleft()
|
||||
response_type = request.RESPONSE_TYPE
|
||||
response_header = response_type.parse_header(read_buffer)
|
||||
recv_correlation_id = response_header.correlation_id
|
||||
log.debug('Received correlation id: %d', recv_correlation_id)
|
||||
# 0.8.2 quirk
|
||||
if (recv_correlation_id == 0 and
|
||||
correlation_id != 0 and
|
||||
response_type is FindCoordinatorResponse[0] and
|
||||
(self._api_version == (0, 8, 2) or self._api_version is None)):
|
||||
log.warning('Kafka 0.8.2 quirk -- GroupCoordinatorResponse'
|
||||
' Correlation ID does not match request. This'
|
||||
' should go away once at least one topic has been'
|
||||
' initialized on the broker.')
|
||||
|
||||
elif correlation_id != recv_correlation_id:
|
||||
# return or raise?
|
||||
raise Errors.CorrelationIdError(
|
||||
'Correlation IDs do not match: sent %d, recv %d'
|
||||
% (correlation_id, recv_correlation_id))
|
||||
|
||||
# decode response
|
||||
log.debug('Processing response %s', response_type.__name__)
|
||||
try:
|
||||
response = response_type.decode(read_buffer)
|
||||
except ValueError:
|
||||
read_buffer.seek(0)
|
||||
buf = read_buffer.read()
|
||||
log.error('Response %d [ResponseType: %s Request: %s]:'
|
||||
' Unable to decode %d-byte buffer: %r',
|
||||
correlation_id, response_type,
|
||||
request, len(buf), buf)
|
||||
raise Errors.KafkaProtocolError('Unable to decode response')
|
||||
|
||||
return (correlation_id, response)
|
||||
|
||||
def _reset_buffer(self):
|
||||
self._receiving = False
|
||||
self._header.seek(0)
|
||||
self._rbuffer = None
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
try:
|
||||
import copyreg # pylint: disable=import-error
|
||||
except ImportError:
|
||||
import copy_reg as copyreg # pylint: disable=import-error
|
||||
|
||||
import types
|
||||
|
||||
|
||||
def _pickle_method(method):
|
||||
try:
|
||||
func_name = method.__func__.__name__
|
||||
obj = method.__self__
|
||||
cls = method.__self__.__class__
|
||||
except AttributeError:
|
||||
func_name = method.im_func.__name__
|
||||
obj = method.im_self
|
||||
cls = method.im_class
|
||||
|
||||
return _unpickle_method, (func_name, obj, cls)
|
||||
|
||||
|
||||
def _unpickle_method(func_name, obj, cls):
|
||||
for cls in cls.mro():
|
||||
try:
|
||||
func = cls.__dict__[func_name]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
break
|
||||
return func.__get__(obj, cls)
|
||||
|
||||
# https://bytes.com/topic/python/answers/552476-why-cant-you-pickle-instancemethods
|
||||
copyreg.pickle(types.MethodType, _pickle_method, _unpickle_method)
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Int16, Int32, Int64, String, Array, Schema, Bytes
|
||||
|
||||
|
||||
class ProduceResponse_v0(Response):
|
||||
API_KEY = 0
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('offset', Int64)))))
|
||||
)
|
||||
|
||||
|
||||
class ProduceResponse_v1(Response):
|
||||
API_KEY = 0
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('offset', Int64))))),
|
||||
('throttle_time_ms', Int32)
|
||||
)
|
||||
|
||||
|
||||
class ProduceResponse_v2(Response):
|
||||
API_KEY = 0
|
||||
API_VERSION = 2
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('offset', Int64),
|
||||
('timestamp', Int64))))),
|
||||
('throttle_time_ms', Int32)
|
||||
)
|
||||
|
||||
|
||||
class ProduceResponse_v3(Response):
|
||||
# Adds support for message format v2
|
||||
API_KEY = 0
|
||||
API_VERSION = 3
|
||||
SCHEMA = ProduceResponse_v2.SCHEMA
|
||||
|
||||
|
||||
class ProduceResponse_v4(Response):
|
||||
"""
|
||||
The version number is bumped up to indicate that the client supports KafkaStorageException.
|
||||
The KafkaStorageException will be translated to NotLeaderForPartitionException in the response if version <= 3
|
||||
"""
|
||||
API_KEY = 0
|
||||
API_VERSION = 4
|
||||
SCHEMA = ProduceResponse_v3.SCHEMA
|
||||
|
||||
|
||||
class ProduceResponse_v5(Response):
|
||||
API_KEY = 0
|
||||
API_VERSION = 5
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('offset', Int64),
|
||||
('timestamp', Int64),
|
||||
('log_start_offset', Int64))))),
|
||||
('throttle_time_ms', Int32)
|
||||
)
|
||||
|
||||
|
||||
class ProduceResponse_v6(Response):
|
||||
"""
|
||||
The version number is bumped to indicate that on quota violation brokers send out responses before throttling.
|
||||
"""
|
||||
API_KEY = 0
|
||||
API_VERSION = 6
|
||||
SCHEMA = ProduceResponse_v5.SCHEMA
|
||||
|
||||
|
||||
class ProduceResponse_v7(Response):
|
||||
"""
|
||||
V7 bumped up to indicate ZStandard capability. (see KIP-110)
|
||||
"""
|
||||
API_KEY = 0
|
||||
API_VERSION = 7
|
||||
SCHEMA = ProduceResponse_v6.SCHEMA
|
||||
|
||||
|
||||
class ProduceResponse_v8(Response):
|
||||
"""
|
||||
V8 bumped up to add two new fields record_errors offset list and error_message
|
||||
(See KIP-467)
|
||||
"""
|
||||
API_KEY = 0
|
||||
API_VERSION = 8
|
||||
SCHEMA = Schema(
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16),
|
||||
('offset', Int64),
|
||||
('timestamp', Int64),
|
||||
('log_start_offset', Int64)),
|
||||
('record_errors', (Array(
|
||||
('batch_index', Int32),
|
||||
('batch_index_error_message', String('utf-8'))
|
||||
))),
|
||||
('error_message', String('utf-8'))
|
||||
))),
|
||||
('throttle_time_ms', Int32)
|
||||
)
|
||||
|
||||
|
||||
class ProduceRequest(Request):
|
||||
API_KEY = 0
|
||||
|
||||
def expect_response(self):
|
||||
if self.required_acks == 0: # pylint: disable=no-member
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ProduceRequest_v0(ProduceRequest):
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = ProduceResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('required_acks', Int16),
|
||||
('timeout', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class ProduceRequest_v1(ProduceRequest):
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = ProduceResponse_v1
|
||||
SCHEMA = ProduceRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class ProduceRequest_v2(ProduceRequest):
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = ProduceResponse_v2
|
||||
SCHEMA = ProduceRequest_v1.SCHEMA
|
||||
|
||||
|
||||
class ProduceRequest_v3(ProduceRequest):
|
||||
# Adds support for message format v2
|
||||
API_VERSION = 3
|
||||
RESPONSE_TYPE = ProduceResponse_v3
|
||||
SCHEMA = Schema(
|
||||
('transactional_id', String('utf-8')),
|
||||
('required_acks', Int16),
|
||||
('timeout', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('records', Bytes)))))
|
||||
)
|
||||
|
||||
|
||||
class ProduceRequest_v4(ProduceRequest):
|
||||
"""
|
||||
The version number is bumped up to indicate that the client supports KafkaStorageException.
|
||||
The KafkaStorageException will be translated to NotLeaderForPartitionException in the response if version <= 3
|
||||
"""
|
||||
API_VERSION = 4
|
||||
RESPONSE_TYPE = ProduceResponse_v4
|
||||
SCHEMA = ProduceRequest_v3.SCHEMA
|
||||
|
||||
|
||||
class ProduceRequest_v5(ProduceRequest):
|
||||
"""
|
||||
Same as v4. The version number is bumped since the v5 response includes an additional
|
||||
partition level field: the log_start_offset.
|
||||
"""
|
||||
API_VERSION = 5
|
||||
RESPONSE_TYPE = ProduceResponse_v5
|
||||
SCHEMA = ProduceRequest_v4.SCHEMA
|
||||
|
||||
|
||||
class ProduceRequest_v6(ProduceRequest):
|
||||
"""
|
||||
The version number is bumped to indicate that on quota violation brokers send out responses before throttling.
|
||||
"""
|
||||
API_VERSION = 6
|
||||
RESPONSE_TYPE = ProduceResponse_v6
|
||||
SCHEMA = ProduceRequest_v5.SCHEMA
|
||||
|
||||
|
||||
class ProduceRequest_v7(ProduceRequest):
|
||||
"""
|
||||
V7 bumped up to indicate ZStandard capability. (see KIP-110)
|
||||
"""
|
||||
API_VERSION = 7
|
||||
RESPONSE_TYPE = ProduceResponse_v7
|
||||
SCHEMA = ProduceRequest_v6.SCHEMA
|
||||
|
||||
|
||||
class ProduceRequest_v8(ProduceRequest):
|
||||
"""
|
||||
V8 bumped up to add two new fields record_errors offset list and error_message to PartitionResponse
|
||||
(See KIP-467)
|
||||
"""
|
||||
API_VERSION = 8
|
||||
RESPONSE_TYPE = ProduceResponse_v8
|
||||
SCHEMA = ProduceRequest_v7.SCHEMA
|
||||
|
||||
|
||||
ProduceRequest = [
|
||||
ProduceRequest_v0, ProduceRequest_v1, ProduceRequest_v2,
|
||||
ProduceRequest_v3, ProduceRequest_v4, ProduceRequest_v5,
|
||||
ProduceRequest_v6, ProduceRequest_v7, ProduceRequest_v8,
|
||||
]
|
||||
ProduceResponse = [
|
||||
ProduceResponse_v0, ProduceResponse_v1, ProduceResponse_v2,
|
||||
ProduceResponse_v3, ProduceResponse_v4, ProduceResponse_v5,
|
||||
ProduceResponse_v6, ProduceResponse_v7, ProduceResponse_v8,
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Bytes, Int16, Int64, Schema, String
|
||||
|
||||
|
||||
class SaslAuthenticateResponse_v0(Response):
|
||||
API_KEY = 36
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('error_message', String('utf-8')),
|
||||
('auth_bytes', Bytes))
|
||||
|
||||
|
||||
class SaslAuthenticateResponse_v1(Response):
|
||||
API_KEY = 36
|
||||
API_VERSION = 1
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('error_message', String('utf-8')),
|
||||
('auth_bytes', Bytes),
|
||||
('session_lifetime_ms', Int64))
|
||||
|
||||
|
||||
class SaslAuthenticateRequest_v0(Request):
|
||||
API_KEY = 36
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = SaslAuthenticateResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('auth_bytes', Bytes))
|
||||
|
||||
|
||||
class SaslAuthenticateRequest_v1(Request):
|
||||
API_KEY = 36
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = SaslAuthenticateResponse_v1
|
||||
SCHEMA = SaslAuthenticateRequest_v0.SCHEMA
|
||||
|
||||
|
||||
SaslAuthenticateRequest = [SaslAuthenticateRequest_v0, SaslAuthenticateRequest_v1]
|
||||
SaslAuthenticateResponse = [SaslAuthenticateResponse_v0, SaslAuthenticateResponse_v1]
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, Int16, Schema, String
|
||||
|
||||
|
||||
class SaslHandshakeResponse_v0(Response):
|
||||
API_KEY = 17
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('error_code', Int16),
|
||||
('enabled_mechanisms', Array(String('utf-8')))
|
||||
)
|
||||
|
||||
|
||||
class SaslHandshakeResponse_v1(Response):
|
||||
API_KEY = 17
|
||||
API_VERSION = 1
|
||||
SCHEMA = SaslHandshakeResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class SaslHandshakeRequest_v0(Request):
|
||||
API_KEY = 17
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = SaslHandshakeResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('mechanism', String('utf-8'))
|
||||
)
|
||||
|
||||
|
||||
class SaslHandshakeRequest_v1(Request):
|
||||
API_KEY = 17
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = SaslHandshakeResponse_v1
|
||||
SCHEMA = SaslHandshakeRequest_v0.SCHEMA
|
||||
|
||||
|
||||
SaslHandshakeRequest = [SaslHandshakeRequest_v0, SaslHandshakeRequest_v1]
|
||||
SaslHandshakeResponse = [SaslHandshakeResponse_v0, SaslHandshakeResponse_v1]
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from kafka.protocol.abstract import AbstractType
|
||||
from kafka.protocol.types import Schema
|
||||
|
||||
from kafka.util import WeakMethod
|
||||
|
||||
|
||||
class Struct(AbstractType):
|
||||
SCHEMA = Schema()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if len(args) == len(self.SCHEMA.fields):
|
||||
for i, name in enumerate(self.SCHEMA.names):
|
||||
self.__dict__[name] = args[i]
|
||||
elif len(args) > 0:
|
||||
raise ValueError('Args must be empty or mirror schema')
|
||||
else:
|
||||
for name in self.SCHEMA.names:
|
||||
self.__dict__[name] = kwargs.pop(name, None)
|
||||
if kwargs:
|
||||
raise ValueError('Keyword(s) not in schema %s: %s'
|
||||
% (list(self.SCHEMA.names),
|
||||
', '.join(kwargs.keys())))
|
||||
|
||||
# overloading encode() to support both class and instance
|
||||
# Without WeakMethod() this creates circular ref, which
|
||||
# causes instances to "leak" to garbage
|
||||
self.encode = WeakMethod(self._encode_self)
|
||||
|
||||
|
||||
@classmethod
|
||||
def encode(cls, item): # pylint: disable=E0202
|
||||
bits = []
|
||||
for i, field in enumerate(cls.SCHEMA.fields):
|
||||
bits.append(field.encode(item[i]))
|
||||
return b''.join(bits)
|
||||
|
||||
def _encode_self(self):
|
||||
return self.SCHEMA.encode(
|
||||
[self.__dict__[name] for name in self.SCHEMA.names]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
if isinstance(data, bytes):
|
||||
data = BytesIO(data)
|
||||
return cls(*[field.decode(data) for field in cls.SCHEMA.fields])
|
||||
|
||||
def get_item(self, name):
|
||||
if name not in self.SCHEMA.names:
|
||||
raise KeyError("%s is not in the schema" % name)
|
||||
return self.__dict__[name]
|
||||
|
||||
def __repr__(self):
|
||||
key_vals = []
|
||||
for name, field in zip(self.SCHEMA.names, self.SCHEMA.fields):
|
||||
key_vals.append('%s=%s' % (name, field.repr(self.__dict__[name])))
|
||||
return self.__class__.__name__ + '(' + ', '.join(key_vals) + ')'
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.encode())
|
||||
|
||||
def __eq__(self, other):
|
||||
if self.SCHEMA != other.SCHEMA:
|
||||
return False
|
||||
for attr in self.SCHEMA.names:
|
||||
if self.__dict__[attr] != other.__dict__[attr]:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from kafka.protocol.api import Request, Response
|
||||
from kafka.protocol.types import Array, Int16, Int32, Int64, Schema, String
|
||||
|
||||
|
||||
class TxnOffsetCommitResponse_v0(Response):
|
||||
API_KEY = 28
|
||||
API_VERSION = 0
|
||||
SCHEMA = Schema(
|
||||
('throttle_time_ms', Int32),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('error_code', Int16))))))
|
||||
|
||||
|
||||
class TxnOffsetCommitResponse_v1(Response):
|
||||
API_KEY = 28
|
||||
API_VERSION = 1
|
||||
SCHEMA = TxnOffsetCommitResponse_v0.SCHEMA
|
||||
|
||||
|
||||
class TxnOffsetCommitResponse_v2(Response):
|
||||
API_KEY = 28
|
||||
API_VERSION = 2
|
||||
SCHEMA = TxnOffsetCommitResponse_v1.SCHEMA
|
||||
|
||||
|
||||
class TxnOffsetCommitRequest_v0(Request):
|
||||
API_KEY = 28
|
||||
API_VERSION = 0
|
||||
RESPONSE_TYPE = TxnOffsetCommitResponse_v0
|
||||
SCHEMA = Schema(
|
||||
('transactional_id', String('utf-8')),
|
||||
('group_id', String('utf-8')),
|
||||
('producer_id', Int64),
|
||||
('producer_epoch', Int16),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('metadata', String('utf-8')))))))
|
||||
|
||||
|
||||
class TxnOffsetCommitRequest_v1(Request):
|
||||
API_KEY = 28
|
||||
API_VERSION = 1
|
||||
RESPONSE_TYPE = TxnOffsetCommitResponse_v1
|
||||
SCHEMA = TxnOffsetCommitRequest_v0.SCHEMA
|
||||
|
||||
|
||||
class TxnOffsetCommitRequest_v2(Request):
|
||||
API_KEY = 28
|
||||
API_VERSION = 2
|
||||
RESPONSE_TYPE = TxnOffsetCommitResponse_v2
|
||||
SCHEMA = Schema(
|
||||
('transactional_id', String('utf-8')),
|
||||
('group_id', String('utf-8')),
|
||||
('producer_id', Int64),
|
||||
('producer_epoch', Int16),
|
||||
('topics', Array(
|
||||
('topic', String('utf-8')),
|
||||
('partitions', Array(
|
||||
('partition', Int32),
|
||||
('offset', Int64),
|
||||
('leader_epoch', Int32),
|
||||
('metadata', String('utf-8')))))))
|
||||
|
||||
|
||||
TxnOffsetCommitRequest = [
|
||||
TxnOffsetCommitRequest_v0, TxnOffsetCommitRequest_v1, TxnOffsetCommitRequest_v2,
|
||||
]
|
||||
TxnOffsetCommitResponse = [
|
||||
TxnOffsetCommitResponse_v0, TxnOffsetCommitResponse_v1, TxnOffsetCommitResponse_v2,
|
||||
]
|
||||
@@ -0,0 +1,365 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import struct
|
||||
from struct import error
|
||||
|
||||
from kafka.protocol.abstract import AbstractType
|
||||
|
||||
|
||||
def _pack(f, value):
|
||||
try:
|
||||
return f(value)
|
||||
except error as e:
|
||||
raise ValueError("Error encountered when attempting to convert value: "
|
||||
"{!r} to struct format: '{}', hit error: {}"
|
||||
.format(value, f, e))
|
||||
|
||||
|
||||
def _unpack(f, data):
|
||||
try:
|
||||
(value,) = f(data)
|
||||
return value
|
||||
except error as e:
|
||||
raise ValueError("Error encountered when attempting to convert value: "
|
||||
"{!r} to struct format: '{}', hit error: {}"
|
||||
.format(data, f, e))
|
||||
|
||||
|
||||
class Int8(AbstractType):
|
||||
_pack = struct.Struct('>b').pack
|
||||
_unpack = struct.Struct('>b').unpack
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
return _pack(cls._pack, value)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
return _unpack(cls._unpack, data.read(1))
|
||||
|
||||
|
||||
class Int16(AbstractType):
|
||||
_pack = struct.Struct('>h').pack
|
||||
_unpack = struct.Struct('>h').unpack
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
return _pack(cls._pack, value)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
return _unpack(cls._unpack, data.read(2))
|
||||
|
||||
|
||||
class Int32(AbstractType):
|
||||
_pack = struct.Struct('>i').pack
|
||||
_unpack = struct.Struct('>i').unpack
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
return _pack(cls._pack, value)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
return _unpack(cls._unpack, data.read(4))
|
||||
|
||||
|
||||
class Int64(AbstractType):
|
||||
_pack = struct.Struct('>q').pack
|
||||
_unpack = struct.Struct('>q').unpack
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
return _pack(cls._pack, value)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
return _unpack(cls._unpack, data.read(8))
|
||||
|
||||
|
||||
class Float64(AbstractType):
|
||||
_pack = struct.Struct('>d').pack
|
||||
_unpack = struct.Struct('>d').unpack
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
return _pack(cls._pack, value)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
return _unpack(cls._unpack, data.read(8))
|
||||
|
||||
|
||||
class String(AbstractType):
|
||||
def __init__(self, encoding='utf-8'):
|
||||
self.encoding = encoding
|
||||
|
||||
def encode(self, value):
|
||||
if value is None:
|
||||
return Int16.encode(-1)
|
||||
value = str(value).encode(self.encoding)
|
||||
return Int16.encode(len(value)) + value
|
||||
|
||||
def decode(self, data):
|
||||
length = Int16.decode(data)
|
||||
if length < 0:
|
||||
return None
|
||||
value = data.read(length)
|
||||
if len(value) != length:
|
||||
raise ValueError('Buffer underrun decoding string')
|
||||
return value.decode(self.encoding)
|
||||
|
||||
|
||||
class Bytes(AbstractType):
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
if value is None:
|
||||
return Int32.encode(-1)
|
||||
else:
|
||||
return Int32.encode(len(value)) + value
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
length = Int32.decode(data)
|
||||
if length < 0:
|
||||
return None
|
||||
value = data.read(length)
|
||||
if len(value) != length:
|
||||
raise ValueError('Buffer underrun decoding Bytes')
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def repr(cls, value):
|
||||
return repr(value[:100] + b'...' if value is not None and len(value) > 100 else value)
|
||||
|
||||
|
||||
class Boolean(AbstractType):
|
||||
_pack = struct.Struct('>?').pack
|
||||
_unpack = struct.Struct('>?').unpack
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
return _pack(cls._pack, value)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
return _unpack(cls._unpack, data.read(1))
|
||||
|
||||
|
||||
class Schema(AbstractType):
|
||||
def __init__(self, *fields):
|
||||
if fields:
|
||||
self.names, self.fields = zip(*fields)
|
||||
else:
|
||||
self.names, self.fields = (), ()
|
||||
|
||||
def encode(self, item):
|
||||
if len(item) != len(self.fields):
|
||||
raise ValueError('Item field count does not match Schema')
|
||||
return b''.join([
|
||||
field.encode(item[i])
|
||||
for i, field in enumerate(self.fields)
|
||||
])
|
||||
|
||||
def decode(self, data):
|
||||
return tuple([field.decode(data) for field in self.fields])
|
||||
|
||||
def __len__(self):
|
||||
return len(self.fields)
|
||||
|
||||
def repr(self, value):
|
||||
key_vals = []
|
||||
try:
|
||||
for i in range(len(self)):
|
||||
try:
|
||||
field_val = getattr(value, self.names[i])
|
||||
except AttributeError:
|
||||
field_val = value[i]
|
||||
key_vals.append('%s=%s' % (self.names[i], self.fields[i].repr(field_val)))
|
||||
return '(' + ', '.join(key_vals) + ')'
|
||||
except Exception:
|
||||
return repr(value)
|
||||
|
||||
|
||||
class Array(AbstractType):
|
||||
def __init__(self, *array_of):
|
||||
if len(array_of) > 1:
|
||||
self.array_of = Schema(*array_of)
|
||||
elif len(array_of) == 1 and (isinstance(array_of[0], AbstractType) or
|
||||
issubclass(array_of[0], AbstractType)):
|
||||
self.array_of = array_of[0]
|
||||
else:
|
||||
raise ValueError('Array instantiated with no array_of type')
|
||||
|
||||
def encode(self, items):
|
||||
if items is None:
|
||||
return Int32.encode(-1)
|
||||
encoded_items = [self.array_of.encode(item) for item in items]
|
||||
return b''.join(
|
||||
[Int32.encode(len(encoded_items))] +
|
||||
encoded_items
|
||||
)
|
||||
|
||||
def decode(self, data):
|
||||
length = Int32.decode(data)
|
||||
if length == -1:
|
||||
return None
|
||||
return [self.array_of.decode(data) for _ in range(length)]
|
||||
|
||||
def repr(self, list_of_items):
|
||||
if list_of_items is None:
|
||||
return 'NULL'
|
||||
return '[' + ', '.join([self.array_of.repr(item) for item in list_of_items]) + ']'
|
||||
|
||||
|
||||
class UnsignedVarInt32(AbstractType):
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
value, i = 0, 0
|
||||
while True:
|
||||
b, = struct.unpack('B', data.read(1))
|
||||
if not (b & 0x80):
|
||||
break
|
||||
value |= (b & 0x7f) << i
|
||||
i += 7
|
||||
if i > 28:
|
||||
raise ValueError('Invalid value {}'.format(value))
|
||||
value |= b << i
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
value &= 0xffffffff
|
||||
ret = b''
|
||||
while (value & 0xffffff80) != 0:
|
||||
b = (value & 0x7f) | 0x80
|
||||
ret += struct.pack('B', b)
|
||||
value >>= 7
|
||||
ret += struct.pack('B', value)
|
||||
return ret
|
||||
|
||||
|
||||
class VarInt32(AbstractType):
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
value = UnsignedVarInt32.decode(data)
|
||||
return (value >> 1) ^ -(value & 1)
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
# bring it in line with the java binary repr
|
||||
value &= 0xffffffff
|
||||
return UnsignedVarInt32.encode((value << 1) ^ (value >> 31))
|
||||
|
||||
|
||||
class VarInt64(AbstractType):
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
value, i = 0, 0
|
||||
while True:
|
||||
b = data.read(1)
|
||||
if not (b & 0x80):
|
||||
break
|
||||
value |= (b & 0x7f) << i
|
||||
i += 7
|
||||
if i > 63:
|
||||
raise ValueError('Invalid value {}'.format(value))
|
||||
value |= b << i
|
||||
return (value >> 1) ^ -(value & 1)
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
# bring it in line with the java binary repr
|
||||
value &= 0xffffffffffffffff
|
||||
v = (value << 1) ^ (value >> 63)
|
||||
ret = b''
|
||||
while (v & 0xffffffffffffff80) != 0:
|
||||
b = (value & 0x7f) | 0x80
|
||||
ret += struct.pack('B', b)
|
||||
v >>= 7
|
||||
ret += struct.pack('B', v)
|
||||
return ret
|
||||
|
||||
|
||||
class CompactString(String):
|
||||
def decode(self, data):
|
||||
length = UnsignedVarInt32.decode(data) - 1
|
||||
if length < 0:
|
||||
return None
|
||||
value = data.read(length)
|
||||
if len(value) != length:
|
||||
raise ValueError('Buffer underrun decoding string')
|
||||
return value.decode(self.encoding)
|
||||
|
||||
def encode(self, value):
|
||||
if value is None:
|
||||
return UnsignedVarInt32.encode(0)
|
||||
value = str(value).encode(self.encoding)
|
||||
return UnsignedVarInt32.encode(len(value) + 1) + value
|
||||
|
||||
|
||||
class TaggedFields(AbstractType):
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
num_fields = UnsignedVarInt32.decode(data)
|
||||
ret = {}
|
||||
if not num_fields:
|
||||
return ret
|
||||
prev_tag = -1
|
||||
for i in range(num_fields):
|
||||
tag = UnsignedVarInt32.decode(data)
|
||||
if tag <= prev_tag:
|
||||
raise ValueError('Invalid or out-of-order tag {}'.format(tag))
|
||||
prev_tag = tag
|
||||
size = UnsignedVarInt32.decode(data)
|
||||
val = data.read(size)
|
||||
ret[tag] = val
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
ret = UnsignedVarInt32.encode(len(value))
|
||||
for k, v in value.items():
|
||||
# do we allow for other data types ?? It could get complicated really fast
|
||||
assert isinstance(v, bytes), 'Value {} is not a byte array'.format(v)
|
||||
assert isinstance(k, int) and k > 0, 'Key {} is not a positive integer'.format(k)
|
||||
ret += UnsignedVarInt32.encode(k)
|
||||
ret += v
|
||||
return ret
|
||||
|
||||
|
||||
class CompactBytes(AbstractType):
|
||||
@classmethod
|
||||
def decode(cls, data):
|
||||
length = UnsignedVarInt32.decode(data) - 1
|
||||
if length < 0:
|
||||
return None
|
||||
value = data.read(length)
|
||||
if len(value) != length:
|
||||
raise ValueError('Buffer underrun decoding Bytes')
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def encode(cls, value):
|
||||
if value is None:
|
||||
return UnsignedVarInt32.encode(0)
|
||||
else:
|
||||
return UnsignedVarInt32.encode(len(value) + 1) + value
|
||||
|
||||
|
||||
class CompactArray(Array):
|
||||
|
||||
def encode(self, items):
|
||||
if items is None:
|
||||
return UnsignedVarInt32.encode(0)
|
||||
return b''.join(
|
||||
[UnsignedVarInt32.encode(len(items) + 1)] +
|
||||
[self.array_of.encode(item) for item in items]
|
||||
)
|
||||
|
||||
def decode(self, data):
|
||||
length = UnsignedVarInt32.decode(data) - 1
|
||||
if length == -1:
|
||||
return None
|
||||
return [self.array_of.decode(data) for _ in range(length)]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from kafka.record.memory_records import MemoryRecords, MemoryRecordsBuilder
|
||||
|
||||
__all__ = ["MemoryRecords", "MemoryRecordsBuilder"]
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Taken from https://cloud.google.com/appengine/docs/standard/python/refdocs/\
|
||||
# modules/google/appengine/api/files/crc32c?hl=ru
|
||||
#
|
||||
# Copyright 2007 Google Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
"""Implementation of CRC-32C checksumming as in rfc3720 section B.4.
|
||||
See https://en.wikipedia.org/wiki/Cyclic_redundancy_check for details on CRC-32C
|
||||
This code is a manual python translation of c code generated by
|
||||
pycrc 0.7.1 (https://pycrc.org/). Command line used:
|
||||
'./pycrc.py --model=crc-32c --generate c --algorithm=table-driven'
|
||||
"""
|
||||
|
||||
import array
|
||||
|
||||
CRC_TABLE = (
|
||||
0x00000000, 0xf26b8303, 0xe13b70f7, 0x1350f3f4,
|
||||
0xc79a971f, 0x35f1141c, 0x26a1e7e8, 0xd4ca64eb,
|
||||
0x8ad958cf, 0x78b2dbcc, 0x6be22838, 0x9989ab3b,
|
||||
0x4d43cfd0, 0xbf284cd3, 0xac78bf27, 0x5e133c24,
|
||||
0x105ec76f, 0xe235446c, 0xf165b798, 0x030e349b,
|
||||
0xd7c45070, 0x25afd373, 0x36ff2087, 0xc494a384,
|
||||
0x9a879fa0, 0x68ec1ca3, 0x7bbcef57, 0x89d76c54,
|
||||
0x5d1d08bf, 0xaf768bbc, 0xbc267848, 0x4e4dfb4b,
|
||||
0x20bd8ede, 0xd2d60ddd, 0xc186fe29, 0x33ed7d2a,
|
||||
0xe72719c1, 0x154c9ac2, 0x061c6936, 0xf477ea35,
|
||||
0xaa64d611, 0x580f5512, 0x4b5fa6e6, 0xb93425e5,
|
||||
0x6dfe410e, 0x9f95c20d, 0x8cc531f9, 0x7eaeb2fa,
|
||||
0x30e349b1, 0xc288cab2, 0xd1d83946, 0x23b3ba45,
|
||||
0xf779deae, 0x05125dad, 0x1642ae59, 0xe4292d5a,
|
||||
0xba3a117e, 0x4851927d, 0x5b016189, 0xa96ae28a,
|
||||
0x7da08661, 0x8fcb0562, 0x9c9bf696, 0x6ef07595,
|
||||
0x417b1dbc, 0xb3109ebf, 0xa0406d4b, 0x522bee48,
|
||||
0x86e18aa3, 0x748a09a0, 0x67dafa54, 0x95b17957,
|
||||
0xcba24573, 0x39c9c670, 0x2a993584, 0xd8f2b687,
|
||||
0x0c38d26c, 0xfe53516f, 0xed03a29b, 0x1f682198,
|
||||
0x5125dad3, 0xa34e59d0, 0xb01eaa24, 0x42752927,
|
||||
0x96bf4dcc, 0x64d4cecf, 0x77843d3b, 0x85efbe38,
|
||||
0xdbfc821c, 0x2997011f, 0x3ac7f2eb, 0xc8ac71e8,
|
||||
0x1c661503, 0xee0d9600, 0xfd5d65f4, 0x0f36e6f7,
|
||||
0x61c69362, 0x93ad1061, 0x80fde395, 0x72966096,
|
||||
0xa65c047d, 0x5437877e, 0x4767748a, 0xb50cf789,
|
||||
0xeb1fcbad, 0x197448ae, 0x0a24bb5a, 0xf84f3859,
|
||||
0x2c855cb2, 0xdeeedfb1, 0xcdbe2c45, 0x3fd5af46,
|
||||
0x7198540d, 0x83f3d70e, 0x90a324fa, 0x62c8a7f9,
|
||||
0xb602c312, 0x44694011, 0x5739b3e5, 0xa55230e6,
|
||||
0xfb410cc2, 0x092a8fc1, 0x1a7a7c35, 0xe811ff36,
|
||||
0x3cdb9bdd, 0xceb018de, 0xdde0eb2a, 0x2f8b6829,
|
||||
0x82f63b78, 0x709db87b, 0x63cd4b8f, 0x91a6c88c,
|
||||
0x456cac67, 0xb7072f64, 0xa457dc90, 0x563c5f93,
|
||||
0x082f63b7, 0xfa44e0b4, 0xe9141340, 0x1b7f9043,
|
||||
0xcfb5f4a8, 0x3dde77ab, 0x2e8e845f, 0xdce5075c,
|
||||
0x92a8fc17, 0x60c37f14, 0x73938ce0, 0x81f80fe3,
|
||||
0x55326b08, 0xa759e80b, 0xb4091bff, 0x466298fc,
|
||||
0x1871a4d8, 0xea1a27db, 0xf94ad42f, 0x0b21572c,
|
||||
0xdfeb33c7, 0x2d80b0c4, 0x3ed04330, 0xccbbc033,
|
||||
0xa24bb5a6, 0x502036a5, 0x4370c551, 0xb11b4652,
|
||||
0x65d122b9, 0x97baa1ba, 0x84ea524e, 0x7681d14d,
|
||||
0x2892ed69, 0xdaf96e6a, 0xc9a99d9e, 0x3bc21e9d,
|
||||
0xef087a76, 0x1d63f975, 0x0e330a81, 0xfc588982,
|
||||
0xb21572c9, 0x407ef1ca, 0x532e023e, 0xa145813d,
|
||||
0x758fe5d6, 0x87e466d5, 0x94b49521, 0x66df1622,
|
||||
0x38cc2a06, 0xcaa7a905, 0xd9f75af1, 0x2b9cd9f2,
|
||||
0xff56bd19, 0x0d3d3e1a, 0x1e6dcdee, 0xec064eed,
|
||||
0xc38d26c4, 0x31e6a5c7, 0x22b65633, 0xd0ddd530,
|
||||
0x0417b1db, 0xf67c32d8, 0xe52cc12c, 0x1747422f,
|
||||
0x49547e0b, 0xbb3ffd08, 0xa86f0efc, 0x5a048dff,
|
||||
0x8ecee914, 0x7ca56a17, 0x6ff599e3, 0x9d9e1ae0,
|
||||
0xd3d3e1ab, 0x21b862a8, 0x32e8915c, 0xc083125f,
|
||||
0x144976b4, 0xe622f5b7, 0xf5720643, 0x07198540,
|
||||
0x590ab964, 0xab613a67, 0xb831c993, 0x4a5a4a90,
|
||||
0x9e902e7b, 0x6cfbad78, 0x7fab5e8c, 0x8dc0dd8f,
|
||||
0xe330a81a, 0x115b2b19, 0x020bd8ed, 0xf0605bee,
|
||||
0x24aa3f05, 0xd6c1bc06, 0xc5914ff2, 0x37faccf1,
|
||||
0x69e9f0d5, 0x9b8273d6, 0x88d28022, 0x7ab90321,
|
||||
0xae7367ca, 0x5c18e4c9, 0x4f48173d, 0xbd23943e,
|
||||
0xf36e6f75, 0x0105ec76, 0x12551f82, 0xe03e9c81,
|
||||
0x34f4f86a, 0xc69f7b69, 0xd5cf889d, 0x27a40b9e,
|
||||
0x79b737ba, 0x8bdcb4b9, 0x988c474d, 0x6ae7c44e,
|
||||
0xbe2da0a5, 0x4c4623a6, 0x5f16d052, 0xad7d5351,
|
||||
)
|
||||
|
||||
CRC_INIT = 0
|
||||
_MASK = 0xFFFFFFFF
|
||||
|
||||
|
||||
def crc_update(crc, data):
|
||||
"""Update CRC-32C checksum with data.
|
||||
Args:
|
||||
crc: 32-bit checksum to update as long.
|
||||
data: byte array, string or iterable over bytes.
|
||||
Returns:
|
||||
32-bit updated CRC-32C as long.
|
||||
"""
|
||||
if not isinstance(data, array.array) or data.itemsize != 1:
|
||||
buf = array.array("B", data)
|
||||
else:
|
||||
buf = data
|
||||
crc = crc ^ _MASK
|
||||
for b in buf:
|
||||
table_index = (crc ^ b) & 0xff
|
||||
crc = (CRC_TABLE[table_index] ^ (crc >> 8)) & _MASK
|
||||
return crc ^ _MASK
|
||||
|
||||
|
||||
def crc_finalize(crc):
|
||||
"""Finalize CRC-32C checksum.
|
||||
This function should be called as last step of crc calculation.
|
||||
Args:
|
||||
crc: 32-bit checksum as long.
|
||||
Returns:
|
||||
finalized 32-bit checksum as long
|
||||
"""
|
||||
return crc & _MASK
|
||||
|
||||
|
||||
def crc(data):
|
||||
"""Compute CRC-32C checksum of the data.
|
||||
Args:
|
||||
data: byte array, string or iterable over bytes.
|
||||
Returns:
|
||||
32-bit CRC-32C checksum of data as long.
|
||||
"""
|
||||
return crc_finalize(crc_update(CRC_INIT, data))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
# TODO remove the pylint disable once pylint fixes
|
||||
# https://github.com/PyCQA/pylint/issues/2571
|
||||
data = sys.stdin.read() # pylint: disable=assignment-from-no-return
|
||||
print(hex(crc(data)))
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class ABCRecord(object):
|
||||
__slots__ = ()
|
||||
|
||||
@abc.abstractproperty
|
||||
def size_in_bytes(self):
|
||||
""" Number of total bytes in record
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def offset(self):
|
||||
""" Absolute offset of record
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def timestamp(self):
|
||||
""" Epoch milliseconds
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def timestamp_type(self):
|
||||
""" CREATE_TIME(0) or APPEND_TIME(1)
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def key(self):
|
||||
""" Bytes key or None
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def value(self):
|
||||
""" Bytes value or None
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def checksum(self):
|
||||
""" Prior to v2 format CRC was contained in every message. This will
|
||||
be the checksum for v0 and v1 and None for v2 and above.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def validate_crc(self):
|
||||
""" Return True if v0/v1 record matches checksum. noop/True for v2 records
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def headers(self):
|
||||
""" If supported by version list of key-value tuples, or empty list if
|
||||
not supported by format.
|
||||
"""
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class ABCRecordBatchBuilder(object):
|
||||
__slots__ = ()
|
||||
|
||||
@abc.abstractmethod
|
||||
def append(self, offset, timestamp, key, value, headers=None):
|
||||
""" Writes record to internal buffer.
|
||||
|
||||
Arguments:
|
||||
offset (int): Relative offset of record, starting from 0
|
||||
timestamp (int or None): Timestamp in milliseconds since beginning
|
||||
of the epoch (midnight Jan 1, 1970 (UTC)). If omitted, will be
|
||||
set to current time.
|
||||
key (bytes or None): Key of the record
|
||||
value (bytes or None): Value of the record
|
||||
headers (List[Tuple[str, bytes]]): Headers of the record. Header
|
||||
keys can not be ``None``.
|
||||
|
||||
Returns:
|
||||
(bytes, int): Checksum of the written record (or None for v2 and
|
||||
above) and size of the written record.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def size_in_bytes(self, offset, timestamp, key, value, headers):
|
||||
""" Return the expected size change on buffer (uncompressed) if we add
|
||||
this message. This will account for varint size changes and give a
|
||||
reliable size.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def build(self):
|
||||
""" Close for append, compress if needed, write size and header and
|
||||
return a ready to send buffer object.
|
||||
|
||||
Return:
|
||||
bytearray: finished batch, ready to send.
|
||||
"""
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class ABCRecordBatch(object):
|
||||
""" For v2 encapsulates a RecordBatch, for v0/v1 a single (maybe
|
||||
compressed) message.
|
||||
"""
|
||||
__slots__ = ()
|
||||
|
||||
@abc.abstractmethod
|
||||
def __iter__(self):
|
||||
""" Return iterator over records (ABCRecord instances). Will decompress
|
||||
if needed.
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def base_offset(self):
|
||||
""" Return base offset for batch
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def size_in_bytes(self):
|
||||
""" Return size of batch in bytes (includes header overhead)
|
||||
"""
|
||||
|
||||
@abc.abstractproperty
|
||||
def magic(self):
|
||||
""" Return magic value (0, 1, 2) for batch.
|
||||
"""
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class ABCRecords(object):
|
||||
__slots__ = ()
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, buffer):
|
||||
""" Initialize with bytes-like object conforming to the buffer
|
||||
interface (ie. bytes, bytearray, memoryview etc.).
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def size_in_bytes(self):
|
||||
""" Returns the size of inner buffer.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def next_batch(self):
|
||||
""" Return next batch of records (ABCRecordBatch instances).
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def has_next(self):
|
||||
""" True if there are more batches to read, False otherwise.
|
||||
"""
|
||||
@@ -0,0 +1,776 @@
|
||||
# See:
|
||||
# https://github.com/apache/kafka/blob/trunk/clients/src/main/java/org/\
|
||||
# apache/kafka/common/record/DefaultRecordBatch.java
|
||||
# https://github.com/apache/kafka/blob/trunk/clients/src/main/java/org/\
|
||||
# apache/kafka/common/record/DefaultRecord.java
|
||||
|
||||
# RecordBatch and Record implementation for magic 2 and above.
|
||||
# The schema is given below:
|
||||
|
||||
# RecordBatch =>
|
||||
# BaseOffset => Int64
|
||||
# Length => Int32
|
||||
# PartitionLeaderEpoch => Int32
|
||||
# Magic => Int8
|
||||
# CRC => Uint32
|
||||
# Attributes => Int16
|
||||
# LastOffsetDelta => Int32 // also serves as LastSequenceDelta
|
||||
# FirstTimestamp => Int64
|
||||
# MaxTimestamp => Int64
|
||||
# ProducerId => Int64
|
||||
# ProducerEpoch => Int16
|
||||
# BaseSequence => Int32
|
||||
# Records => [Record]
|
||||
|
||||
# Record =>
|
||||
# Length => Varint
|
||||
# Attributes => Int8
|
||||
# TimestampDelta => Varlong
|
||||
# OffsetDelta => Varint
|
||||
# Key => Bytes
|
||||
# Value => Bytes
|
||||
# Headers => [HeaderKey HeaderValue]
|
||||
# HeaderKey => String
|
||||
# HeaderValue => Bytes
|
||||
|
||||
# Note that when compression is enabled (see attributes below), the compressed
|
||||
# record data is serialized directly following the count of the number of
|
||||
# records. (ie Records => [Record], but without length bytes)
|
||||
|
||||
# The CRC covers the data from the attributes to the end of the batch (i.e. all
|
||||
# the bytes that follow the CRC). It is located after the magic byte, which
|
||||
# means that clients must parse the magic byte before deciding how to interpret
|
||||
# the bytes between the batch length and the magic byte. The partition leader
|
||||
# epoch field is not included in the CRC computation to avoid the need to
|
||||
# recompute the CRC when this field is assigned for every batch that is
|
||||
# received by the broker. The CRC-32C (Castagnoli) polynomial is used for the
|
||||
# computation.
|
||||
|
||||
# The current RecordBatch attributes are given below:
|
||||
#
|
||||
# * Unused (6-15)
|
||||
# * Control (5)
|
||||
# * Transactional (4)
|
||||
# * Timestamp Type (3)
|
||||
# * Compression Type (0-2)
|
||||
|
||||
import struct
|
||||
import time
|
||||
from kafka.record.abc import ABCRecord, ABCRecordBatch, ABCRecordBatchBuilder
|
||||
from kafka.record.util import (
|
||||
decode_varint, encode_varint, calc_crc32c, size_of_varint
|
||||
)
|
||||
from kafka.errors import CorruptRecordError, UnsupportedCodecError
|
||||
from kafka.codec import (
|
||||
gzip_encode, snappy_encode, lz4_encode, zstd_encode,
|
||||
gzip_decode, snappy_decode, lz4_decode, zstd_decode
|
||||
)
|
||||
import kafka.codec as codecs
|
||||
|
||||
|
||||
class DefaultRecordBase(object):
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
HEADER_STRUCT = struct.Struct(
|
||||
">q" # BaseOffset => Int64
|
||||
"i" # Length => Int32
|
||||
"i" # PartitionLeaderEpoch => Int32
|
||||
"b" # Magic => Int8
|
||||
"I" # CRC => Uint32
|
||||
"h" # Attributes => Int16
|
||||
"i" # LastOffsetDelta => Int32 // also serves as LastSequenceDelta
|
||||
"q" # FirstTimestamp => Int64
|
||||
"q" # MaxTimestamp => Int64
|
||||
"q" # ProducerId => Int64
|
||||
"h" # ProducerEpoch => Int16
|
||||
"i" # BaseSequence => Int32
|
||||
"i" # Records count => Int32
|
||||
)
|
||||
# Byte offset in HEADER_STRUCT of attributes field. Used to calculate CRC
|
||||
ATTRIBUTES_OFFSET = struct.calcsize(">qiibI")
|
||||
CRC_OFFSET = struct.calcsize(">qiib")
|
||||
AFTER_LEN_OFFSET = struct.calcsize(">qi")
|
||||
|
||||
CODEC_MASK = 0x07
|
||||
CODEC_NONE = 0x00
|
||||
CODEC_GZIP = 0x01
|
||||
CODEC_SNAPPY = 0x02
|
||||
CODEC_LZ4 = 0x03
|
||||
CODEC_ZSTD = 0x04
|
||||
TIMESTAMP_TYPE_MASK = 0x08
|
||||
TRANSACTIONAL_MASK = 0x10
|
||||
CONTROL_MASK = 0x20
|
||||
|
||||
LOG_APPEND_TIME = 1
|
||||
CREATE_TIME = 0
|
||||
NO_PRODUCER_ID = -1
|
||||
NO_SEQUENCE = -1
|
||||
MAX_INT = 2147483647
|
||||
|
||||
def _assert_has_codec(self, compression_type):
|
||||
if compression_type == self.CODEC_GZIP:
|
||||
checker, name = codecs.has_gzip, "gzip"
|
||||
elif compression_type == self.CODEC_SNAPPY:
|
||||
checker, name = codecs.has_snappy, "snappy"
|
||||
elif compression_type == self.CODEC_LZ4:
|
||||
checker, name = codecs.has_lz4, "lz4"
|
||||
elif compression_type == self.CODEC_ZSTD:
|
||||
checker, name = codecs.has_zstd, "zstd"
|
||||
else:
|
||||
raise UnsupportedCodecError("Unrecognized compression type: %s" % (compression_type,))
|
||||
if not checker():
|
||||
raise UnsupportedCodecError(
|
||||
"Libraries for {} compression codec not found".format(name))
|
||||
|
||||
|
||||
class DefaultRecordBatch(DefaultRecordBase, ABCRecordBatch):
|
||||
|
||||
__slots__ = ("_buffer", "_header_data", "_pos", "_num_records",
|
||||
"_next_record_index", "_decompressed")
|
||||
|
||||
def __init__(self, buffer):
|
||||
self._buffer = bytearray(buffer)
|
||||
self._header_data = self.HEADER_STRUCT.unpack_from(self._buffer)
|
||||
self._pos = self.HEADER_STRUCT.size
|
||||
self._num_records = self._header_data[12]
|
||||
self._next_record_index = 0
|
||||
self._decompressed = False
|
||||
|
||||
@property
|
||||
def base_offset(self):
|
||||
return self._header_data[0]
|
||||
|
||||
@property
|
||||
def size_in_bytes(self):
|
||||
return self._header_data[1] + self.AFTER_LEN_OFFSET
|
||||
|
||||
@property
|
||||
def leader_epoch(self):
|
||||
return self._header_data[2]
|
||||
|
||||
@property
|
||||
def magic(self):
|
||||
return self._header_data[3]
|
||||
|
||||
@property
|
||||
def crc(self):
|
||||
return self._header_data[4]
|
||||
|
||||
@property
|
||||
def attributes(self):
|
||||
return self._header_data[5]
|
||||
|
||||
@property
|
||||
def last_offset_delta(self):
|
||||
return self._header_data[6]
|
||||
|
||||
@property
|
||||
def last_offset(self):
|
||||
return self.base_offset + self.last_offset_delta
|
||||
|
||||
@property
|
||||
def next_offset(self):
|
||||
return self.last_offset + 1
|
||||
|
||||
@property
|
||||
def compression_type(self):
|
||||
return self.attributes & self.CODEC_MASK
|
||||
|
||||
@property
|
||||
def timestamp_type(self):
|
||||
return int(bool(self.attributes & self.TIMESTAMP_TYPE_MASK))
|
||||
|
||||
@property
|
||||
def is_transactional(self):
|
||||
return bool(self.attributes & self.TRANSACTIONAL_MASK)
|
||||
|
||||
@property
|
||||
def is_control_batch(self):
|
||||
return bool(self.attributes & self.CONTROL_MASK)
|
||||
|
||||
@property
|
||||
def first_timestamp(self):
|
||||
return self._header_data[7]
|
||||
|
||||
@property
|
||||
def max_timestamp(self):
|
||||
return self._header_data[8]
|
||||
|
||||
@property
|
||||
def producer_id(self):
|
||||
return self._header_data[9]
|
||||
|
||||
def has_producer_id(self):
|
||||
return self.producer_id > self.NO_PRODUCER_ID
|
||||
|
||||
@property
|
||||
def producer_epoch(self):
|
||||
return self._header_data[10]
|
||||
|
||||
@property
|
||||
def base_sequence(self):
|
||||
return self._header_data[11]
|
||||
|
||||
@property
|
||||
def has_sequence(self):
|
||||
return self._header_data[11] != -1 # NO_SEQUENCE
|
||||
|
||||
@property
|
||||
def last_sequence(self):
|
||||
if self.base_sequence == self.NO_SEQUENCE:
|
||||
return self.NO_SEQUENCE
|
||||
return self._increment_sequence(self.base_sequence, self.last_offset_delta)
|
||||
|
||||
def _increment_sequence(self, base, increment):
|
||||
if base > (self.MAX_INT - increment):
|
||||
return increment - (self.MAX_INT - base) - 1
|
||||
return base + increment
|
||||
|
||||
@property
|
||||
def records_count(self):
|
||||
return self._header_data[12]
|
||||
|
||||
def _maybe_uncompress(self):
|
||||
if not self._decompressed:
|
||||
compression_type = self.compression_type
|
||||
if compression_type != self.CODEC_NONE:
|
||||
self._assert_has_codec(compression_type)
|
||||
data = memoryview(self._buffer)[self._pos:]
|
||||
if compression_type == self.CODEC_GZIP:
|
||||
uncompressed = gzip_decode(data)
|
||||
if compression_type == self.CODEC_SNAPPY:
|
||||
uncompressed = snappy_decode(data.tobytes())
|
||||
if compression_type == self.CODEC_LZ4:
|
||||
uncompressed = lz4_decode(data.tobytes())
|
||||
if compression_type == self.CODEC_ZSTD:
|
||||
uncompressed = zstd_decode(data.tobytes())
|
||||
self._buffer = bytearray(uncompressed)
|
||||
self._pos = 0
|
||||
self._decompressed = True
|
||||
|
||||
def _read_msg(
|
||||
self,
|
||||
decode_varint=decode_varint):
|
||||
# Record =>
|
||||
# Length => Varint
|
||||
# Attributes => Int8
|
||||
# TimestampDelta => Varlong
|
||||
# OffsetDelta => Varint
|
||||
# Key => Bytes
|
||||
# Value => Bytes
|
||||
# Headers => [HeaderKey HeaderValue]
|
||||
# HeaderKey => String
|
||||
# HeaderValue => Bytes
|
||||
|
||||
buffer = self._buffer
|
||||
pos = self._pos
|
||||
length, pos = decode_varint(buffer, pos)
|
||||
start_pos = pos
|
||||
_, pos = decode_varint(buffer, pos) # attrs can be skipped for now
|
||||
|
||||
ts_delta, pos = decode_varint(buffer, pos)
|
||||
if self.timestamp_type == self.LOG_APPEND_TIME:
|
||||
timestamp = self.max_timestamp
|
||||
else:
|
||||
timestamp = self.first_timestamp + ts_delta
|
||||
|
||||
offset_delta, pos = decode_varint(buffer, pos)
|
||||
offset = self.base_offset + offset_delta
|
||||
|
||||
key_len, pos = decode_varint(buffer, pos)
|
||||
if key_len >= 0:
|
||||
key = bytes(buffer[pos: pos + key_len])
|
||||
pos += key_len
|
||||
else:
|
||||
key = None
|
||||
|
||||
value_len, pos = decode_varint(buffer, pos)
|
||||
if value_len >= 0:
|
||||
value = bytes(buffer[pos: pos + value_len])
|
||||
pos += value_len
|
||||
else:
|
||||
value = None
|
||||
|
||||
header_count, pos = decode_varint(buffer, pos)
|
||||
if header_count < 0:
|
||||
raise CorruptRecordError("Found invalid number of record "
|
||||
"headers {}".format(header_count))
|
||||
headers = []
|
||||
while header_count:
|
||||
# Header key is of type String, that can't be None
|
||||
h_key_len, pos = decode_varint(buffer, pos)
|
||||
if h_key_len < 0:
|
||||
raise CorruptRecordError(
|
||||
"Invalid negative header key size {}".format(h_key_len))
|
||||
h_key = buffer[pos: pos + h_key_len].decode("utf-8")
|
||||
pos += h_key_len
|
||||
|
||||
# Value is of type NULLABLE_BYTES, so it can be None
|
||||
h_value_len, pos = decode_varint(buffer, pos)
|
||||
if h_value_len >= 0:
|
||||
h_value = bytes(buffer[pos: pos + h_value_len])
|
||||
pos += h_value_len
|
||||
else:
|
||||
h_value = None
|
||||
|
||||
headers.append((h_key, h_value))
|
||||
header_count -= 1
|
||||
|
||||
# validate whether we have read all header bytes in the current record
|
||||
if pos - start_pos != length:
|
||||
raise CorruptRecordError(
|
||||
"Invalid record size: expected to read {} bytes in record "
|
||||
"payload, but instead read {}".format(length, pos - start_pos))
|
||||
self._pos = pos
|
||||
|
||||
if self.is_control_batch:
|
||||
return ControlRecord(
|
||||
length, offset, timestamp, self.timestamp_type, key, value, headers)
|
||||
else:
|
||||
return DefaultRecord(
|
||||
length, offset, timestamp, self.timestamp_type, key, value, headers)
|
||||
|
||||
def __iter__(self):
|
||||
self._maybe_uncompress()
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if self._next_record_index >= self._num_records:
|
||||
if self._pos != len(self._buffer):
|
||||
raise CorruptRecordError(
|
||||
"{} unconsumed bytes after all records consumed".format(
|
||||
len(self._buffer) - self._pos))
|
||||
raise StopIteration
|
||||
try:
|
||||
msg = self._read_msg()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise CorruptRecordError(
|
||||
"Found invalid record structure: {!r}".format(err))
|
||||
else:
|
||||
self._next_record_index += 1
|
||||
return msg
|
||||
|
||||
next = __next__
|
||||
|
||||
def validate_crc(self):
|
||||
assert self._decompressed is False, \
|
||||
"Validate should be called before iteration"
|
||||
|
||||
crc = self.crc
|
||||
data_view = memoryview(self._buffer)[self.ATTRIBUTES_OFFSET:]
|
||||
verify_crc = calc_crc32c(data_view.tobytes())
|
||||
return crc == verify_crc
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
"DefaultRecordBatch(magic={}, base_offset={}, last_offset_delta={},"
|
||||
" first_timestamp={}, max_timestamp={},"
|
||||
" is_transactional={}, producer_id={}, producer_epoch={}, base_sequence={},"
|
||||
" records_count={})".format(
|
||||
self.magic, self.base_offset, self.last_offset_delta,
|
||||
self.first_timestamp, self.max_timestamp,
|
||||
self.is_transactional, self.producer_id, self.producer_epoch, self.base_sequence,
|
||||
self.records_count))
|
||||
|
||||
|
||||
class DefaultRecord(ABCRecord):
|
||||
|
||||
__slots__ = ("_size_in_bytes", "_offset", "_timestamp", "_timestamp_type", "_key", "_value",
|
||||
"_headers")
|
||||
|
||||
def __init__(self, size_in_bytes, offset, timestamp, timestamp_type, key, value, headers):
|
||||
self._size_in_bytes = size_in_bytes
|
||||
self._offset = offset
|
||||
self._timestamp = timestamp
|
||||
self._timestamp_type = timestamp_type
|
||||
self._key = key
|
||||
self._value = value
|
||||
self._headers = headers
|
||||
|
||||
@property
|
||||
def size_in_bytes(self):
|
||||
return self._size_in_bytes
|
||||
|
||||
@property
|
||||
def offset(self):
|
||||
return self._offset
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
""" Epoch milliseconds
|
||||
"""
|
||||
return self._timestamp
|
||||
|
||||
@property
|
||||
def timestamp_type(self):
|
||||
""" CREATE_TIME(0) or APPEND_TIME(1)
|
||||
"""
|
||||
return self._timestamp_type
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
""" Bytes key or None
|
||||
"""
|
||||
return self._key
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
""" Bytes value or None
|
||||
"""
|
||||
return self._value
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._headers
|
||||
|
||||
@property
|
||||
def checksum(self):
|
||||
return None
|
||||
|
||||
def validate_crc(self):
|
||||
return True
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
"DefaultRecord(offset={!r}, timestamp={!r}, timestamp_type={!r},"
|
||||
" key={!r}, value={!r}, headers={!r})".format(
|
||||
self._offset, self._timestamp, self._timestamp_type,
|
||||
self._key, self._value, self._headers)
|
||||
)
|
||||
|
||||
|
||||
class ControlRecord(DefaultRecord):
|
||||
__slots__ = ("_size_in_bytes", "_offset", "_timestamp", "_timestamp_type", "_key", "_value",
|
||||
"_headers", "_version", "_type")
|
||||
|
||||
KEY_STRUCT = struct.Struct(
|
||||
">h" # Current Version => Int16
|
||||
"h" # Type => Int16 (0 indicates an abort marker, 1 indicates a commit)
|
||||
)
|
||||
|
||||
def __init__(self, size_in_bytes, offset, timestamp, timestamp_type, key, value, headers):
|
||||
super(ControlRecord, self).__init__(size_in_bytes, offset, timestamp, timestamp_type, key, value, headers)
|
||||
(self._version, self._type) = self.KEY_STRUCT.unpack(self._key)
|
||||
|
||||
# see https://kafka.apache.org/documentation/#controlbatch
|
||||
@property
|
||||
def version(self):
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def abort(self):
|
||||
return self._type == 0
|
||||
|
||||
@property
|
||||
def commit(self):
|
||||
return self._type == 1
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
"ControlRecord(offset={!r}, timestamp={!r}, timestamp_type={!r},"
|
||||
" version={!r}, type={!r} <{!s}>)".format(
|
||||
self._offset, self._timestamp, self._timestamp_type,
|
||||
self._version, self._type, "abort" if self.abort else "commit")
|
||||
)
|
||||
|
||||
|
||||
class DefaultRecordBatchBuilder(DefaultRecordBase, ABCRecordBatchBuilder):
|
||||
|
||||
# excluding key, value and headers:
|
||||
# 5 bytes length + 10 bytes timestamp + 5 bytes offset + 1 byte attributes
|
||||
MAX_RECORD_OVERHEAD = 21
|
||||
|
||||
__slots__ = ("_magic", "_compression_type", "_batch_size", "_is_transactional",
|
||||
"_producer_id", "_producer_epoch", "_base_sequence",
|
||||
"_first_timestamp", "_max_timestamp", "_last_offset", "_num_records",
|
||||
"_buffer")
|
||||
|
||||
def __init__(
|
||||
self, magic, compression_type, is_transactional,
|
||||
producer_id, producer_epoch, base_sequence, batch_size):
|
||||
assert magic >= 2
|
||||
self._magic = magic
|
||||
self._compression_type = compression_type & self.CODEC_MASK
|
||||
self._batch_size = batch_size
|
||||
self._is_transactional = bool(is_transactional)
|
||||
# KIP-98 fields for EOS
|
||||
self._producer_id = producer_id
|
||||
self._producer_epoch = producer_epoch
|
||||
self._base_sequence = base_sequence
|
||||
|
||||
self._first_timestamp = None
|
||||
self._max_timestamp = None
|
||||
self._last_offset = 0
|
||||
self._num_records = 0
|
||||
|
||||
self._buffer = bytearray(self.HEADER_STRUCT.size)
|
||||
|
||||
def set_producer_state(self, producer_id, producer_epoch, base_sequence, is_transactional):
|
||||
assert not is_transactional or producer_id != -1, "Cannot write transactional messages without a valid producer ID"
|
||||
assert producer_id == -1 or producer_epoch != -1, "Invalid negative producer epoch"
|
||||
assert producer_id == -1 or base_sequence != -1, "Invalid negative sequence number"
|
||||
self._producer_id = producer_id
|
||||
self._producer_epoch = producer_epoch
|
||||
self._base_sequence = base_sequence
|
||||
self._is_transactional = is_transactional
|
||||
|
||||
@property
|
||||
def producer_id(self):
|
||||
return self._producer_id
|
||||
|
||||
@property
|
||||
def producer_epoch(self):
|
||||
return self._producer_epoch
|
||||
|
||||
def _get_attributes(self, include_compression_type=True):
|
||||
attrs = 0
|
||||
if include_compression_type:
|
||||
attrs |= self._compression_type
|
||||
# Timestamp Type is set by Broker
|
||||
if self._is_transactional:
|
||||
attrs |= self.TRANSACTIONAL_MASK
|
||||
# Control batches are only created by Broker
|
||||
return attrs
|
||||
|
||||
def append(self, offset, timestamp, key, value, headers,
|
||||
# Cache for LOAD_FAST opcodes
|
||||
encode_varint=encode_varint, size_of_varint=size_of_varint,
|
||||
get_type=type, type_int=int, time_time=time.time,
|
||||
byte_like=(bytes, bytearray, memoryview),
|
||||
bytearray_type=bytearray, len_func=len, zero_len_varint=1
|
||||
):
|
||||
""" Write message to messageset buffer with MsgVersion 2
|
||||
"""
|
||||
# Check types
|
||||
if get_type(offset) != type_int:
|
||||
raise TypeError(offset)
|
||||
if timestamp is None:
|
||||
timestamp = type_int(time_time() * 1000)
|
||||
elif get_type(timestamp) != type_int:
|
||||
raise TypeError(timestamp)
|
||||
if not (key is None or get_type(key) in byte_like):
|
||||
raise TypeError(
|
||||
"Not supported type for key: {}".format(type(key)))
|
||||
if not (value is None or get_type(value) in byte_like):
|
||||
raise TypeError(
|
||||
"Not supported type for value: {}".format(type(value)))
|
||||
|
||||
# We will always add the first message, so those will be set
|
||||
if self._first_timestamp is None:
|
||||
self._first_timestamp = timestamp
|
||||
self._max_timestamp = timestamp
|
||||
timestamp_delta = 0
|
||||
first_message = 1
|
||||
else:
|
||||
timestamp_delta = timestamp - self._first_timestamp
|
||||
first_message = 0
|
||||
|
||||
# We can't write record right away to out buffer, we need to
|
||||
# precompute the length as first value...
|
||||
message_buffer = bytearray_type(b"\x00") # Attributes
|
||||
write_byte = message_buffer.append
|
||||
write = message_buffer.extend
|
||||
|
||||
encode_varint(timestamp_delta, write_byte)
|
||||
# Base offset is always 0 on Produce
|
||||
encode_varint(offset, write_byte)
|
||||
|
||||
if key is not None:
|
||||
encode_varint(len_func(key), write_byte)
|
||||
write(key)
|
||||
else:
|
||||
write_byte(zero_len_varint)
|
||||
|
||||
if value is not None:
|
||||
encode_varint(len_func(value), write_byte)
|
||||
write(value)
|
||||
else:
|
||||
write_byte(zero_len_varint)
|
||||
|
||||
encode_varint(len_func(headers), write_byte)
|
||||
|
||||
for h_key, h_value in headers:
|
||||
h_key = h_key.encode("utf-8")
|
||||
encode_varint(len_func(h_key), write_byte)
|
||||
write(h_key)
|
||||
if h_value is not None:
|
||||
encode_varint(len_func(h_value), write_byte)
|
||||
write(h_value)
|
||||
else:
|
||||
write_byte(zero_len_varint)
|
||||
|
||||
message_len = len_func(message_buffer)
|
||||
main_buffer = self._buffer
|
||||
|
||||
required_size = message_len + size_of_varint(message_len)
|
||||
# Check if we can write this message
|
||||
if (required_size + len_func(main_buffer) > self._batch_size and
|
||||
not first_message):
|
||||
return None
|
||||
|
||||
# Those should be updated after the length check
|
||||
if self._max_timestamp < timestamp:
|
||||
self._max_timestamp = timestamp
|
||||
self._num_records += 1
|
||||
self._last_offset = offset
|
||||
|
||||
encode_varint(message_len, main_buffer.append)
|
||||
main_buffer.extend(message_buffer)
|
||||
|
||||
return DefaultRecordMetadata(offset, required_size, timestamp)
|
||||
|
||||
def write_header(self, use_compression_type=True):
|
||||
batch_len = len(self._buffer)
|
||||
self.HEADER_STRUCT.pack_into(
|
||||
self._buffer, 0,
|
||||
0, # BaseOffset, set by broker
|
||||
batch_len - self.AFTER_LEN_OFFSET, # Size from here to end
|
||||
0, # PartitionLeaderEpoch, set by broker
|
||||
self._magic,
|
||||
0, # CRC will be set below, as we need a filled buffer for it
|
||||
self._get_attributes(use_compression_type),
|
||||
self._last_offset,
|
||||
self._first_timestamp or 0,
|
||||
self._max_timestamp or 0,
|
||||
self._producer_id,
|
||||
self._producer_epoch,
|
||||
self._base_sequence,
|
||||
self._num_records
|
||||
)
|
||||
crc = calc_crc32c(self._buffer[self.ATTRIBUTES_OFFSET:])
|
||||
struct.pack_into(">I", self._buffer, self.CRC_OFFSET, crc)
|
||||
|
||||
def _maybe_compress(self):
|
||||
if self._compression_type != self.CODEC_NONE:
|
||||
self._assert_has_codec(self._compression_type)
|
||||
header_size = self.HEADER_STRUCT.size
|
||||
data = bytes(self._buffer[header_size:])
|
||||
if self._compression_type == self.CODEC_GZIP:
|
||||
compressed = gzip_encode(data)
|
||||
elif self._compression_type == self.CODEC_SNAPPY:
|
||||
compressed = snappy_encode(data)
|
||||
elif self._compression_type == self.CODEC_LZ4:
|
||||
compressed = lz4_encode(data)
|
||||
elif self._compression_type == self.CODEC_ZSTD:
|
||||
compressed = zstd_encode(data)
|
||||
compressed_size = len(compressed)
|
||||
if len(data) <= compressed_size:
|
||||
# We did not get any benefit from compression, lets send
|
||||
# uncompressed
|
||||
return False
|
||||
else:
|
||||
# Trim bytearray to the required size
|
||||
needed_size = header_size + compressed_size
|
||||
del self._buffer[needed_size:]
|
||||
self._buffer[header_size:needed_size] = compressed
|
||||
return True
|
||||
return False
|
||||
|
||||
def build(self):
|
||||
send_compressed = self._maybe_compress()
|
||||
self.write_header(send_compressed)
|
||||
return self._buffer
|
||||
|
||||
def size(self):
|
||||
""" Return current size of data written to buffer
|
||||
"""
|
||||
return len(self._buffer)
|
||||
|
||||
@classmethod
|
||||
def header_size_in_bytes(self):
|
||||
return self.HEADER_STRUCT.size
|
||||
|
||||
@classmethod
|
||||
def size_in_bytes(self, offset_delta, timestamp_delta, key, value, headers):
|
||||
size_of_body = (
|
||||
1 + # Attrs
|
||||
size_of_varint(offset_delta) +
|
||||
size_of_varint(timestamp_delta) +
|
||||
self.size_of(key, value, headers)
|
||||
)
|
||||
return size_of_body + size_of_varint(size_of_body)
|
||||
|
||||
@classmethod
|
||||
def size_of(cls, key, value, headers):
|
||||
size = 0
|
||||
# Key size
|
||||
if key is None:
|
||||
size += 1
|
||||
else:
|
||||
key_len = len(key)
|
||||
size += size_of_varint(key_len) + key_len
|
||||
# Value size
|
||||
if value is None:
|
||||
size += 1
|
||||
else:
|
||||
value_len = len(value)
|
||||
size += size_of_varint(value_len) + value_len
|
||||
# Header size
|
||||
size += size_of_varint(len(headers))
|
||||
for h_key, h_value in headers:
|
||||
h_key_len = len(h_key.encode("utf-8"))
|
||||
size += size_of_varint(h_key_len) + h_key_len
|
||||
|
||||
if h_value is None:
|
||||
size += 1
|
||||
else:
|
||||
h_value_len = len(h_value)
|
||||
size += size_of_varint(h_value_len) + h_value_len
|
||||
return size
|
||||
|
||||
@classmethod
|
||||
def estimate_size_in_bytes(cls, key, value, headers):
|
||||
""" Get the upper bound estimate on the size of record
|
||||
"""
|
||||
return (
|
||||
cls.HEADER_STRUCT.size + cls.MAX_RECORD_OVERHEAD +
|
||||
cls.size_of(key, value, headers)
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
"DefaultRecordBatchBuilder(magic={}, base_offset={}, last_offset_delta={},"
|
||||
" first_timestamp={}, max_timestamp={},"
|
||||
" is_transactional={}, producer_id={}, producer_epoch={}, base_sequence={},"
|
||||
" records_count={})".format(
|
||||
self._magic, 0, self._last_offset,
|
||||
self._first_timestamp or 0, self._max_timestamp or 0,
|
||||
self._is_transactional, self._producer_id, self._producer_epoch, self._base_sequence,
|
||||
self._num_records))
|
||||
|
||||
|
||||
class DefaultRecordMetadata(object):
|
||||
|
||||
__slots__ = ("_size", "_timestamp", "_offset")
|
||||
|
||||
def __init__(self, offset, size, timestamp):
|
||||
self._offset = offset
|
||||
self._size = size
|
||||
self._timestamp = timestamp
|
||||
|
||||
@property
|
||||
def offset(self):
|
||||
return self._offset
|
||||
|
||||
@property
|
||||
def crc(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
return self._timestamp
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
"DefaultRecordMetadata(offset={!r}, size={!r}, timestamp={!r})"
|
||||
.format(self._offset, self._size, self._timestamp)
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user