From 7b308c537a4cd88d8040c6c67f6485dc0d09dad2 Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Thu, 25 Apr 2024 15:30:42 +0100 Subject: [PATCH 01/30] Fix the release build (#1177) previous variable was only set for a PR, however the release uses a tag trigger. Co-authored-by: stheppi --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3a736075..15ac18fb8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -231,7 +231,7 @@ jobs: format: 'HTML' args: >- --failOnCVSS 5 - --suppression https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.owner.login }}/${{github.event.repository.name}}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml + --suppression https://raw.githubusercontent.com/${{ github.repository_owner }}/${{ github.event.repository.name }}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml - name: Upload Test results uses: actions/upload-artifact@master with: From e9ac58328da88fca558cb5c7fbc45c4787fc587d Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Thu, 25 Apr 2024 16:09:58 +0100 Subject: [PATCH 02/30] Fix the release build (#1178) * Fix the release build previous variable was only set for a PR, however the release uses a tag trigger. This time it hardcodes lensesio * Uses ${{ github.event.repository.owner.login }} --------- Co-authored-by: stheppi --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15ac18fb8..9ba8b6fbf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -231,7 +231,7 @@ jobs: format: 'HTML' args: >- --failOnCVSS 5 - --suppression https://raw.githubusercontent.com/${{ github.repository_owner }}/${{ github.event.repository.name }}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml + --suppression https://raw.githubusercontent.com/${{ github.event.repository.owner.login }}/${{ github.event.repository.name }}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml - name: Upload Test results uses: actions/upload-artifact@master with: From b604a489c8206f1161ab239470e60c0958e38fee Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Fri, 26 Apr 2024 11:22:12 +0100 Subject: [PATCH 03/30] =?UTF-8?q?Uses=20${{=20github.event.pull=5Frequest.?= =?UTF-8?q?head.repo.owner.login=20||=20github.ev=E2=80=A6=20(#1182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Uses ${{ github.event.pull_request.head.repo.owner.login || github.event.repository.owner.login}} * Applying to the java connectors --------- Co-authored-by: stheppi --- .github/workflows/build.yml | 2 +- .github/workflows/java-build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ba8b6fbf..f9251e718 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -231,7 +231,7 @@ jobs: format: 'HTML' args: >- --failOnCVSS 5 - --suppression https://raw.githubusercontent.com/${{ github.event.repository.owner.login }}/${{ github.event.repository.name }}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml + --suppression https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.owner.login || github.event.repository.owner.login }}/${{ github.event.repository.name }}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml - name: Upload Test results uses: actions/upload-artifact@master with: diff --git a/.github/workflows/java-build.yml b/.github/workflows/java-build.yml index 04bedeb28..402bd0b3c 100644 --- a/.github/workflows/java-build.yml +++ b/.github/workflows/java-build.yml @@ -126,7 +126,7 @@ jobs: format: 'HTML' args: >- --failOnCVSS 5 - --suppression https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.owner.login }}/${{github.event.repository.name}}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml + --suppression https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.owner.login || github.event.repository.owner.login }}/${{github.event.repository.name}}/${{ steps.branch_name.outputs.tag }}${{ steps.branch_name.outputs.current_branch }}/suppression.xml - name: Upload Test results uses: actions/upload-artifact@master From e02fcc0b3654ba2c2f99a4df43f61353408a1e6a Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:41:25 +0200 Subject: [PATCH 04/30] Update s3, sts to 2.25.38 (#1180) --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index b4ceccc19..1c1af96a2 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -70,7 +70,7 @@ object Dependencies { val jerseyCommonVersion = "3.1.6" val calciteVersion = "1.34.0" - val awsSdkVersion = "2.25.36" + val awsSdkVersion = "2.25.38" val azureDataLakeVersion = "12.18.3" val azureIdentityVersion = "1.11.4" From de383910ae212b2aa6fd00aec4344c070ef60477 Mon Sep 17 00:00:00 2001 From: David Sloan <33483659+davidsloan@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:23:02 +0100 Subject: [PATCH 05/30] Update/azure (#1183) * Update azure-storage-file-datalake to 12.18.4 * Update azure-identity to 1.12.0 * Update azure-core to 1.48.0 * Suppressing false positive CVE from latest Azure libraries --------- Co-authored-by: Scala Steward --- project/Dependencies.scala | 6 +++--- suppression.xml | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 1c1af96a2..edfeac18a 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -72,9 +72,9 @@ object Dependencies { val calciteVersion = "1.34.0" val awsSdkVersion = "2.25.38" - val azureDataLakeVersion = "12.18.3" - val azureIdentityVersion = "1.11.4" - val azureCoreVersion = "1.47.0" + val azureDataLakeVersion = "12.18.4" + val azureIdentityVersion = "1.12.0" + val azureCoreVersion = "1.48.0" val gcpStorageVersion = "2.37.0" val jacksonVersion = "2.17.0" diff --git a/suppression.xml b/suppression.xml index 865acc02e..c534b47c1 100644 --- a/suppression.xml +++ b/suppression.xml @@ -103,4 +103,13 @@ ^pkg:maven/org\.threeten/threetenbp@.*$ CVE-2024-23082 + + + + + ^pkg:maven/com\.azure/azure\-xml@.*$ + CVE-2023-36052 + \ No newline at end of file From 7e35eaf272f68296d36468db0d6e291eea109299 Mon Sep 17 00:00:00 2001 From: David Sloan <33483659+davidsloan@users.noreply.github.com> Date: Fri, 26 Apr 2024 14:20:40 +0100 Subject: [PATCH 06/30] Enabling java module in scala via sbt (#1175) * Enabling java module in scala via sbt * supposedly successful migration to java's JarManifest * AsciiArtPrinter migrated to java * remove unused commons, tweak dependency checker for java modules * removed unnecessary logfile * formatting (scalaFmt) * JarManifest handles scenario when it's not a file now, added asserj * JarManifest made more functional * making use of JarManifestProvided trait * running formatting (scalafmt) * pushing java connectors version to 7.0.1-SNAPSHOT * PR comments, closing JarFile resources properly --------- Co-authored-by: Mati Urban --- build.sbt | 18 ++++- java-connectors/build.gradle | 2 +- .../common/util/JarManifest.java | 72 +++++++++---------- .../common/util/JarManifestTest.java | 13 ++++ .../sink/S3ConsumerGroupsSinkConnector.scala | 7 +- .../s3/sink/S3ConsumerGroupsSinkTask.scala | 10 +-- .../connect/aws/s3/sink/S3SinkConnector.scala | 7 +- .../connect/aws/s3/sink/S3SinkTask.scala | 4 +- .../aws/s3/source/S3SourceConnector.scala | 7 +- .../datalake/sink/DatalakeSinkConnector.scala | 7 +- .../datalake/sink/DatalakeSinkTask.scala | 4 +- .../sink/DocumentDbSinkConnector.scala | 8 +-- .../documentdb/sink/DocumentDbSinkTask.scala | 9 ++- .../sink/CassandraSinkConnector.scala | 12 ++-- .../cassandra/sink/CassandraSinkTask.scala | 9 +-- .../source/CassandraSourceConnector.scala | 16 ++--- .../source/CassandraSourceTask.scala | 14 +--- .../cloud/common/sink/CloudSinkTask.scala | 6 +- .../cloud/common/source/CloudSourceTask.scala | 11 ++- .../common/utils/AsciiArtPrinter.scala | 43 ----------- .../common/utils/JarManifest.scala | 60 ---------------- .../common/utils/JarManifestProvided.scala | 26 +++++++ .../elastic6/ElasticSinkConnector.scala | 13 ++-- .../connect/elastic6/ElasticSinkTask.scala | 8 +-- .../elastic7/ElasticSinkConnector.scala | 13 ++-- .../connect/elastic7/ElasticSinkTask.scala | 9 +-- .../ftp/source/FtpSourceConnector.scala | 9 +-- .../connect/ftp/source/FtpSourceTask.scala | 9 +-- .../sink/GCPStorageSinkConnector.scala | 7 +- .../gcp/storage/sink/GCPStorageSinkTask.scala | 4 +- .../source/GCPStorageSourceConnector.scala | 7 +- .../connect/http/sink/HttpSinkConnector.scala | 6 +- .../connect/http/sink/HttpSinkTask.scala | 10 ++- .../connect/influx/InfluxSinkConnector.scala | 12 ++-- .../connect/influx/InfluxSinkTask.scala | 11 ++- .../connect/jms/sink/JMSSinkConnector.scala | 10 +-- .../connect/jms/sink/JMSSinkTask.scala | 8 +-- .../jms/source/JMSSourceConnector.scala | 9 +-- .../connect/jms/source/JMSSourceTask.scala | 13 ++-- .../mongodb/sink/MongoSinkConnector.scala | 7 +- .../connect/mongodb/sink/MongoSinkTask.scala | 9 +-- .../connect/mqtt/sink/MqttSinkConnector.scala | 7 +- .../connect/mqtt/sink/MqttSinkTask.scala | 9 +-- .../mqtt/source/MqttSourceConnector.scala | 8 +-- .../connect/mqtt/source/MqttSourceTask.scala | 9 +-- .../redis/sink/RedisSinkConnector.scala | 9 +-- .../connect/redis/sink/RedisSinkTask.scala | 9 +-- project/Dependencies.scala | 19 ++++- 48 files changed, 226 insertions(+), 383 deletions(-) delete mode 100644 kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/AsciiArtPrinter.scala delete mode 100644 kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifest.scala create mode 100644 kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifestProvided.scala diff --git a/build.sbt b/build.sbt index 8662b22a7..78b3f65fe 100644 --- a/build.sbt +++ b/build.sbt @@ -12,6 +12,7 @@ ThisBuild / scalaVersion := Dependencies.scalaVersion lazy val subProjects: Seq[Project] = Seq( `query-language`, + `java-common`, common, `sql-common`, `cloud-common`, @@ -58,6 +59,19 @@ lazy val `query-language` = (project in file("java-connectors/kafka-connect-quer .configureTests(baseTestDeps) .configureAntlr() +lazy val `java-common` = (project in file("java-connectors/kafka-connect-common")) + .settings( + settings ++ + Seq( + name := "kafka-connect-java-common", + description := "Common components from java", + libraryDependencies ++= javaCommonDeps, + publish / skip := true, + ), + ) + .configureAssembly(false) + .configureTests(javaCommonTestDeps) + lazy val `sql-common` = (project in file("kafka-connect-sql-common")) .dependsOn(`query-language`) .dependsOn(`common`) @@ -75,6 +89,7 @@ lazy val `sql-common` = (project in file("kafka-connect-sql-common")) lazy val common = (project in file("kafka-connect-common")) .dependsOn(`query-language`) + .dependsOn(`java-common`) .settings( settings ++ Seq( @@ -459,7 +474,8 @@ val generateDepCheckModulesList = taskKey[Seq[File]]("generateDepCheckModulesLis Compile / generateModulesList := new FileWriter(subProjects).generate((Compile / resourceManaged).value / "modules.txt") Compile / generateDepCheckModulesList := - new FileWriter(subProjects.tail).generate((Compile / resourceManaged).value / "depcheck-modules.txt") + new FileWriter(subProjects.filter(sp => !sp.base.asPath.startsWith("java-connectors/"))) + .generate((Compile / resourceManaged).value / "depcheck-modules.txt") Compile / generateItModulesList := new FileWriter( subProjects.filter(p => p.containsDir("src/it")), diff --git a/java-connectors/build.gradle b/java-connectors/build.gradle index c7c74b793..0a413c30e 100644 --- a/java-connectors/build.gradle +++ b/java-connectors/build.gradle @@ -8,7 +8,7 @@ plugins { allprojects { group = "io.lenses.streamreactor" - version = "6.4.0-SNAPSHOT" + version = "7.0.1-SNAPSHOT" description = "stream-reactor" apply plugin: 'java' diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java index 0306b5389..d59505931 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java @@ -29,12 +29,15 @@ import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.jar.Attributes; import java.util.jar.JarFile; -import java.util.jar.Manifest; +import java.util.stream.Collectors; /** * Class that reads JAR Manifest files so we can easily get some of the properties from it. @@ -44,21 +47,24 @@ public class JarManifest { private static final String UNKNOWN = "unknown"; private static final String NEW_LINE = System.getProperty("line.separator"); private static final String SEMICOLON = ":"; - private final Map jarAttributes = new HashMap<>(); + private Map jarAttributes = new HashMap<>(); /** * Creates JarManifest. * @param location Jar file location */ public JarManifest(URL location) { - Manifest manifest; - - try (JarFile jarFile = new JarFile(new File(location.toURI()))) { - manifest = jarFile.getManifest(); + try { + File file = new File(location.toURI()); + if (file.isFile()) { + try (JarFile jarFile = new JarFile(file)) { + ofNullable(jarFile.getManifest()).flatMap(mf -> of(mf.getMainAttributes())) + .ifPresent(mainAttrs -> jarAttributes = extractMainAttributes(mainAttrs)); + } + } } catch (URISyntaxException | IOException e) { throw new ConnectorStartupException(e); } - extractMainAttributes(manifest.getMainAttributes()); } /** @@ -66,29 +72,23 @@ public JarManifest(URL location) { * @param jarFile */ public JarManifest(JarFile jarFile) { - Manifest manifest; - try { - Optional jarFileOptional = of(jarFile); - manifest = jarFileOptional.get().getManifest(); - } catch (NullPointerException | IOException e) { - throw new ConnectorStartupException(e); + Optional jarFileOptional = ofNullable(jarFile); + if (jarFileOptional.isPresent()) { + try (JarFile jf = jarFileOptional.get()) { + ofNullable(jf.getManifest()).flatMap(mf -> of(mf.getMainAttributes())) + .ifPresent(mainAttrs -> jarAttributes = extractMainAttributes(mainAttrs)); + } catch (IOException e) { + throw new ConnectorStartupException(e); + } } - extractMainAttributes(manifest.getMainAttributes()); } - private void extractMainAttributes(Attributes mainAttributes) { - jarAttributes.put(REACTOR_VER.getAttributeName(), - ofNullable(mainAttributes.getValue(REACTOR_VER.getAttributeName())).orElse(UNKNOWN)); - jarAttributes.put(KAFKA_VER.getAttributeName(), - ofNullable(mainAttributes.getValue(KAFKA_VER.getAttributeName())).orElse(UNKNOWN)); - jarAttributes.put(GIT_REPO.getAttributeName(), - ofNullable(mainAttributes.getValue(GIT_REPO.getAttributeName())).orElse(UNKNOWN)); - jarAttributes.put(GIT_HASH.getAttributeName(), - ofNullable(mainAttributes.getValue(GIT_HASH.getAttributeName())).orElse(UNKNOWN)); - jarAttributes.put(GIT_TAG.getAttributeName(), - ofNullable(mainAttributes.getValue(GIT_TAG.getAttributeName())).orElse(UNKNOWN)); - jarAttributes.put(REACTOR_DOCS.getAttributeName(), - ofNullable(mainAttributes.getValue(REACTOR_DOCS.getAttributeName())).orElse(UNKNOWN)); + private Map extractMainAttributes(Attributes mainAttributes) { + return Collections.unmodifiableMap(Arrays.stream(ManifestAttributes.values()) + .collect(Collectors.toMap(ManifestAttributes::getAttributeName, + manifestAttribute -> + ofNullable(mainAttributes.getValue(manifestAttribute.getAttributeName())).orElse(UNKNOWN)) + )); } /** @@ -103,18 +103,12 @@ public String getVersion() { */ public String buildManifestString() { StringBuilder manifestBuilder = new StringBuilder(); - manifestBuilder.append(REACTOR_VER.attributeName).append(SEMICOLON) - .append(jarAttributes.get(REACTOR_VER.getAttributeName())).append(NEW_LINE); - manifestBuilder.append(KAFKA_VER.attributeName).append(SEMICOLON) - .append(jarAttributes.get(KAFKA_VER.getAttributeName())).append(NEW_LINE); - manifestBuilder.append(GIT_REPO.attributeName).append(SEMICOLON) - .append(jarAttributes.get(GIT_REPO.getAttributeName())).append(NEW_LINE); - manifestBuilder.append(GIT_HASH.attributeName).append(SEMICOLON) - .append(jarAttributes.get(GIT_HASH.getAttributeName())).append(NEW_LINE); - manifestBuilder.append(GIT_TAG.attributeName).append(SEMICOLON) - .append(jarAttributes.get(GIT_TAG.getAttributeName())).append(NEW_LINE); - manifestBuilder.append(REACTOR_DOCS.attributeName).append(SEMICOLON) - .append(jarAttributes.get(REACTOR_DOCS.getAttributeName())).append(NEW_LINE); + List attributesInStringOrder = + List.of(REACTOR_VER, KAFKA_VER, GIT_REPO, GIT_HASH, GIT_TAG, REACTOR_DOCS); + attributesInStringOrder.forEach( + attribute -> manifestBuilder.append(attribute.attributeName).append(SEMICOLON) + .append(jarAttributes.get(attribute.getAttributeName())).append(NEW_LINE) + ); return manifestBuilder.toString(); } diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java index a1d7188bf..eff71797b 100644 --- a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java @@ -15,6 +15,7 @@ */ package io.lenses.streamreactor.common.util; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -30,6 +31,7 @@ class JarManifestTest { private static final String UNKNOWN = "unknown"; + private static final String EMPTY_STRING = ""; JarManifest testObj; @@ -79,4 +81,15 @@ void getVersionShouldReturnUnknownVersionIfNotIncludedInManifest() throws IOExce verify(attributes).getValue(ManifestAttributes.REACTOR_VER.getAttributeName()); assertEquals(UNKNOWN, streamReactorVersion); } + + @Test + void getVersionShouldReturnDefaultIfFileProvidedIsNotJar() { + //given + + //when + testObj = new JarManifest(getClass().getProtectionDomain().getCodeSource().getLocation()); + + //then + assertThat(testObj.getVersion()).isEqualTo(EMPTY_STRING); + } } \ No newline at end of file diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkConnector.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkConnector.scala index 07b9009a3..b1e295fc8 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkConnector.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkConnector.scala @@ -16,7 +16,7 @@ package io.lenses.streamreactor.connect.aws.s3.sink import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings import io.lenses.streamreactor.connect.aws.s3.sink.config.S3ConsumerGroupsSinkConfigDef import io.lenses.streamreactor.connect.cloud.common.config.TaskDistributor @@ -29,13 +29,10 @@ import java.util /** * A connector which stores the latest Kafka consumer group offset from "__consumer_offsets" topic in S3. */ -class S3ConsumerGroupsSinkConnector extends SinkConnector with LazyLogging { +class S3ConsumerGroupsSinkConnector extends SinkConnector with LazyLogging with JarManifestProvided { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val props: util.Map[String, String] = new util.HashMap[String, String]() - override def version(): String = manifest.version() - override def taskClass(): Class[_ <: Task] = classOf[S3ConsumerGroupsSinkTask] override def config(): ConfigDef = S3ConsumerGroupsSinkConfigDef.config diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkTask.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkTask.scala index d8758c0d6..26ee3a9bc 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkTask.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ConsumerGroupsSinkTask.scala @@ -17,8 +17,8 @@ package io.lenses.streamreactor.connect.aws.s3.sink import cats.implicits.toShow import io.lenses.streamreactor.common.errors.ErrorHandler -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.aws.s3.auth.AwsS3ClientCreator import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings.CONNECTOR_PREFIX import io.lenses.streamreactor.connect.aws.s3.sink.config.S3ConsumerGroupsSinkConfig @@ -45,15 +45,11 @@ import scala.jdk.CollectionConverters.MapHasAsScala * But since the s3 key is unique for group-topic-partition the last write will be the latest offset. */ -class S3ConsumerGroupsSinkTask extends SinkTask with ErrorHandler { - - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) +class S3ConsumerGroupsSinkTask extends SinkTask with ErrorHandler with JarManifestProvided { private var connectorTaskId: ConnectorTaskId = _ private var writerManager: ConsumerGroupsWriter = _ - override def version(): String = manifest.version() - override def start(fallbackProps: util.Map[String, String]): Unit = { printAsciiHeader(manifest, "/aws-s3-cg-sink-ascii.txt") diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkConnector.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkConnector.scala index 9577872b7..5bec03978 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkConnector.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkConnector.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.aws.s3.sink -import io.lenses.streamreactor.common.utils.JarManifest import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings import io.lenses.streamreactor.connect.aws.s3.sink.config.S3SinkConfigDef import io.lenses.streamreactor.connect.cloud.common.config.TaskDistributor @@ -26,13 +26,10 @@ import org.apache.kafka.connect.sink.SinkConnector import java.util -class S3SinkConnector extends SinkConnector with LazyLogging { +class S3SinkConnector extends SinkConnector with LazyLogging with JarManifestProvided { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val props: util.Map[String, String] = new util.HashMap[String, String]() - override def version(): String = manifest.version() - override def taskClass(): Class[_ <: Task] = classOf[S3SinkTask] override def config(): ConfigDef = S3SinkConfigDef.config diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala index 672025fe6..315f4dd99 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala @@ -15,7 +15,7 @@ */ package io.lenses.streamreactor.connect.aws.s3.sink -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.JarManifest import io.lenses.streamreactor.connect.aws.s3.auth.AwsS3ClientCreator import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings import io.lenses.streamreactor.connect.aws.s3.model.location.S3LocationValidator @@ -33,7 +33,7 @@ class S3SinkTask extends CloudSinkTask[S3FileMetadata, S3SinkConfig, S3Client]( S3ConfigSettings.CONNECTOR_PREFIX, "/aws-s3-sink-ascii.txt", - JarManifest(S3SinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), + new JarManifest(S3SinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), ) { val writerManagerCreator = new WriterManagerCreator[S3FileMetadata, S3SinkConfig]() diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceConnector.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceConnector.scala index 3c11a769e..ee10bdf0d 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceConnector.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceConnector.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.aws.s3.source -import io.lenses.streamreactor.common.utils.JarManifest import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings.CONNECTOR_PREFIX import io.lenses.streamreactor.connect.aws.s3.source.config.S3SourceConfigDef import io.lenses.streamreactor.connect.cloud.common.config.TaskDistributor @@ -27,13 +27,10 @@ import org.apache.kafka.connect.source.SourceConnector import java.util -class S3SourceConnector extends SourceConnector with LazyLogging { +class S3SourceConnector extends SourceConnector with LazyLogging with JarManifestProvided { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val props: util.Map[String, String] = new util.HashMap[String, String]() - override def version(): String = manifest.version() - override def taskClass(): Class[_ <: Task] = classOf[S3SourceTask] override def config(): ConfigDef = S3SourceConfigDef.config diff --git a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkConnector.scala b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkConnector.scala index e013b2a0f..9df32f6e3 100644 --- a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkConnector.scala +++ b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkConnector.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.datalake.sink -import io.lenses.streamreactor.common.utils.JarManifest import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.datalake.config.AzureConfigSettings import io.lenses.streamreactor.connect.datalake.sink.config.DatalakeSinkConfigDef import io.lenses.streamreactor.connect.cloud.common.config.TaskDistributor @@ -26,13 +26,10 @@ import org.apache.kafka.connect.sink.SinkConnector import java.util -class DatalakeSinkConnector extends SinkConnector with LazyLogging { +class DatalakeSinkConnector extends SinkConnector with LazyLogging with JarManifestProvided { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val props: util.Map[String, String] = new util.HashMap[String, String]() - override def version(): String = manifest.version() - override def taskClass(): Class[_ <: Task] = classOf[DatalakeSinkTask] override def config(): ConfigDef = DatalakeSinkConfigDef.config diff --git a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala index 27bdfcbdc..ca67bf841 100644 --- a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala +++ b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala @@ -16,7 +16,7 @@ package io.lenses.streamreactor.connect.datalake.sink import com.azure.storage.file.datalake.DataLakeServiceClient -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.JarManifest import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.sink.CloudSinkTask import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface @@ -31,7 +31,7 @@ class DatalakeSinkTask extends CloudSinkTask[DatalakeFileMetadata, DatalakeSinkConfig, DataLakeServiceClient]( AzureConfigSettings.CONNECTOR_PREFIX, "/datalake-sink-ascii.txt", - JarManifest(DatalakeSinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), + new JarManifest(DatalakeSinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), ) { override def createClient(config: DatalakeSinkConfig): Either[Throwable, DataLakeServiceClient] = diff --git a/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkConnector.scala b/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkConnector.scala index 8acbbb14a..fb969a614 100644 --- a/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkConnector.scala +++ b/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkConnector.scala @@ -16,7 +16,6 @@ package io.lenses.streamreactor.connect.azure.documentdb.sink import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest import io.lenses.streamreactor.connect.azure.documentdb.DocumentClientProvider import io.lenses.streamreactor.connect.azure.documentdb.config.DocumentDbConfig import io.lenses.streamreactor.connect.azure.documentdb.config.DocumentDbConfigConstants @@ -25,6 +24,7 @@ import io.lenses.streamreactor.connect.azure.documentdb.config.DocumentDbSinkSet import java.util import com.microsoft.azure.documentdb._ import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.common.config.ConfigException import org.apache.kafka.connect.connector.Task @@ -47,9 +47,9 @@ import scala.util.Try */ class DocumentDbSinkConnector private[sink] (builder: DocumentDbSinkSettings => DocumentClient) extends SinkConnector - with StrictLogging { + with StrictLogging + with JarManifestProvided { private var configProps: util.Map[String, String] = _ - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) def this() = this(DocumentClientProvider.get) @@ -118,8 +118,6 @@ class DocumentDbSinkConnector private[sink] (builder: DocumentDbSinkSettings => override def stop(): Unit = {} - override def version(): String = manifest.version() - override def config(): ConfigDef = DocumentDbConfig.config private def readOrCreateCollections( diff --git a/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkTask.scala b/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkTask.scala index 3137e92c4..fc445ab5d 100644 --- a/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkTask.scala +++ b/kafka-connect-azure-documentdb/src/main/scala/io/lenses/streamreactor/connect/azure/documentdb/sink/DocumentDbSinkTask.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.azure.documentdb.sink -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.azure.documentdb.config.DocumentDbConfig import io.lenses.streamreactor.connect.azure.documentdb.config.DocumentDbConfigConstants @@ -40,9 +40,8 @@ import scala.util.Try * Kafka Connect Azure Document DB sink task. Called by * framework to put records to the target sink */ -class DocumentDbSinkTask extends SinkTask with StrictLogging { +class DocumentDbSinkTask extends SinkTask with StrictLogging with JarManifestProvided { private var writer: Option[DocumentDbWriter] = None - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false @@ -86,5 +85,5 @@ class DocumentDbSinkTask extends SinkTask with StrictLogging { override def flush(map: util.Map[TopicPartition, OffsetAndMetadata]): Unit = {} - override def version: String = manifest.version() + override def version: String = manifest.getVersion() } diff --git a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkConnector.scala b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkConnector.scala index a6a264e54..91c7c79dc 100644 --- a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkConnector.scala +++ b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkConnector.scala @@ -15,18 +15,17 @@ */ package io.lenses.streamreactor.connect.cassandra.sink +import com.typesafe.scalalogging.StrictLogging import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest - -import java.util +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.cassandra.config.CassandraConfigConstants import io.lenses.streamreactor.connect.cassandra.config.CassandraConfigSink -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.errors.ConnectException import org.apache.kafka.connect.sink.SinkConnector +import java.util import scala.jdk.CollectionConverters.MapHasAsScala import scala.jdk.CollectionConverters.SeqHasAsJava import scala.util.Failure @@ -38,10 +37,9 @@ import scala.util.Try * * Sets up CassandraSinkTask and configurations for the tasks. */ -class CassandraSinkConnector extends SinkConnector with StrictLogging { +class CassandraSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private var configProps: util.Map[String, String] = _ private val configDef = CassandraConfigSink.sinkConfig - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SinkTask class to use @@ -77,7 +75,5 @@ class CassandraSinkConnector extends SinkConnector with StrictLogging { override def stop(): Unit = {} - override def version(): String = manifest.version() - override def config(): ConfigDef = configDef } diff --git a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkTask.scala b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkTask.scala index 7c7a3db9c..eee85589c 100644 --- a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkTask.scala +++ b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/sink/CassandraSinkTask.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.cassandra.sink -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import java.util @@ -41,11 +41,10 @@ import scala.util.Try * Kafka Connect Cassandra sink task. Called by * framework to put records to the target sink */ -class CassandraSinkTask extends SinkTask with StrictLogging { +class CassandraSinkTask extends SinkTask with StrictLogging with JarManifestProvided { private var writer: Option[CassandraJsonWriter] = None private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) logger.info("Task initialising") /** @@ -91,6 +90,4 @@ class CassandraSinkTask extends SinkTask with StrictLogging { } override def flush(map: util.Map[TopicPartition, OffsetAndMetadata]): Unit = {} - - override def version: String = manifest.version() } diff --git a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceConnector.scala b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceConnector.scala index bfc0e4182..ba9e70e04 100644 --- a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceConnector.scala +++ b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceConnector.scala @@ -15,17 +15,17 @@ */ package io.lenses.streamreactor.connect.cassandra.source -import java.util +import com.typesafe.scalalogging.StrictLogging import io.lenses.kcql.Kcql -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.cassandra.config.CassandraConfigConstants import io.lenses.streamreactor.connect.cassandra.config.CassandraConfigSource -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.source.SourceConnector import org.apache.kafka.connect.util.ConnectorUtils +import java.util import scala.jdk.CollectionConverters.ListHasAsScala import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala @@ -37,11 +37,10 @@ import scala.jdk.CollectionConverters.SeqHasAsJava * * Sets up CassandraSourceTask and configurations for the tasks. */ -class CassandraSourceConnector extends SourceConnector with StrictLogging { +class CassandraSourceConnector extends SourceConnector with StrictLogging with JarManifestProvided { private var configProps: Option[util.Map[String, String]] = None private val configDef = CassandraConfigSource.sourceConfig - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * Defines the sink class to use @@ -86,12 +85,5 @@ class CassandraSourceConnector extends SourceConnector with StrictLogging { override def stop(): Unit = {} - /** - * Gets the version of this sink. - * - * @return - */ - override def version(): String = manifest.version() - override def config(): ConfigDef = configDef } diff --git a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceTask.scala b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceTask.scala index 67932ff8b..4c1fddcb8 100644 --- a/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceTask.scala +++ b/kafka-connect-cassandra/src/main/scala/io/lenses/streamreactor/connect/cassandra/source/CassandraSourceTask.scala @@ -16,8 +16,7 @@ package io.lenses.streamreactor.connect.cassandra.source import io.lenses.streamreactor.common.queues.QueueHelpers -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader import java.util import java.util.concurrent.LinkedBlockingQueue @@ -27,6 +26,7 @@ import io.lenses.streamreactor.connect.cassandra.config.CassandraConfigSource import io.lenses.streamreactor.connect.cassandra.config.CassandraSettings import io.lenses.streamreactor.connect.cassandra.config.CassandraSourceSetting import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import org.apache.kafka.connect.errors.ConnectException import org.apache.kafka.connect.source.SourceRecord import org.apache.kafka.connect.source.SourceTask @@ -44,7 +44,7 @@ import scala.util.Try * stream-reactor */ -class CassandraSourceTask extends SourceTask with StrictLogging { +class CassandraSourceTask extends SourceTask with StrictLogging with JarManifestProvided { private val queues = mutable.Map.empty[String, LinkedBlockingQueue[SourceRecord]] private val readers = mutable.Map.empty[String, CassandraTableReader] private val stopControl = new Object() @@ -56,7 +56,6 @@ class CassandraSourceTask extends SourceTask with StrictLogging { private var tracker: Long = 0 private var pollInterval: Long = CassandraConfigConstants.DEFAULT_POLL_INTERVAL private var name: String = "" - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * Starts the Cassandra source, parsing the options and setting up the reader. @@ -200,13 +199,6 @@ class CassandraSourceTask extends SourceTask with StrictLogging { } } - /** - * Gets the version of this Source. - * - * @return - */ - override def version: String = manifest.version() - /** * Check the queue size of a table. * diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala index 68dbcf8a6..5bd5e9209 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala @@ -19,8 +19,8 @@ import cats.implicits.toBifunctorOps import cats.implicits.toShow import io.lenses.streamreactor.common.errors.ErrorHandler import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.util.JarManifest import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskIdCreator import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig @@ -60,7 +60,7 @@ abstract class CloudSinkTask[MD <: FileMetadata, C <: CloudSinkConfig, CT]( private var writerManager: WriterManager[MD] = _ implicit var connectorTaskId: ConnectorTaskId = _ - override def version(): String = manifest.version() + override def version(): String = manifest.getVersion() override def start(fallbackProps: util.Map[String, String]): Unit = { diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/CloudSourceTask.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/CloudSourceTask.scala index 51083e131..a8112c1d3 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/CloudSourceTask.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/CloudSourceTask.scala @@ -22,8 +22,8 @@ import cats.effect.Ref import cats.implicits.catsSyntaxOptionId import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.common.config.base.traits.WithConnectorPrefix -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSourceConfig import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskIdCreator @@ -49,15 +49,14 @@ import scala.jdk.CollectionConverters._ abstract class CloudSourceTask[MD <: FileMetadata, C <: CloudSourceConfig[MD], CT] extends SourceTask with LazyLogging - with WithConnectorPrefix { + with WithConnectorPrefix + with JarManifestProvided { def validator: CloudLocationValidator private val contextOffsetFn: CloudLocation => Option[CloudLocation] = SourceContextReader.getCurrentOffset(() => context) - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) - @volatile private var s3SourceTaskState: Option[CloudSourceTaskState] = None @@ -68,8 +67,6 @@ abstract class CloudSourceTask[MD <: FileMetadata, C <: CloudSourceConfig[MD], C implicit var connectorTaskId: ConnectorTaskId = _ - override def version(): String = manifest.version() - /** * Start sets up readers for every configured connection in the properties */ diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/AsciiArtPrinter.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/AsciiArtPrinter.scala deleted file mode 100644 index be7929c2d..000000000 --- a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/AsciiArtPrinter.scala +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.common.utils - -import com.typesafe.scalalogging.LazyLogging - -import java.nio.charset.CodingErrorAction -import scala.io.Codec -import scala.io.Source -import scala.util.Failure -import scala.util.Success -import scala.util.Try - -object AsciiArtPrinter extends LazyLogging { - - def printAsciiHeader(manifest: JarManifest, asciiArtResource: String): Unit = Try { - implicit val codec: Codec = Codec("UTF-8") - codec.onMalformedInput(CodingErrorAction.REPLACE) - codec.onUnmappableCharacter(CodingErrorAction.REPLACE) - logger.info( - Source.fromInputStream( - getClass.getResourceAsStream(asciiArtResource), - ).mkString + s" ${manifest.version()}", - ) - logger.info(manifest.printManifest()) - } match { - case Failure(exception) => logger.error("Unable to print ASCII Art on startup", exception) - case Success(_) => () - } -} diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifest.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifest.scala deleted file mode 100644 index a23dd8352..000000000 --- a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifest.scala +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.common.utils - -import java.io.File -import java.net.URL -import java.util.jar.JarFile - -import scala.collection.mutable - -case class JarManifest(location: URL) { - - val map = mutable.Map.empty[String, String] - - var msg = "unknown" - try { - val file = new File(location.toURI) - if (file.isFile) { - val jarFile = new JarFile(file) - val manifest = jarFile.getManifest - val attributes = manifest.getMainAttributes - map += "StreamReactor-Version" -> attributes.getValue("StreamReactor-Version") - map += "Kafka-Version" -> attributes.getValue("Kafka-Version") - map += "Git-Repo" -> attributes.getValue("Git-Repo") - map += "Git-Commit-Hash" -> attributes.getValue("Git-Commit-Hash") - map += "Git-Tag" -> attributes.getValue("Git-Tag") - map += "StreamReactor-Docs" -> attributes.getValue("StreamReactor-Docs") - } - } catch { - case t: Throwable => msg = t.getMessage - } - - def version(): String = map.getOrElse("StreamReactor-Version", "") - - def printManifest(): String = { - val msg = "unknown" - - s""" - |StreamReactor-Version: ${map.getOrElse("StreamReactor-Version", msg)} - |Kafka-Version: ${map.getOrElse("Kafka-Version", msg)} - |Git-Repo: ${map.getOrElse("Git-Repo", msg)} - |Git-Commit-Hash: ${map.getOrElse("Git-Commit-Hash", msg)} - |Git-Tag: ${map.getOrElse("Git-Tag", msg)} - |StreamReactor-Docs: ${map.getOrElse("StreamReactor-Docs", msg)} - """.stripMargin - } -} diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifestProvided.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifestProvided.scala new file mode 100644 index 000000000..2fb34846f --- /dev/null +++ b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/utils/JarManifestProvided.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.utils + +import io.lenses.streamreactor.common.util.JarManifest + +trait JarManifestProvided { + + lazy val manifest: JarManifest = new JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) + + def version(): String = manifest.getVersion() + +} diff --git a/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkConnector.scala b/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkConnector.scala index 71d3a8b08..e77bc72fb 100644 --- a/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkConnector.scala +++ b/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkConnector.scala @@ -15,24 +15,22 @@ */ package io.lenses.streamreactor.connect.elastic6 +import com.typesafe.scalalogging.StrictLogging import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest - -import java.util +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.elastic6.config.ElasticConfig import io.lenses.streamreactor.connect.elastic6.config.ElasticConfigConstants -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.sink.SinkConnector +import java.util import scala.jdk.CollectionConverters.MapHasAsScala import scala.jdk.CollectionConverters.SeqHasAsJava -class ElasticSinkConnector extends SinkConnector with StrictLogging { +class ElasticSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private var configProps: Option[util.Map[String, String]] = None private val configDef = ElasticConfig.config - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SinkTask class to use @@ -62,6 +60,5 @@ class ElasticSinkConnector extends SinkConnector with StrictLogging { } override def stop(): Unit = {} - override def version(): String = manifest.version() - override def config(): ConfigDef = configDef + override def config(): ConfigDef = configDef } diff --git a/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkTask.scala b/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkTask.scala index c8609df0a..20c5be2dd 100644 --- a/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkTask.scala +++ b/kafka-connect-elastic6/src/main/scala/io/lenses/streamreactor/connect/elastic6/ElasticSinkTask.scala @@ -16,8 +16,8 @@ package io.lenses.streamreactor.connect.elastic6 import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.elastic6.config.ElasticConfig import io.lenses.streamreactor.connect.elastic6.config.ElasticConfigConstants @@ -32,11 +32,10 @@ import java.util import scala.jdk.CollectionConverters.IterableHasAsScala import scala.jdk.CollectionConverters.MapHasAsScala -class ElasticSinkTask extends SinkTask with StrictLogging { +class ElasticSinkTask extends SinkTask with StrictLogging with JarManifestProvided { private var writer: Option[ElasticJsonWriter] = None private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * Parse the configurations and setup the writer @@ -85,5 +84,4 @@ class ElasticSinkTask extends SinkTask with StrictLogging { override def flush(map: util.Map[TopicPartition, OffsetAndMetadata]): Unit = logger.info("Flushing Elastic Sink") - override def version: String = manifest.version() } diff --git a/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkConnector.scala b/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkConnector.scala index 6a5a0f64b..1f5bf25ec 100644 --- a/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkConnector.scala +++ b/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkConnector.scala @@ -15,24 +15,22 @@ */ package io.lenses.streamreactor.connect.elastic7 +import com.typesafe.scalalogging.StrictLogging import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest - -import java.util +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.elastic7.config.ElasticConfig import io.lenses.streamreactor.connect.elastic7.config.ElasticConfigConstants -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.sink.SinkConnector +import java.util import scala.jdk.CollectionConverters.MapHasAsScala import scala.jdk.CollectionConverters.SeqHasAsJava -class ElasticSinkConnector extends SinkConnector with StrictLogging { +class ElasticSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private var configProps: Option[util.Map[String, String]] = None private val configDef = ElasticConfig.config - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SinkTask class to use @@ -62,6 +60,5 @@ class ElasticSinkConnector extends SinkConnector with StrictLogging { } override def stop(): Unit = {} - override def version(): String = manifest.version() - override def config(): ConfigDef = configDef + override def config(): ConfigDef = configDef } diff --git a/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkTask.scala b/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkTask.scala index d29d6201d..500067d0f 100644 --- a/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkTask.scala +++ b/kafka-connect-elastic7/src/main/scala/io/lenses/streamreactor/connect/elastic7/ElasticSinkTask.scala @@ -16,8 +16,8 @@ package io.lenses.streamreactor.connect.elastic7 import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.elastic7.config.ElasticConfig import io.lenses.streamreactor.connect.elastic7.config.ElasticConfigConstants @@ -32,11 +32,10 @@ import java.util import scala.jdk.CollectionConverters.IterableHasAsScala import scala.jdk.CollectionConverters.MapHasAsScala -class ElasticSinkTask extends SinkTask with StrictLogging { +class ElasticSinkTask extends SinkTask with StrictLogging with JarManifestProvided { private var writer: Option[ElasticJsonWriter] = None private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * Parse the configurations and setup the writer @@ -84,6 +83,4 @@ class ElasticSinkTask extends SinkTask with StrictLogging { override def flush(map: util.Map[TopicPartition, OffsetAndMetadata]): Unit = logger.info("Flushing Elastic Sink") - - override def version: String = manifest.version() } diff --git a/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceConnector.scala b/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceConnector.scala index 5924f9716..0120f5528 100644 --- a/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceConnector.scala +++ b/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceConnector.scala @@ -15,9 +15,9 @@ */ package io.lenses.streamreactor.connect.ftp.source -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.errors.ConnectException import org.apache.kafka.connect.source.SourceConnector @@ -27,9 +27,8 @@ import scala.jdk.CollectionConverters.SeqHasAsJava import scala.util.Failure import scala.util.Try -class FtpSourceConnector extends SourceConnector with StrictLogging { +class FtpSourceConnector extends SourceConnector with StrictLogging with JarManifestProvided { private var configProps: Option[util.Map[String, String]] = None - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) override def taskClass(): Class[_ <: Task] = classOf[FtpSourceTask] @@ -57,7 +56,5 @@ class FtpSourceConnector extends SourceConnector with StrictLogging { } } - override def version(): String = manifest.version() - override def config() = FtpSourceConfig.definition } diff --git a/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceTask.scala b/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceTask.scala index ed5853137..adc8dc9ba 100644 --- a/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceTask.scala +++ b/kafka-connect-ftp/src/main/scala/io/lenses/streamreactor/connect/ftp/source/FtpSourceTask.scala @@ -15,9 +15,9 @@ */ package io.lenses.streamreactor.connect.ftp.source -import io.lenses.streamreactor.common.utils.JarManifest import io.lenses.streamreactor.connect.ftp.source.OpTimer.profile import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import org.apache.kafka.connect.errors.ConnectException import org.apache.kafka.connect.source.SourceRecord import org.apache.kafka.connect.source.SourceTask @@ -104,9 +104,8 @@ class FtpSourcePoller(cfg: FtpSourceConfig, offsetStorage: OffsetStorageReader) ) } -class FtpSourceTask extends SourceTask with StrictLogging { +class FtpSourceTask extends SourceTask with StrictLogging with JarManifestProvided { var poller: Option[FtpSourcePoller] = None - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) override def stop(): Unit = { logger.info("stop") @@ -115,7 +114,7 @@ class FtpSourceTask extends SourceTask with StrictLogging { override def start(props: util.Map[String, String]): Unit = { logger.info("start") - logger.info(manifest.printManifest()) + logger.info(manifest.buildManifestString()) val conf = if (context.configs().isEmpty) props else context.configs() @@ -127,8 +126,6 @@ class FtpSourceTask extends SourceTask with StrictLogging { poller = Some(new FtpSourcePoller(sourceConfig, context.offsetStorageReader)) } - override def version(): String = manifest.version() - override def poll(): util.List[SourceRecord] = poller match { case Some(poller) => poller.poll().asJava case None => throw new ConnectException("FtpSourceTask is not initialized but it is polled") diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkConnector.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkConnector.scala index 0d15a6861..7add90872 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkConnector.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkConnector.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.gcp.storage.sink -import io.lenses.streamreactor.common.utils.JarManifest import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.cloud.common.config.TaskDistributor import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings import io.lenses.streamreactor.connect.gcp.storage.sink.config.GCPStorageSinkConfigDef @@ -26,13 +26,10 @@ import org.apache.kafka.connect.sink.SinkConnector import java.util -class GCPStorageSinkConnector extends SinkConnector with LazyLogging { +class GCPStorageSinkConnector extends SinkConnector with LazyLogging with JarManifestProvided { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val props: util.Map[String, String] = new util.HashMap[String, String]() - override def version(): String = manifest.version() - override def taskClass(): Class[_ <: Task] = classOf[GCPStorageSinkTask] override def config(): ConfigDef = GCPStorageSinkConfigDef.config diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala index 40c72cf47..d22c43349 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala @@ -16,7 +16,7 @@ package io.lenses.streamreactor.connect.gcp.storage.sink import com.google.cloud.storage.Storage -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.JarManifest import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.sink.CloudSinkTask import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface @@ -32,7 +32,7 @@ class GCPStorageSinkTask extends CloudSinkTask[GCPStorageFileMetadata, GCPStorageSinkConfig, Storage]( GCPConfigSettings.CONNECTOR_PREFIX, "/gcpstorage-sink-ascii.txt", - JarManifest(GCPStorageSinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), + new JarManifest(GCPStorageSinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), ) { override def createClient(config: GCPStorageSinkConfig): Either[Throwable, Storage] = diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/GCPStorageSourceConnector.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/GCPStorageSourceConnector.scala index e78d64def..f67383411 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/GCPStorageSourceConnector.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/GCPStorageSourceConnector.scala @@ -16,7 +16,7 @@ package io.lenses.streamreactor.connect.gcp.storage.source import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.cloud.common.config.TaskDistributor import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.CONNECTOR_PREFIX import io.lenses.streamreactor.connect.gcp.storage.source.config.GCPStorageSourceConfigDef @@ -27,13 +27,10 @@ import org.apache.kafka.connect.source.SourceConnector import java.util -class GCPStorageSourceConnector extends SourceConnector with LazyLogging { +class GCPStorageSourceConnector extends SourceConnector with LazyLogging with JarManifestProvided { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val props: util.Map[String, String] = new util.HashMap[String, String]() - override def version(): String = manifest.version() - override def taskClass(): Class[_ <: Task] = classOf[GCPStorageSourceTask] override def config(): ConfigDef = GCPStorageSourceConfigDef.config diff --git a/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkConnector.scala b/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkConnector.scala index 5b1dddbf8..9f47795b5 100644 --- a/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkConnector.scala +++ b/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkConnector.scala @@ -17,7 +17,7 @@ package io.lenses.streamreactor.connect.http.sink import cats.implicits.catsSyntaxOptionId import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.http.sink.config.HttpSinkConfigDef import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task @@ -28,14 +28,12 @@ import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala import scala.jdk.CollectionConverters.SeqHasAsJava -class HttpSinkConnector extends SinkConnector with LazyLogging { +class HttpSinkConnector extends SinkConnector with LazyLogging with JarManifestProvided { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private var props: Option[Map[String, String]] = Option.empty private var maybeSinkName: Option[String] = Option.empty private def sinkName = maybeSinkName.getOrElse("Lenses.io HTTP Sink") - override def version(): String = manifest.version() override def taskClass(): Class[_ <: Task] = classOf[HttpSinkTask] diff --git a/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkTask.scala b/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkTask.scala index 83d9c8ca0..c7622c5a0 100644 --- a/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkTask.scala +++ b/kafka-connect-http/src/main/scala/io/lenses/streamreactor/connect/http/sink/HttpSinkTask.scala @@ -20,8 +20,7 @@ import cats.effect.IO import cats.effect.Ref import cats.effect.unsafe.IORuntime import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader import io.lenses.streamreactor.connect.cloud.common.model.Offset import io.lenses.streamreactor.connect.cloud.common.model.Topic import io.lenses.streamreactor.connect.cloud.common.model.TopicPartition @@ -36,13 +35,14 @@ import org.apache.kafka.connect.errors.ConnectException import org.apache.kafka.connect.sink.SinkRecord import org.apache.kafka.connect.sink.SinkTask import cats.syntax.all._ +import io.lenses.streamreactor.common.utils.JarManifestProvided + import java.util import scala.jdk.CollectionConverters.IterableHasAsScala import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala -class HttpSinkTask extends SinkTask with LazyLogging { - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) +class HttpSinkTask extends SinkTask with LazyLogging with JarManifestProvided { implicit val runtime: IORuntime = IORuntime.global private var maybeTemplate: Option[TemplateType] = Option.empty private var maybeWriterManager: Option[HttpWriterManager] = Option.empty @@ -189,6 +189,4 @@ class HttpSinkTask extends SinkTask with LazyLogging { _ <- maybeWriterManager.traverse(_.close) _ <- deferred.complete(().asRight) } yield ()).unsafeRunSync() - - override def version(): String = manifest.version() } diff --git a/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkConnector.scala b/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkConnector.scala index 8a1624395..8352d6767 100644 --- a/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkConnector.scala +++ b/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkConnector.scala @@ -15,17 +15,16 @@ */ package io.lenses.streamreactor.connect.influx +import com.typesafe.scalalogging.StrictLogging import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest - -import java.util +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.influx.config.InfluxConfig import io.lenses.streamreactor.connect.influx.config.InfluxConfigConstants -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.sink.SinkConnector +import java.util import scala.jdk.CollectionConverters.MapHasAsScala import scala.jdk.CollectionConverters.SeqHasAsJava @@ -35,10 +34,9 @@ import scala.jdk.CollectionConverters.SeqHasAsJava * * Sets up InfluxSinkTask and configurations for the tasks. */ -class InfluxSinkConnector extends SinkConnector with StrictLogging { +class InfluxSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private var configProps: Option[util.Map[String, String]] = None private val configDef = InfluxConfig.config - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SinkTask class to use @@ -69,7 +67,5 @@ class InfluxSinkConnector extends SinkConnector with StrictLogging { override def stop(): Unit = {} - override def version(): String = manifest.version() - override def config(): ConfigDef = configDef } diff --git a/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkTask.scala b/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkTask.scala index 9fa8affa6..8a17b63bc 100644 --- a/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkTask.scala +++ b/kafka-connect-influxdb/src/main/scala/io/lenses/streamreactor/connect/influx/InfluxSinkTask.scala @@ -15,16 +15,16 @@ */ package io.lenses.streamreactor.connect.influx +import com.typesafe.scalalogging.StrictLogging import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.influx.config.InfluxConfig import io.lenses.streamreactor.connect.influx.config.InfluxConfigConstants import io.lenses.streamreactor.connect.influx.config.InfluxSettings import io.lenses.streamreactor.connect.influx.writers.InfluxDbWriter import io.lenses.streamreactor.connect.influx.writers.WriterFactoryFn -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.clients.consumer.OffsetAndMetadata import org.apache.kafka.common.TopicPartition import org.apache.kafka.connect.sink.SinkRecord @@ -39,12 +39,11 @@ import scala.jdk.CollectionConverters.MapHasAsScala * * Kafka Connect InfluxDb sink task. Called by framework to put records to the target database */ -class InfluxSinkTask extends SinkTask with StrictLogging { +class InfluxSinkTask extends SinkTask with StrictLogging with JarManifestProvided { var writer: Option[InfluxDbWriter] = None private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * Parse the configurations and setup the writer @@ -93,7 +92,5 @@ class InfluxSinkTask extends SinkTask with StrictLogging { progressCounter.empty() } - override def version: String = manifest.version() - override def flush(offsets: util.Map[TopicPartition, OffsetAndMetadata]): Unit = {} } diff --git a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkConnector.scala b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkConnector.scala index bc95e275a..f2d719551 100644 --- a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkConnector.scala +++ b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkConnector.scala @@ -15,11 +15,11 @@ */ package io.lenses.streamreactor.connect.jms.sink +import com.typesafe.scalalogging.StrictLogging import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.jms.config.JMSConfig import io.lenses.streamreactor.connect.jms.config.JMSConfigConstants -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.sink.SinkConnector @@ -34,10 +34,9 @@ import scala.jdk.CollectionConverters.SeqHasAsJava * * Sets up JmsSinkTask and configurations for the tasks. */ -class JMSSinkConnector extends SinkConnector with StrictLogging { +class JMSSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private var configProps: Option[util.Map[String, String]] = None private val configDef = JMSConfig.config - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SinkTask class to use @@ -67,8 +66,5 @@ class JMSSinkConnector extends SinkConnector with StrictLogging { } override def stop(): Unit = {} - - override def version(): String = manifest.version() - override def config(): ConfigDef = configDef } diff --git a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkTask.scala b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkTask.scala index d1c26f6f0..5b0ca3ebf 100644 --- a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkTask.scala +++ b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/sink/JMSSinkTask.scala @@ -16,8 +16,8 @@ package io.lenses.streamreactor.connect.jms.sink import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.jms.config.JMSConfig import io.lenses.streamreactor.connect.jms.config.JMSConfigConstants @@ -38,12 +38,11 @@ import scala.jdk.CollectionConverters.MapHasAsScala * * Kafka Connect JMS sink task. Called by framework to put records to the target sink */ -class JMSSinkTask extends SinkTask with StrictLogging { +class JMSSinkTask extends SinkTask with StrictLogging with JarManifestProvided { var writer: Option[JMSWriter] = None val progressCounter = new ProgressCounter private var enableProgress: Boolean = false - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * Parse the configurations and setup the writer @@ -92,5 +91,4 @@ class JMSSinkTask extends SinkTask with StrictLogging { //TODO //have the writer expose a is busy; can expose an await using a countdownlatch internally } - override def version: String = manifest.version() } diff --git a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceConnector.scala b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceConnector.scala index c27fd90eb..0c2d1f507 100644 --- a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceConnector.scala +++ b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceConnector.scala @@ -15,10 +15,10 @@ */ package io.lenses.streamreactor.connect.jms.source -import io.lenses.streamreactor.common.utils.JarManifest +import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.jms.config.JMSConfig import io.lenses.streamreactor.connect.jms.config.JMSConfigConstants -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.source.SourceConnector @@ -34,10 +34,9 @@ import scala.jdk.CollectionConverters.SeqHasAsJava * Created by andrew@datamountaineer.com on 10/03/2017. * stream-reactor */ -class JMSSourceConnector extends SourceConnector with StrictLogging { +class JMSSourceConnector extends SourceConnector with StrictLogging with JarManifestProvided { private var configProps: Map[String, String] = _ private val configDef = JMSConfig.config - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) override def taskClass(): Class[_ <: Task] = classOf[JMSSourceTask] @@ -59,8 +58,6 @@ class JMSSourceConnector extends SourceConnector with StrictLogging { override def stop(): Unit = {} - override def version(): String = manifest.version() - private def kcqlTaskScaling(maxTasks: Int): util.List[util.Map[String, String]] = { val raw = getRawKcqlString diff --git a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceTask.scala b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceTask.scala index 424d7f475..4d52abb36 100644 --- a/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceTask.scala +++ b/kafka-connect-jms/src/main/scala/io/lenses/streamreactor/connect/jms/source/JMSSourceTask.scala @@ -15,14 +15,15 @@ */ package io.lenses.streamreactor.connect.jms.source -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.jms.config.JMSConfig import io.lenses.streamreactor.connect.jms.config.JMSConfigConstants import io.lenses.streamreactor.connect.jms.config.JMSSettings import io.lenses.streamreactor.connect.jms.source.readers.JMSReader -import com.typesafe.scalalogging.StrictLogging +import jakarta.jms.Message import org.apache.kafka.connect.source.SourceRecord import org.apache.kafka.connect.source.SourceTask @@ -31,7 +32,6 @@ import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong import java.util.function.BiConsumer -import jakarta.jms.Message import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration._ import scala.jdk.CollectionConverters.MapHasAsScala @@ -44,13 +44,12 @@ import scala.util.Try * Created by andrew@datamountaineer.com on 10/03/2017. * stream-reactor */ -class JMSSourceTask extends SourceTask with StrictLogging { +class JMSSourceTask extends SourceTask with StrictLogging with JarManifestProvided { private var reader: JMSReader = _ private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false private val pollingTimeout: AtomicLong = new AtomicLong(0L) private val recordsToCommit = new ConcurrentHashMap[SourceRecord, MessageAndTimestamp]() - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val EmptyRecords = Collections.emptyList[SourceRecord]() private var lastEvictedTimestamp: FiniteDuration = FiniteDuration(System.currentTimeMillis(), MILLISECONDS) private var evictInterval: Int = 0 @@ -132,8 +131,6 @@ class JMSSourceTask extends SourceTask with StrictLogging { } evictUncommittedMessages() } - - override def version: String = manifest.version() } case class MessageAndTimestamp(msg: Message, timestamp: FiniteDuration) diff --git a/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkConnector.scala b/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkConnector.scala index 66772168b..499e03db0 100644 --- a/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkConnector.scala +++ b/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkConnector.scala @@ -16,7 +16,7 @@ package io.lenses.streamreactor.connect.mongodb.sink import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import java.util import io.lenses.streamreactor.connect.mongodb.config.MongoConfig @@ -39,9 +39,8 @@ import scala.util.Try * * Sets up MongoSinkTask and configurations for the tasks. */ -class MongoSinkConnector extends SinkConnector with StrictLogging { +class MongoSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private var configProps: util.Map[String, String] = _ - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SinkTask class to use @@ -77,8 +76,6 @@ class MongoSinkConnector extends SinkConnector with StrictLogging { override def stop(): Unit = {} - override def version(): String = manifest.version() - override def config(): ConfigDef = MongoConfig.config override def validate(connectorConfigs: util.Map[String, String]): Config = super.validate(connectorConfigs) diff --git a/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkTask.scala b/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkTask.scala index bf525b0eb..b0cf8bcd4 100644 --- a/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkTask.scala +++ b/kafka-connect-mongodb/src/main/scala/io/lenses/streamreactor/connect/mongodb/sink/MongoSinkTask.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.mongodb.sink -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.mongodb.config.MongoConfig import io.lenses.streamreactor.connect.mongodb.config.MongoConfigConstants @@ -40,9 +40,8 @@ import scala.util.Try * Kafka Connect Mongo DB sink task. Called by * framework to put records to the target sink */ -class MongoSinkTask extends SinkTask with StrictLogging { +class MongoSinkTask extends SinkTask with StrictLogging with JarManifestProvided { private var writer: Option[MongoWriter] = None - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false @@ -87,6 +86,4 @@ class MongoSinkTask extends SinkTask with StrictLogging { } override def flush(map: util.Map[TopicPartition, OffsetAndMetadata]): Unit = {} - - override def version: String = manifest.version() } diff --git a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkConnector.scala b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkConnector.scala index 37753b44a..93e5d9f2a 100644 --- a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkConnector.scala +++ b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkConnector.scala @@ -16,10 +16,10 @@ package io.lenses.streamreactor.connect.mqtt.sink import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest import io.lenses.streamreactor.connect.mqtt.config.MqttConfigConstants import io.lenses.streamreactor.connect.mqtt.config.MqttSinkConfig import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.sink.SinkConnector @@ -33,10 +33,9 @@ import scala.jdk.CollectionConverters.SeqHasAsJava * Created by andrew@datamountaineer.com on 28/08/2017. * stream-reactor */ -class MqttSinkConnector extends SinkConnector with StrictLogging { +class MqttSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private val configDef = MqttSinkConfig.config private var configProps: Option[util.Map[String, String]] = None - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) override def start(props: util.Map[String, String]): Unit = { logger.info(s"Starting Mqtt sink connector.") @@ -46,8 +45,6 @@ class MqttSinkConnector extends SinkConnector with StrictLogging { override def taskClass(): Class[_ <: Task] = classOf[MqttSinkTask] - override def version(): String = manifest.version() - override def stop(): Unit = {} override def taskConfigs(maxTasks: Int): util.List[util.Map[String, String]] = { diff --git a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkTask.scala b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkTask.scala index 2389e7e95..78c4c9f2c 100644 --- a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkTask.scala +++ b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/sink/MqttSinkTask.scala @@ -17,8 +17,8 @@ package io.lenses.streamreactor.connect.mqtt.sink import io.lenses.streamreactor.common.converters.sink.Converter import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.mqtt.config.MqttConfigConstants import io.lenses.streamreactor.connect.mqtt.config.MqttSinkConfig @@ -41,11 +41,10 @@ import scala.util.Try * Created by andrew@datamountaineer.com on 27/08/2017. * stream-reactor */ -class MqttSinkTask extends SinkTask with StrictLogging { +class MqttSinkTask extends SinkTask with StrictLogging with JarManifestProvided { private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false private var writer: Option[MqttWriter] = None - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) override def start(props: util.Map[String, String]): Unit = { printAsciiHeader(manifest, "/mqtt-sink-ascii.txt") @@ -109,6 +108,4 @@ class MqttSinkTask extends SinkTask with StrictLogging { require(writer.nonEmpty, "Writer is not set!") writer.foreach(w => w.flush()) } - - override def version: String = manifest.version() } diff --git a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceConnector.scala b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceConnector.scala index bd6968139..22d5f742f 100644 --- a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceConnector.scala +++ b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceConnector.scala @@ -15,13 +15,12 @@ */ package io.lenses.streamreactor.connect.mqtt.source -import io.lenses.streamreactor.common.utils.JarManifest - import java.util import java.util.Collections import io.lenses.streamreactor.connect.mqtt.config.MqttSourceConfig import io.lenses.streamreactor.connect.mqtt.config.MqttSourceSettings import com.typesafe.scalalogging.StrictLogging +import io.lenses.streamreactor.common.utils.JarManifestProvided import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.source.SourceConnector @@ -29,10 +28,9 @@ import org.apache.kafka.connect.source.SourceConnector import scala.jdk.CollectionConverters.MapHasAsScala import scala.jdk.CollectionConverters.SeqHasAsJava -class MqttSourceConnector extends SourceConnector with StrictLogging { +class MqttSourceConnector extends SourceConnector with StrictLogging with JarManifestProvided { private val configDef = MqttSourceConfig.config private var configProps: util.Map[String, String] = _ - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SourceTask class to use @@ -95,6 +93,4 @@ class MqttSourceConnector extends SourceConnector with StrictLogging { override def stop(): Unit = {} override def config(): ConfigDef = configDef - - override def version(): String = manifest.version() } diff --git a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceTask.scala b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceTask.scala index 37fdb33ce..a9d86ea7e 100644 --- a/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceTask.scala +++ b/kafka-connect-mqtt/src/main/scala/io/lenses/streamreactor/connect/mqtt/source/MqttSourceTask.scala @@ -15,14 +15,14 @@ */ package io.lenses.streamreactor.connect.mqtt.source -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.converters.source.Converter import io.lenses.streamreactor.connect.mqtt.config.MqttConfigConstants import io.lenses.streamreactor.connect.mqtt.config.MqttSourceConfig import io.lenses.streamreactor.connect.mqtt.config.MqttSourceSettings import io.lenses.streamreactor.connect.mqtt.connection.MqttClientConnectionFn -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigException import org.apache.kafka.connect.source.SourceRecord @@ -36,11 +36,10 @@ import scala.util.Failure import scala.util.Success import scala.util.Try -class MqttSourceTask extends SourceTask with StrictLogging { +class MqttSourceTask extends SourceTask with StrictLogging with JarManifestProvided { private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false private var mqttManager: Option[MqttManager] = None - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) override def start(props: util.Map[String, String]): Unit = { printAsciiHeader(manifest, "/mqtt-source-ascii.txt") @@ -111,6 +110,4 @@ class MqttSourceTask extends SourceTask with StrictLogging { mqttManager.foreach(_.close()) progressCounter.empty() } - - override def version: String = manifest.version() } diff --git a/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkConnector.scala b/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkConnector.scala index a36c88db8..effe0c2ef 100644 --- a/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkConnector.scala +++ b/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkConnector.scala @@ -15,11 +15,11 @@ */ package io.lenses.streamreactor.connect.redis.sink +import com.typesafe.scalalogging.StrictLogging import io.lenses.streamreactor.common.config.Helpers -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.connect.redis.sink.config.RedisConfig import io.lenses.streamreactor.connect.redis.sink.config.RedisConfigConstants -import com.typesafe.scalalogging.StrictLogging import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.connect.connector.Task import org.apache.kafka.connect.sink.SinkConnector @@ -34,10 +34,9 @@ import scala.jdk.CollectionConverters.SeqHasAsJava * * Sets up RedisSinkTask and configurations for the tasks. */ -class RedisSinkConnector extends SinkConnector with StrictLogging { +class RedisSinkConnector extends SinkConnector with StrictLogging with JarManifestProvided { private var configProps: util.Map[String, String] = _ private val configDef = RedisConfig.config - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * States which SinkTask class to use @@ -68,7 +67,5 @@ class RedisSinkConnector extends SinkConnector with StrictLogging { override def stop(): Unit = {} - override def version(): String = manifest.version() - override def config(): ConfigDef = configDef } diff --git a/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkTask.scala b/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkTask.scala index d9376ff0f..16038e3c7 100644 --- a/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkTask.scala +++ b/kafka-connect-redis/src/main/scala/io/lenses/streamreactor/connect/redis/sink/RedisSinkTask.scala @@ -16,8 +16,8 @@ package io.lenses.streamreactor.connect.redis.sink import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.utils.AsciiArtPrinter.printAsciiHeader -import io.lenses.streamreactor.common.utils.JarManifest +import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader +import io.lenses.streamreactor.common.utils.JarManifestProvided import io.lenses.streamreactor.common.utils.ProgressCounter import io.lenses.streamreactor.connect.redis.sink.config.RedisConfig import io.lenses.streamreactor.connect.redis.sink.config.RedisConfigConstants @@ -41,11 +41,10 @@ import scala.jdk.CollectionConverters.MapHasAsScala * Kafka Connect Redis sink task. Called by framework to put records to the * target sink */ -class RedisSinkTask extends SinkTask with StrictLogging { +class RedisSinkTask extends SinkTask with StrictLogging with JarManifestProvided { var writer: List[DbWriter] = List[DbWriter]() private val progressCounter = new ProgressCounter private var enableProgress: Boolean = false - private val manifest = JarManifest(getClass.getProtectionDomain.getCodeSource.getLocation) /** * Parse the configurations and setup the writer @@ -242,6 +241,4 @@ class RedisSinkTask extends SinkTask with StrictLogging { //TODO //have the writer expose a is busy; can expose an await using a countdownlatch internally } - - override def version: String = manifest.version() } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index edfeac18a..6bd759281 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -40,6 +40,9 @@ object Dependencies { val scalatestPlusScalaCheckVersion = "3.1.0.0-RC2" val scalaCheckVersion = "1.18.0" + val junitJupiterVersion = "5.10.2" + val assertjCoreVersion = "3.25.3" + val kafkaVersion: String = "3.7.0" val confluentVersion: String = "7.6.1" @@ -61,6 +64,8 @@ object Dependencies { // build plugins version val betterMonadicForVersion = "0.3.1" + val lombokVersion = "1.18.32" + val logbackVersion = "1.5.6" val scalaLoggingVersion = "3.9.5" @@ -171,8 +176,11 @@ object Dependencies { val scalatest = "org.scalatest" %% "scalatest" % scalatestVersion val scalatestPlusScalaCheck = "org.scalatestplus" %% "scalatestplus-scalacheck" % scalatestPlusScalaCheckVersion - val scalaCheck = "org.scalacheck" %% "scalacheck" % scalaCheckVersion - val `mockito-scala` = "org.mockito" %% "mockito-scala" % mockitoScalaVersion + val scalaCheck = "org.scalacheck" %% "scalacheck" % scalaCheckVersion + val `mockitoScala` = "org.mockito" %% "mockito-scala" % mockitoScalaVersion + + val `junitJupiter` = "org.junit.jupiter" % "junit-jupiter-api" % junitJupiterVersion + val `assertjCore` = "org.assertj" % "assertj-core" % assertjCoreVersion val catsEffectScalatest = "org.typelevel" %% "cats-effect-testing-scalatest" % `cats-effect-testing` @@ -258,6 +266,8 @@ object Dependencies { lazy val calciteLinq4J = "org.apache.calcite" % "calcite-linq4j" % calciteVersion + lazy val lombok = "org.projectlombok" % "lombok" % lombokVersion + lazy val s3Sdk = "software.amazon.awssdk" % "s3" % awsSdkVersion lazy val stsSdk = "software.amazon.awssdk" % "sts" % awsSdkVersion @@ -401,7 +411,7 @@ trait Dependencies { catsEffectScalatest, scalatestPlusScalaCheck, scalaCheck, - `mockito-scala`, + `mockitoScala`, `wiremock`, jerseyCommon, avro4s, @@ -437,6 +447,9 @@ trait Dependencies { confluentJsonSchemaSerializer, ) ++ enumeratum ++ circe + val javaCommonDeps: Seq[ModuleID] = Seq(lombok, kafkaConnectJson, kafkaClients) + val javaCommonTestDeps: Seq[ModuleID] = Seq(junitJupiter, assertjCore, `mockitoScala`, logback) + //Specific modules dependencies val kafkaConnectCloudCommonDeps: Seq[ModuleID] = Seq( From cb660f3fc6946ebec60ce9b4f9d7c4715030fd49 Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Sun, 28 Apr 2024 19:36:09 +0100 Subject: [PATCH 07/30] Enhancements for Source Line-Start-End Functionality (LC-196) (#1184) * Enhancements for Source Line-Start-End Functionality (LC-196) This PR addresses a specific challenge encountered by users of the S3 source functionality. In cases where an external producer terminates a file without the end message mark. This ends up losing data. To mitigate this issue, the PR introduces a new property entry for KCQL to signal the unterminated message to be considered. * Fixes the compilation error and adds a test for initialising the StartEndLineReadTextMode --------- Co-authored-by: stheppi --- .../config/kcqlprops/PropsKeyEnum.scala | 2 + .../common/source/config/ReadTextMode.scala | 9 +++- .../kcqlprops/CloudSourcePropsSchema.scala | 19 +++---- .../ReadTextModeTestFormatSelection.scala | 17 +++++- .../io/text/LineStartLineEndReader.scala | 33 +++++++++--- .../io/text/LineStartLineEndReaderTest.scala | 53 +++++++++++++++++++ 6 files changed, 112 insertions(+), 21 deletions(-) diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala index e14a27506..25c0db682 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala @@ -37,6 +37,8 @@ object PropsKeyEnum extends Enum[PropsKeyEntry] { case object ReadStartLine extends PropsKeyEntry("read.text.start.line") case object ReadEndLine extends PropsKeyEntry("read.text.end.line") + case object ReadLastEndLineMissing extends PropsKeyEntry("read.text.last.end.line.missing") + case object ReadTrimLine extends PropsKeyEntry("read.text.trim") case object StoreEnvelope extends PropsKeyEntry(DataStorageSettings.StoreEnvelopeKey) diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextMode.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextMode.scala index eb6af562f..f51cf2720 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextMode.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextMode.scala @@ -56,7 +56,10 @@ object ReadTextMode { startLine <- props.getString(PropsKeyEnum.ReadStartLine) endLine <- props.getString(PropsKeyEnum.ReadEndLine) trim <- props.getOptionalBoolean(PropsKeyEnum.ReadTrimLine).toOption.flatten.orElse(Some(false)) - } yield StartEndLineReadTextMode(startLine, endLine, trim) + lastEndLineMissing <- props.getOptionalBoolean(PropsKeyEnum.ReadLastEndLineMissing).toOption.flatten.orElse( + Some(false), + ) + } yield StartEndLineReadTextMode(startLine, endLine, trim, lastEndLineMissing) case None => Option.empty } } @@ -76,7 +79,8 @@ case class StartEndTagReadTextMode(startTag: String, endTag: String, buffer: Int } } -case class StartEndLineReadTextMode(startLine: String, endLine: String, trim: Boolean) extends ReadTextMode { +case class StartEndLineReadTextMode(startLine: String, endLine: String, trim: Boolean, lastEndLineMissing: Boolean) + extends ReadTextMode { override def createStreamReader( input: InputStream, ): CloudDataIterator[String] = { @@ -85,6 +89,7 @@ case class StartEndLineReadTextMode(startLine: String, endLine: String, trim: Bo startLine, endLine, trim, + lastEndLineMissing, ) new CustomTextStreamReader(() => lineReader.next(), () => lineReader.close()) diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/kcqlprops/CloudSourcePropsSchema.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/kcqlprops/CloudSourcePropsSchema.scala index d2718404f..7db5dc772 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/kcqlprops/CloudSourcePropsSchema.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/kcqlprops/CloudSourcePropsSchema.scala @@ -26,15 +26,16 @@ import scala.jdk.CollectionConverters.MapHasAsScala object CloudSourcePropsSchema { private[source] val keys = Map[PropsKeyEntry, PropsSchema]( - ReadTextMode -> EnumPropsSchema(ReadTextModeEnum), - ReadRegex -> StringPropsSchema, - ReadStartTag -> StringPropsSchema, - ReadEndTag -> StringPropsSchema, - ReadStartLine -> StringPropsSchema, - ReadEndLine -> StringPropsSchema, - BufferSize -> IntPropsSchema, - ReadTrimLine -> BooleanPropsSchema, - StoreEnvelope -> BooleanPropsSchema, + ReadTextMode -> EnumPropsSchema(ReadTextModeEnum), + ReadRegex -> StringPropsSchema, + ReadStartTag -> StringPropsSchema, + ReadEndTag -> StringPropsSchema, + ReadStartLine -> StringPropsSchema, + ReadEndLine -> StringPropsSchema, + ReadLastEndLineMissing -> BooleanPropsSchema, + BufferSize -> IntPropsSchema, + ReadTrimLine -> BooleanPropsSchema, + StoreEnvelope -> BooleanPropsSchema, ) val schema = KcqlPropsSchema(PropsKeyEnum, keys) diff --git a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextModeTestFormatSelection.scala b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextModeTestFormatSelection.scala index c4afd8abc..77cabc8e6 100644 --- a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextModeTestFormatSelection.scala +++ b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/source/config/ReadTextModeTestFormatSelection.scala @@ -100,7 +100,20 @@ class ReadTextModeTestFormatSelection extends AnyFlatSpec with Matchers { PropsKeyEnum.ReadEndLine.entryName -> "", ), ), - ) should be(Some(StartEndLineReadTextMode("SSM", "", false))) + ) should be(Some(StartEndLineReadTextMode("SSM", "", false, false))) + } + + "ReadTextMode" should "set the end of line missing" in { + ReadTextMode( + readProps( + Map( + PropsKeyEnum.ReadTextMode.entryName -> ReadTextModeEnum.StartEndLine.entryName, + PropsKeyEnum.ReadStartLine.entryName -> "SSM", + PropsKeyEnum.ReadEndLine.entryName -> "", + PropsKeyEnum.ReadLastEndLineMissing.entryName -> "true", + ), + ), + ) should be(Some(StartEndLineReadTextMode("SSM", "", false, true))) } "ReadTextMode" should "return start and end line when configured with trim enabled" in { @@ -113,7 +126,7 @@ class ReadTextModeTestFormatSelection extends AnyFlatSpec with Matchers { PropsKeyEnum.ReadTrimLine.entryName -> "true", ), ), - ) should be(Some(StartEndLineReadTextMode("SSM", "", true))) + ) should be(Some(StartEndLineReadTextMode("SSM", "", true, false))) } "ReadTextMode" should "return none when no start or end line is configured" in { diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReader.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReader.scala index 1d7a5ae12..68cb608de 100644 --- a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReader.scala +++ b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReader.scala @@ -24,11 +24,19 @@ import java.io.InputStreamReader * end is found. The start and end lines are included in the record. * If the file ends and there is no end, the record is ignored * - * @param input - * @param start - * @param end + * @param input the input stream + * @param start the record is considered to start when a line matching start is found + * @param end the record is considered complete when a line matching end is found + * @param trim if true, the record is trimmed + * @param lastEndLineMissing if true, the record is considered complete when end of file is reached */ -class LineStartLineEndReader(input: InputStream, start: String, end: String, trim: Boolean = false) extends LineReader { +class LineStartLineEndReader( + input: InputStream, + start: String, + end: String, + trim: Boolean = false, + lastEndLineMissing: Boolean = false, +) extends LineReader { private val br = new BufferedReader(new InputStreamReader(input)) //Returns the next record or None if there are no more @@ -60,10 +68,19 @@ class LineStartLineEndReader(input: InputStream, start: String, end: String, tri builder.append(line) line = br.readLine() } - Option(line).map { _ => - builder.append(System.lineSeparator()) - builder.append(end) - builder.toString() + Option(line) match { + case Some(_) => + builder.append(System.lineSeparator()) + builder.append(end) + Some(builder.toString()) + case None => + if (lastEndLineMissing) { + builder.append(System.lineSeparator()) + builder.append(end) + Some(builder.toString()) + } else { + None + } } } } diff --git a/kafka-connect-common/src/test/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReaderTest.scala b/kafka-connect-common/src/test/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReaderTest.scala index 3d5893602..d78faf6a7 100644 --- a/kafka-connect-common/src/test/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReaderTest.scala +++ b/kafka-connect-common/src/test/scala/io/lenses/streamreactor/connect/io/text/LineStartLineEndReaderTest.scala @@ -234,5 +234,58 @@ class LineStartLineEndReaderTest extends AnyFunSuite with Matchers { |x""".stripMargin, ) } + + test("when lastEndLineMissing=true, return the record if the end line is missing") { + val reader = new LineStartLineEndReader(createInputStream( + """ + |start + |a + |b + |c + | + |start + |x""".stripMargin, + ), + "start", + "", + trim = true, + lastEndLineMissing = true, + ) + reader.next() shouldBe Some( + """start + |a + |b + |c""".stripMargin, + ) + reader.next() shouldBe Some( + """start + |x""".stripMargin, + ) + } + + test("when lastEndLineMissing=true, return the record if the end line is missing all file is a message") { + val reader = new LineStartLineEndReader(createInputStream( + """ + |start + |a + |b + |c + |start + |x""".stripMargin, + ), + "start", + "", + trim = true, + lastEndLineMissing = true, + ) + reader.next() shouldBe Some( + """start + |a + |b + |c + |start + |x""".stripMargin, + ) + } private def createInputStream(data: String): InputStream = new ByteArrayInputStream(data.getBytes) } From e8cff24cb7c069d0586dd7927e10af1c02f288fa Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Mon, 29 Apr 2024 00:07:55 +0200 Subject: [PATCH 08/30] Update s3, sts to 2.25.40 (#1186) Co-authored-by: Stefan Bocutiu --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 6bd759281..4fc18c7af 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -75,7 +75,7 @@ object Dependencies { val jerseyCommonVersion = "3.1.6" val calciteVersion = "1.34.0" - val awsSdkVersion = "2.25.38" + val awsSdkVersion = "2.25.40" val azureDataLakeVersion = "12.18.4" val azureIdentityVersion = "1.12.0" From ae57b61713fb772e8cb58e9e61b11fb5eb1f24d1 Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Mon, 29 Apr 2024 10:59:59 +0200 Subject: [PATCH 09/30] Update commons-codec to 1.17.0 (#1185) --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 4fc18c7af..d1414198c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -111,7 +111,7 @@ object Dependencies { val mqttVersion = "1.2.5" val commonsNetVersion = "3.10.0" - val commonsCodecVersion = "1.16.1" + val commonsCodecVersion = "1.17.0" val commonsCompressVersion = "1.26.1" val commonsConfigVersion = "2.10.1" val commonsIOVersion = "2.16.1" From 9b1722faed032ac22ab45ac279d54691ff777293 Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Tue, 30 Apr 2024 16:04:18 +0100 Subject: [PATCH 10/30] S3/GCS/Azure Source: Enhanced Data Reload Strategy for Specific Timeframes (#1187) * First commit * Adapted the S3 integration tests to account for the new object key name including the earliest record timestamp. Refactored a few parameter/field names around the object key name. * Object Key format version only applies when the envelope storage is used. It is the only storage which guarantees the record timestamp to be preserved. Sinks tests have been updated to reflect the object keys values. * Fixes endless loop in test in case of test failure. Avoids Avro invalid sync as a result of concurrent tests writing the same file * Expand the object key value to contain the min and max records timestamp within the file. This change would reduce the complexity of the initial seek when a request to load from a specific point in time is chosen * Removes obsolete comment. For temp files/folders call the deleteOnExit --------- Co-authored-by: stheppi --- .../aws/s3/sink/S3AvroWriterManagerTest.scala | 128 +++++++++++++--- .../aws/s3/sink/S3JsonWriterManagerTest.scala | 132 ++++++++++++---- .../s3/sink/S3ParquetWriterManagerTest.scala | 35 +++-- ...nkTaskAvroEnvelopeNullKeyOrValueTest.scala | 12 +- .../s3/sink/S3SinkTaskAvroEnvelopeTest.scala | 37 +++-- .../s3/sink/S3SinkTaskJsonEnvelopeTest.scala | 8 +- .../sink/S3SinkTaskParquetEnvelopeTest.scala | 18 ++- .../aws/s3/sink/WriterManagerTest.scala | 3 +- .../s3/source/S3SourceAvroEnvelopeTest.scala | 83 +++++------ ...urceAvroWithValueAsArrayEnvelopeTest.scala | 68 ++++----- ...WithValueAsOptionalArrayEnvelopeTest.scala | 141 ++++++++---------- .../s3/source/S3SourceJsonEnvelopeTest.scala | 65 ++++---- .../source/S3SourceParquetEnvelopeTest.scala | 83 +++++------ .../source/S3SourceTaskBucketRootTest.scala | 13 +- .../aws/s3/source/S3SourceTaskTest.scala | 21 ++- .../s3/source/S3SourceTaskXmlReaderTest.scala | 57 ++++--- .../aws/s3/source/SourceRecordsLoop.scala | 31 ++++ .../aws/s3/source/TempFileHelper.scala | 25 ++++ .../aws/s3/source/UploadException.scala | 9 ++ .../aws/s3/utils/S3ProxyContainerTest.scala | 2 +- .../cloud/common/utils/RemoteFileHelper.scala | 2 +- .../HierarchicalPartitionExtractorTest.scala | 11 +- .../aws/s3/model/PartitionExtractorTest.scala | 5 +- .../common/formats/writer/MessageDetail.scala | 4 +- .../common/sink/WriterManagerCreator.scala | 44 +++--- .../sink/config/CloudSinkBucketOptions.scala | 99 +++++++++--- .../common/sink/naming/CloudKeyNamer.scala | 14 +- .../cloud/common/sink/naming/FileNamer.scala | 44 +++++- .../cloud/common/sink/naming/KeyNamer.scala | 12 +- .../common/sink/naming/ObjectKeyBuilder.scala | 40 +++++ .../cloud/common/sink/writer/WriteState.scala | 38 +++-- .../cloud/common/sink/writer/Writer.scala | 70 +++++---- .../common/sink/writer/WriterManager.scala | 7 +- .../source/config/PartitionExtractor.scala | 22 ++- .../sink/naming/CloudKeyNamerTest.scala | 66 ++++++-- .../common/sink/naming/FileNamerTest.scala | 16 +- .../common/sink/CoreSinkTaskTestCases.scala | 101 ++++++++++++- .../cloud/common/utils/RemoteFileHelper.scala | 2 +- .../converters/SinkRecordConverterTest.scala | 14 +- 39 files changed, 1056 insertions(+), 526 deletions(-) create mode 100644 kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/SourceRecordsLoop.scala create mode 100644 kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/TempFileHelper.scala create mode 100644 kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/UploadException.scala create mode 100644 kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/ObjectKeyBuilder.scala diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala index 29d866c76..82e380df6 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala @@ -48,8 +48,10 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.LeftPadP import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.NoOpPaddingStrategy import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingService import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingStrategy -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamer import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer +import io.lenses.streamreactor.connect.cloud.common.sink.naming.FileNamer +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV0 +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV1 import io.lenses.streamreactor.connect.cloud.common.utils.SampleData.UsersSchemaDecimal import org.apache.avro.generic.GenericRecord import org.apache.kafka.connect.data.Decimal @@ -60,6 +62,7 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import java.nio.ByteBuffer +import java.time.Instant class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyContainerTest { @@ -73,7 +76,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont private implicit val cloudLocationValidator: CloudLocationValidator = S3LocationValidator private val bucketAndPrefix = CloudLocation(BucketName, PathPrefix.some) - private def avroConfig = S3SinkConfig( + private def avroConfig(fileNamer: FileNamer) = S3SinkConfig( S3ConnectionConfig( None, Some(s3Container.identity.identity), @@ -89,10 +92,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( AvroFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamer( - identity[String], - AvroFormatSelection.extension, - ), + fileNamer, new PaddingService(Map[String, PaddingStrategy]( "partition" -> NoOpPaddingStrategy, "offset" -> LeftPadPaddingStrategy(12, 0), @@ -108,7 +108,81 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ) "avro sink" should "write 2 records to avro format in s3" in { - val sink = writerManagerCreator.from(avroConfig) + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + identity[String], + AvroFormatSelection.extension, + ))) + firstUsers.zipWithIndex.foreach { + case (struct: Struct, index: Int) => + val writeRes = sink.write( + TopicPartitionOffset(Topic(TopicName), 1, Offset((index + 1).toLong)), + MessageDetail( + NullSinkData(None), + StructSinkData(struct), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(index.toLong + 101)), + Topic(TopicName), + 1, + Offset((index + 1).toLong), + ), + ) + writeRes.isRight should be(true) + } + + sink.close() + + val keys = listBucketPath(BucketName, "streamReactorBackups/myTopic/1/") + keys.size should be(1) + + val byteArray = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2_101_102.avro") + val genericRecords: List[GenericRecord] = avroFormatReader.read(byteArray) + genericRecords.size should be(2) + + genericRecords(0).get("name").toString should be("sam") + genericRecords(1).get("name").toString should be("laura") + + } + + "avro sink" should "write multiple files and keeping the earliest timestamp" in { + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + identity[String], + AvroFormatSelection.extension, + ))) + firstUsers.zip(List(0 -> 100, 1 -> 99, 2 -> 101, 3 -> 102)).foreach { + case (struct: Struct, (index: Int, timestamp: Int)) => + val writeRes = sink.write( + TopicPartitionOffset(Topic(TopicName), 1, Offset((index + 1).toLong)), + MessageDetail( + NullSinkData(None), + StructSinkData(struct), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(timestamp.toLong)), + Topic(TopicName), + 1, + Offset((index + 1).toLong), + ), + ) + writeRes.isRight should be(true) + } + + sink.close() + + listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) + + val byteArray = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2_99_100.avro") + val genericRecords: List[GenericRecord] = avroFormatReader.read(byteArray) + genericRecords.size should be(2) + + genericRecords(0).get("name").toString should be("sam") + genericRecords(1).get("name").toString should be("laura") + + } + + "avro sink" should "write 2 records to avro format in s3 using v0 namer" in { + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV0( + identity[String], + AvroFormatSelection.extension, + ))) firstUsers.zipWithIndex.foreach { case (struct: Struct, index: Int) => val writeRes = sink.write( @@ -139,7 +213,10 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont } "avro sink" should "write BigDecimal" in { - val sink = writerManagerCreator.from(avroConfig) + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + identity[String], + AvroFormatSelection.extension, + ))) val usersWithDecimal1 = new Struct(UsersSchemaDecimal) .put("name", "sam") @@ -154,7 +231,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont NullSinkData(None), StructSinkData(usersWithDecimal1), Map.empty, - None, + Some(Instant.ofEpochMilli(10L)), Topic(TopicName), 1, Offset(1L), @@ -178,7 +255,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont NullSinkData(None), StructSinkData(usersWithDecimal2), Map.empty, - None, + Some(Instant.ofEpochMilli(10L)), Topic(TopicName), 1, Offset(2L), @@ -189,7 +266,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - val byteArray = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2.avro") + val byteArray = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2_10_10.avro") val genericRecords: List[GenericRecord] = avroFormatReader.read(byteArray) genericRecords.size should be(2) @@ -215,28 +292,33 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont new Struct(secondSchema).put("name", "coco").put("designation", null).put("salary", 395.44), ) - val sink = writerManagerCreator.from(avroConfig) + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + identity[String], + AvroFormatSelection.extension, + ))) firstUsers.concat(usersWithNewSchema).zipWithIndex.foreach { case (user, index) => sink.write( TopicPartitionOffset(Topic(TopicName), 1, Offset((index + 1).toLong)), - MessageDetail(NullSinkData(None), - StructSinkData(user), - Map.empty[String, SinkData], - None, - Topic(TopicName), - 1, - Offset((index + 1).toLong), + MessageDetail( + NullSinkData(None), + StructSinkData(user), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(index.toLong)), + Topic(TopicName), + 1, + Offset((index + 1).toLong), ), ) } sink.close() - listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) + val keys = listBucketPath(BucketName, "streamReactorBackups/myTopic/1/") + keys.size should be(3) // records 1 and 2 val genericRecords1: List[GenericRecord] = avroFormatReader.read( - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2.avro"), + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2_0_1.avro"), ) genericRecords1.size should be(2) genericRecords1(0).get("name").toString should be("sam") @@ -244,14 +326,14 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont // record 3 only - next schema is different so ending the file val genericRecords2: List[GenericRecord] = avroFormatReader.read( - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/3.avro"), + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/3_2_2.avro"), ) genericRecords2.size should be(1) genericRecords2(0).get("name").toString should be("tom") // record 3 only - next schema is different so ending the file val genericRecords3: List[GenericRecord] = avroFormatReader.read( - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/5.avro"), + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/5_3_4.avro"), ) genericRecords3.size should be(2) genericRecords3(0).get("name").toString should be("bobo") diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala index 9fe59edc5..2ca1be6a8 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala @@ -48,13 +48,16 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.NoOpPadd import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingService import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingStrategy import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamer +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV0 +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV1 import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData.firstUsers import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData.users import io.lenses.streamreactor.connect.cloud.common.utils.SampleData.UsersSchemaDecimal import org.apache.kafka.connect.data.Struct import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers + +import java.time.Instant import scala.jdk.CollectionConverters._ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyContainerTest { @@ -67,7 +70,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont private val PathPrefix = "streamReactorBackups" private implicit val cloudLocationValidator: S3LocationValidator.type = S3LocationValidator - "json sink" should "write single json record" in { + "json sink" should "write single json record using v0 key naming" in { val bucketAndPrefix = CloudLocation(BucketName, PathPrefix.some) val config = S3SinkConfig( @@ -86,7 +89,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( JsonFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamer( + new OffsetFileNamerV0( identity[String], JsonFormatSelection.extension, ), @@ -109,7 +112,15 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont val offset = Offset(1) sink.write( TopicPartitionOffset(topic, 1, offset), - MessageDetail(NullSinkData(None), StructSinkData(users.head), Map.empty[String, SinkData], None, topic, 1, offset), + MessageDetail( + NullSinkData(None), + StructSinkData(users.head), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(1001L)), + topic, + 1, + offset, + ), ) sink.close() @@ -120,6 +131,67 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ) } + "json sink" should "write single json record using v1 key naming" in { + + val bucketAndPrefix = CloudLocation(BucketName, PathPrefix.some) + val config = S3SinkConfig( + S3ConnectionConfig( + None, + Some(s3Container.identity.identity), + Some(s3Container.identity.credential), + AuthMode.Credentials, + ), + bucketOptions = Seq( + CloudSinkBucketOptions( + TopicName.some, + bucketAndPrefix, + commitPolicy = CommitPolicy(Count(1)), + formatSelection = JsonFormatSelection, + keyNamer = new CloudKeyNamer( + JsonFormatSelection, + defaultPartitionSelection(Values), + new OffsetFileNamerV1( + identity[String], + JsonFormatSelection.extension, + ), + new PaddingService(Map[String, PaddingStrategy]( + "partition" -> NoOpPaddingStrategy, + "offset" -> LeftPadPaddingStrategy(12, 0), + )), + ), + localStagingArea = LocalStagingArea(localRoot), + dataStorage = DataStorageSettings.disabled, + ), // JsonS3Format + ), + offsetSeekerOptions = OffsetSeekerOptions(5), + compressionCodec, + batchDelete = true, + ) + + val sink = writerManagerCreator.from(config) + val topic = Topic(TopicName) + val offset = Offset(1) + sink.write( + TopicPartitionOffset(topic, 1, offset), + MessageDetail( + NullSinkData(None), + StructSinkData(users.head), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(111L)), + topic, + 1, + offset, + ), + ) + sink.close() + + listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) + + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/1_111_111.json") should be( + """{"name":"sam","title":"mr","salary":100.43}""", + ) + } + "json sink" should "write schemas to json" in { val bucketAndPrefix = CloudLocation(BucketName, PathPrefix.some) @@ -139,7 +211,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( AvroFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamer( + new OffsetFileNamerV1( identity[String], JsonFormatSelection.extension, ), @@ -164,7 +236,15 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont val offset = Offset(index.toLong + 1) sink.write( TopicPartitionOffset(topic, 1, offset), - MessageDetail(NullSinkData(None), StructSinkData(struct), Map.empty[String, SinkData], None, topic, 0, offset), + MessageDetail( + NullSinkData(None), + StructSinkData(struct), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli((index + 1).toLong)), + topic, + 0, + offset, + ), ) } @@ -172,7 +252,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/3.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/3_1_3.json") should be( """{"name":"sam","title":"mr","salary":100.43}{"name":"laura","title":"ms","salary":429.06}{"name":"tom","title":null,"salary":395.44}""", ) } @@ -196,7 +276,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( AvroFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamer( + new OffsetFileNamerV1( identity[String], JsonFormatSelection.extension, ), @@ -228,13 +308,14 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ) sink.write( TopicPartitionOffset(topic, 1, offset), - MessageDetail(NullSinkData(None), - StructSinkData(usersWithDecimal), - Map.empty[String, SinkData], - None, - topic, - 0, - offset, + MessageDetail( + NullSinkData(None), + StructSinkData(usersWithDecimal), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(5555L)), + topic, + 0, + offset, ), ) @@ -242,7 +323,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/1.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/1_5555_5555.json") should be( s"""{"name":"sam","title":"mr","salary":100.430000000000000000}""", ) } @@ -266,7 +347,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( AvroFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamer( + new OffsetFileNamerV1( identity[String], JsonFormatSelection.extension, ), @@ -294,20 +375,21 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont sink.write( TopicPartitionOffset(topic, 1, offset), - MessageDetail(NullSinkData(None), - ArraySinkData(listOfPojo, None), - Map.empty[String, SinkData], - None, - topic, - 1, - offset, + MessageDetail( + NullSinkData(None), + ArraySinkData(listOfPojo, None), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(1L)), + topic, + 1, + offset, ), ) sink.close() listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/1.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/1_1_1.json") should be( """[{"name":"sam","title":"mr","salary":100.43},{"name":"laura","title":"ms","salary":429.06}]""", ) } diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala index ec7969426..6f1adf283 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala @@ -47,8 +47,8 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.LeftPadP import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.NoOpPaddingStrategy import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingService import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingStrategy -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamer import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV1 import org.apache.avro.generic.GenericRecord import org.apache.kafka.connect.data.Schema import org.apache.kafka.connect.data.SchemaBuilder @@ -56,6 +56,8 @@ import org.apache.kafka.connect.data.Struct import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import java.time.Instant + class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyContainerTest { private val writerManagerCreator = new WriterManagerCreator[S3FileMetadata, S3SinkConfig]() @@ -83,7 +85,7 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC keyNamer = new CloudKeyNamer( ParquetFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamer( + new OffsetFileNamerV1( identity[String], ParquetFormatSelection.extension, ), @@ -111,7 +113,15 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC val offset = Offset(index.toLong + 1) sink.write( TopicPartitionOffset(topic, 1, offset), - MessageDetail(NullSinkData(None), StructSinkData(struct), Map.empty[String, SinkData], None, topic, 1, offset), + MessageDetail( + NullSinkData(None), + StructSinkData(struct), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(1001L + index.toLong)), + topic, + 1, + offset, + ), ) } @@ -119,7 +129,7 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - val byteArray = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2.parquet") + val byteArray = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2_1001_1002.parquet") val genericRecords: List[GenericRecord] = parquetFormatReader.read(byteArray) genericRecords.size should be(2) @@ -149,7 +159,15 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC val offset = Offset(index.toLong + 1) sink.write( TopicPartitionOffset(topic, 1, offset), - MessageDetail(NullSinkData(None), StructSinkData(user), Map.empty[String, SinkData], None, topic, 1, offset), + MessageDetail( + NullSinkData(None), + StructSinkData(user), + Map.empty[String, SinkData], + Some(Instant.ofEpochMilli(index.toLong)), + topic, + 1, + offset, + ), ) } sink.close() @@ -158,7 +176,7 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC // records 1 and 2 val genericRecords1: List[GenericRecord] = parquetFormatReader.read( - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2.parquet"), + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2_0_1.parquet"), ) genericRecords1.size should be(2) genericRecords1(0).get("name").toString should be("sam") @@ -166,14 +184,13 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC // record 3 only - next schema is different so ending the file val genericRecords2: List[GenericRecord] = parquetFormatReader.read( - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/3.parquet"), + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/3_2_2.parquet"), ) genericRecords2.size should be(1) genericRecords2(0).get("name").toString should be("tom") - // record 3 only - next schema is different so ending the file val genericRecords3: List[GenericRecord] = parquetFormatReader.read( - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/5.parquet"), + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/5_3_4.parquet"), ) genericRecords3.size should be(2) genericRecords3(0).get("name").toString should be("bobo") diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeNullKeyOrValueTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeNullKeyOrValueTest.scala index 54515adf2..e0b447fbe 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeNullKeyOrValueTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeNullKeyOrValueTest.scala @@ -87,7 +87,8 @@ class S3SinkTaskAvroEnvelopeNullKeyOrValueTest listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(2) - val bytes1 = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.avro") + val bytes1 = + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_10001_10002.avro") val genericRecords1 = avroFormatReader.read(bytes1) genericRecords1.size should be(2) @@ -144,7 +145,8 @@ class S3SinkTaskAvroEnvelopeNullKeyOrValueTest headers2.get("h1").toString should be("record1-header2") headers2.get("h2") should be(2L) - val bytes2 = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.avro") + val bytes2 = + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003_10003_10003.avro") val genericRecords2 = avroFormatReader.read(bytes2) genericRecords2.size should be(1) @@ -212,7 +214,8 @@ class S3SinkTaskAvroEnvelopeNullKeyOrValueTest listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(2) - val bytes1 = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.avro") + val bytes1 = + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_10001_10002.avro") val genericRecords1 = avroFormatReader.read(bytes1) genericRecords1.size should be(2) @@ -269,7 +272,8 @@ class S3SinkTaskAvroEnvelopeNullKeyOrValueTest headers2.get("h1").toString should be("record1-header2") headers2.get("h2") should be(2L) - val bytes2 = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.avro") + val bytes2 = + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003_10003_10003.avro") val genericRecords2 = avroFormatReader.read(bytes2) genericRecords2.size should be(1) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala index 9364832e5..9328cc782 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala @@ -21,6 +21,7 @@ import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData. import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval +import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.KeyNameFormatVersion import io.lenses.streamreactor.connect.cloud.common.formats.AvroFormatReader import org.apache.avro.generic.GenericRecord import org.apache.kafka.common.TopicPartition @@ -29,10 +30,12 @@ import org.apache.kafka.connect.data.Schema import org.apache.kafka.connect.data.SchemaAndValue import org.apache.kafka.connect.data.Struct import org.apache.kafka.connect.sink.SinkRecord +import org.apache.kafka.connect.sink.SinkTaskContext import org.mockito.MockitoSugar import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import java.util import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.SeqHasAsJava @@ -82,15 +85,31 @@ class S3SinkTaskAvroEnvelopeTest record } - "S3SinkTask" should "write to avro format" in { + "S3SinkTask" should "write to avro format using V1 format" in { - val task = new S3SinkTask() + testWritingAvro( + ( + defaultProps + + ("connect.s3.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS AVRO PROPERTIES('store.envelope'=true, 'padding.length.partition'='12', 'padding.length.offset'='12', '${FlushCount.entryName}'=3)") + ).asJava, + "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.avro", + ) + } - val props = ( - defaultProps + - ("connect.s3.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS AVRO PROPERTIES('store.envelope'=true, 'padding.length.partition'='12', 'padding.length.offset'='12', '${FlushCount.entryName}'=3)") - ).asJava + "S3SinkTask" should "write to avro format using V0 format" in { + testWritingAvro( + ( + defaultProps + + ("connect.s3.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS AVRO PROPERTIES('store.envelope'=true, 'padding.length.partition'='12', 'padding.length.offset'='12', '${FlushCount.entryName}'=3, '${KeyNameFormatVersion.entryName}'=0)") + ).asJava, + "streamReactorBackups/myTopic/000000000001/000000000003.avro", + ) + } + private def testWritingAvro(props: util.Map[String, String], expected: String) = { + val task = new S3SinkTask() + val ctx = mock[SinkTaskContext] + task.initialize(ctx) task.start(props) task.open(Seq(new TopicPartition(TopicName, 1)).asJava) val struct1 = new Struct(schema).put("name", "sam").put("title", "mr").put("salary", 100.43) @@ -130,7 +149,7 @@ class S3SinkTaskAvroEnvelopeTest listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.avro") + val bytes = remoteFileAsBytes(BucketName, expected) val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(3) @@ -265,7 +284,7 @@ class S3SinkTaskAvroEnvelopeTest val genericRecords1 = avroFormatReader.read(remoteFileAsBytes(BucketName, - "streamReactorBackups/myTopic/000000000001/000000000002.avro", + "streamReactorBackups/myTopic/000000000001/000000000002_10001_10002.avro", )) genericRecords1.size should be(2) @@ -323,7 +342,7 @@ class S3SinkTaskAvroEnvelopeTest val genericRecords2 = avroFormatReader.read(remoteFileAsBytes(BucketName, - "streamReactorBackups/myTopic/000000000001/000000000003.avro", + "streamReactorBackups/myTopic/000000000001/000000000003_10003_10003.avro", )) genericRecords2.size should be(1) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala index cf177d0c4..c85d6ab4d 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala @@ -127,7 +127,7 @@ class S3SinkTaskJsonEnvelopeTest listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.json") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.json") val jsonRecords = new String(bytes).split("\n") jsonRecords.size should be(3) @@ -198,7 +198,7 @@ class S3SinkTaskJsonEnvelopeTest files.size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.json") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.json") val jsonRecords = new String(bytes).split("\n") jsonRecords.size should be(3) @@ -272,7 +272,7 @@ class S3SinkTaskJsonEnvelopeTest files.size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.json") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.json") val jsonRecords = new String(bytes).split("\n") jsonRecords.size should be(3) @@ -408,7 +408,7 @@ class S3SinkTaskJsonEnvelopeTest files.size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.json") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.json") val jsonRecords = new String(bytes).split("\n") jsonRecords.size should be(3) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala index 5b9386bf5..4d2596fbe 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala @@ -20,6 +20,7 @@ import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData._ import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount +import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.KeyNameFormatVersion import io.lenses.streamreactor.connect.cloud.common.formats.reader.ParquetFormatReader import org.apache.avro.generic.GenericRecord import org.apache.kafka.common.TopicPartition @@ -64,13 +65,24 @@ class S3SinkTaskParquetEnvelopeTest "S3SinkTask" should "write to avro format" in { - val task = new S3SinkTask() - val props = (defaultProps + ( "connect.s3.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS `PARQUET` PROPERTIES('store.envelope'=true,'padding.length.partition'='12', 'padding.length.offset'='12', '${FlushCount.entryName}'=3)", )).asJava + testScenario(props, "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.parquet") + } + + "S3SinkTask" should "write to avro format using V0 key format" in { + + val props = (defaultProps + ( + "connect.s3.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS `PARQUET` PROPERTIES('store.envelope'=true,'padding.length.partition'='12', 'padding.length.offset'='12', '${FlushCount.entryName}'=3, '${KeyNameFormatVersion.entryName}'=0)", + )).asJava + testScenario(props, "streamReactorBackups/myTopic/000000000001/000000000003.parquet") + } + private def testScenario(props: java.util.Map[String, String], expectedFile: String) = { + val task = new S3SinkTask() task.start(props) + task.open(Seq(new TopicPartition(TopicName, 1)).asJava) val struct1 = new Struct(schema).put("name", "sam").put("title", "mr").put("salary", 100.43) val struct2 = new Struct(schema).put("name", "laura").put("title", "ms").put("salary", 429.06) @@ -109,7 +121,7 @@ class S3SinkTaskParquetEnvelopeTest listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.parquet") + val bytes = remoteFileAsBytes(BucketName, expectedFile) val genericRecords = parquetFormatReader.read(bytes) genericRecords.size should be(3) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/WriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/WriterManagerTest.scala index 3f6ad01d9..b80045f69 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/WriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/WriterManagerTest.scala @@ -13,6 +13,7 @@ import io.lenses.streamreactor.connect.cloud.common.sink.commit.Count import io.lenses.streamreactor.connect.cloud.common.sink.commit.FileSize import io.lenses.streamreactor.connect.cloud.common.sink.commit.Interval import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer +import io.lenses.streamreactor.connect.cloud.common.sink.naming.ObjectKeyBuilder import io.lenses.streamreactor.connect.cloud.common.sink.seek.IndexManager import io.lenses.streamreactor.connect.cloud.common.sink.writer.WriterManager import org.apache.kafka.clients.consumer.OffsetAndMetadata @@ -36,7 +37,7 @@ class WriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyContainerT keyNamerFn = _ => s3KeyNamer.asRight, stagingFilenameFn = (_, _) => new File("blah.csv").asRight, - finalFilenameFn = (_, _, _) => mock[CloudLocation].asRight, + objKeyBuilderFn = (_, _) => mock[ObjectKeyBuilder], formatWriterFn = (_, _) => mock[FormatWriter].asRight, indexManager = mock[IndexManager[S3FileMetadata]], _.asRight, diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroEnvelopeTest.scala index 04c362d7d..3600ea656 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroEnvelopeTest.scala @@ -7,17 +7,16 @@ import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceSettingsKeys import org.apache.avro.SchemaBuilder import org.apache.avro.file.CodecFactory +//import org.apache.avro.file.CodecFactory import org.apache.avro.file.DataFileWriter import org.apache.avro.generic.GenericDatumWriter -import org.apache.kafka.connect.source.SourceRecord import org.scalatest.EitherValues -import org.scalatest.concurrent.Eventually.eventually import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import java.time.Instant import scala.jdk.CollectionConverters.IterableHasAsScala -import scala.jdk.CollectionConverters.ListHasAsScala import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala @@ -26,7 +25,8 @@ class S3SourceAvroEnvelopeTest with AnyFlatSpecLike with Matchers with EitherValues - with CloudSourceSettingsKeys { + with CloudSourceSettingsKeys + with TempFileHelper { def DefaultProps: Map[String, String] = defaultProps + ( SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS -> "1000", @@ -100,8 +100,7 @@ class S3SourceAvroEnvelopeTest metadata.put("offset", 0L) envelope.put("metadata", metadata) - val file = new java.io.File("00001.avro") - try { + withFile("00001.avro") { file => val outputStream = new java.io.BufferedOutputStream(new java.io.FileOutputStream(file)) val writer: GenericDatumWriter[Any] = new GenericDatumWriter[Any](EnvelopeSchema) val fileWriter: DataFileWriter[Any] = @@ -111,11 +110,9 @@ class S3SourceAvroEnvelopeTest fileWriter.flush() fileWriter.close() + file.exists() shouldBe true storageInterface.uploadFile(UploadableFile(file), BucketName, s"$MyPrefix/avro/0") ().asRight - } finally { - file.delete() - () } } @@ -131,46 +128,40 @@ class S3SourceAvroEnvelopeTest task.start(props) - var sourceRecords: Seq[SourceRecord] = List.empty try { - eventually { - do { - sourceRecords = sourceRecords ++ task.poll().asScala - } while (sourceRecords.size != 1) - task.poll() should be(empty) - } + val sourceRecords = + SourceRecordsLoop.loop(task, 30.seconds.toMillis, 1).getOrElse(fail("No records returned within timeout")) + task.poll() should be(empty) + val sourceRecord = sourceRecords.head + sourceRecord.keySchema().name() should be("transactionId") + sourceRecord.key().asInstanceOf[org.apache.kafka.connect.data.Struct].getString("id") should be("1") + + sourceRecord.valueSchema().name() should be("transaction") + val valStruct = sourceRecord.value().asInstanceOf[org.apache.kafka.connect.data.Struct] + valStruct.getString("id") should be("1") + valStruct.getString("name") should be("John Smith") + valStruct.getString("email") should be("jn@johnsmith.com") + valStruct.getString("card") should be("1234567890") + valStruct.getString("ip") should be("192.168.0.2") + valStruct.getString("country") should be("UK") + valStruct.getString("currency") should be("GBP") + valStruct.getString("timestamp") should be("2020-01-01T00:00:00.000Z") + + sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", + "header2" -> 123456789L, + )) + + sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") + val sourceOffsetMap = sourceRecord.sourceOffset().asScala + sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" + sourceOffsetMap("line") shouldBe "0" + sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true + + sourceRecord.topic() shouldBe TopicName + sourceRecord.kafkaPartition() shouldBe 3 + sourceRecord.timestamp() shouldBe 1234567890L } finally { task.stop() } - - //assert the record matches the envelope - val sourceRecord = sourceRecords.head - sourceRecord.keySchema().name() should be("transactionId") - sourceRecord.key().asInstanceOf[org.apache.kafka.connect.data.Struct].getString("id") should be("1") - - sourceRecord.valueSchema().name() should be("transaction") - val valStruct = sourceRecord.value().asInstanceOf[org.apache.kafka.connect.data.Struct] - valStruct.getString("id") should be("1") - valStruct.getString("name") should be("John Smith") - valStruct.getString("email") should be("jn@johnsmith.com") - valStruct.getString("card") should be("1234567890") - valStruct.getString("ip") should be("192.168.0.2") - valStruct.getString("country") should be("UK") - valStruct.getString("currency") should be("GBP") - valStruct.getString("timestamp") should be("2020-01-01T00:00:00.000Z") - - sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", - "header2" -> 123456789L, - )) - - sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") - val sourceOffsetMap = sourceRecord.sourceOffset().asScala - sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" - sourceOffsetMap("line") shouldBe "0" - sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true - - sourceRecord.topic() shouldBe TopicName - sourceRecord.kafkaPartition() shouldBe 3 - sourceRecord.timestamp() shouldBe 1234567890L } } diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsArrayEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsArrayEnvelopeTest.scala index 1d3614372..7bf7db9f4 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsArrayEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsArrayEnvelopeTest.scala @@ -1,6 +1,6 @@ package io.lenses.streamreactor.connect.aws.s3.source -import cats.implicits.catsSyntaxEitherId +import cats.implicits.toBifunctorOps import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile @@ -10,16 +10,14 @@ import org.apache.avro.file.CodecFactory import org.apache.avro.file.DataFileWriter import org.apache.avro.generic.GenericDatumWriter import org.apache.kafka.connect.data.Schema -import org.apache.kafka.connect.source.SourceRecord import org.scalatest.EitherValues -import org.scalatest.concurrent.Eventually.eventually import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import java.nio.ByteBuffer import java.time.Instant import scala.jdk.CollectionConverters.IterableHasAsScala -import scala.jdk.CollectionConverters.ListHasAsScala import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala @@ -28,7 +26,8 @@ class S3SourceAvroWithValueAsArrayEnvelopeTest with AnyFlatSpecLike with Matchers with EitherValues - with CloudSourceSettingsKeys { + with CloudSourceSettingsKeys + with TempFileHelper { def DefaultProps: Map[String, String] = defaultProps + ( SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS -> "1000", @@ -80,8 +79,7 @@ class S3SourceAvroWithValueAsArrayEnvelopeTest metadata.put("offset", 0L) envelope.put("metadata", metadata) - val file = new java.io.File("00001.avro") - try { + withFile("00001.avro") { file => val outputStream = new java.io.BufferedOutputStream(new java.io.FileOutputStream(file)) val writer: GenericDatumWriter[Any] = new GenericDatumWriter[Any](EnvelopeSchema) val fileWriter: DataFileWriter[Any] = @@ -92,10 +90,7 @@ class S3SourceAvroWithValueAsArrayEnvelopeTest fileWriter.close() storageInterface.uploadFile(UploadableFile(file), BucketName, s"$MyPrefix/avro/0") - ().asRight - } finally { - file.delete() - () + .leftMap(e => new UploadException(e)) } } @@ -112,41 +107,36 @@ class S3SourceAvroWithValueAsArrayEnvelopeTest task.start(props) - var sourceRecords: Seq[SourceRecord] = List.empty try { - eventually { + val sourceRecords = + SourceRecordsLoop.loop(task, 10.seconds.toMillis, 1).getOrElse(fail("No records returned within timeout")) - do { - sourceRecords = sourceRecords ++ task.poll().asScala - } while (sourceRecords.size != 1) - task.poll() should be(empty) + task.poll() should be(empty) - } - } finally { - task.stop() - } + val sourceRecord = sourceRecords.head + sourceRecord.keySchema().`type`() should be(Schema.Type.BYTES) + sourceRecord.key().asInstanceOf[Array[Byte]] should be(Array[Byte](1, 2, 3, 4, 5, 6, 7, 8, 9)) - //assert the record matches the envelope - val sourceRecord = sourceRecords.head - sourceRecord.keySchema().`type`() should be(Schema.Type.BYTES) - sourceRecord.key().asInstanceOf[Array[Byte]] should be(Array[Byte](1, 2, 3, 4, 5, 6, 7, 8, 9)) + sourceRecord.valueSchema().`type`() should be(Schema.Type.BYTES) + val value: Array[Byte] = sourceRecord.value().asInstanceOf[Array[Byte]] + value should be(Array[Byte](1, 2, 3, 4, 5, 6, 7, 8, 9)) - sourceRecord.valueSchema().`type`() should be(Schema.Type.BYTES) - val value: Array[Byte] = sourceRecord.value().asInstanceOf[Array[Byte]] - value should be(Array[Byte](1, 2, 3, 4, 5, 6, 7, 8, 9)) + sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", + "header2" -> 123456789L, + )) - sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", - "header2" -> 123456789L, - )) + sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") + val sourceOffsetMap = sourceRecord.sourceOffset().asScala + sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" + sourceOffsetMap("line") shouldBe "0" + sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true - sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") - val sourceOffsetMap = sourceRecord.sourceOffset().asScala - sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" - sourceOffsetMap("line") shouldBe "0" - sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true + sourceRecord.topic() shouldBe TopicName + sourceRecord.kafkaPartition() shouldBe 3 + sourceRecord.timestamp() shouldBe 1234567890L + } finally { + task.stop() + } - sourceRecord.topic() shouldBe TopicName - sourceRecord.kafkaPartition() shouldBe 3 - sourceRecord.timestamp() shouldBe 1234567890L } } diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsOptionalArrayEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsOptionalArrayEnvelopeTest.scala index 6b62d7767..485bb8de1 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsOptionalArrayEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceAvroWithValueAsOptionalArrayEnvelopeTest.scala @@ -1,6 +1,6 @@ package io.lenses.streamreactor.connect.aws.s3.source -import cats.implicits.catsSyntaxEitherId +import cats.implicits.toBifunctorOps import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile @@ -11,24 +11,21 @@ import org.apache.avro.file.CodecFactory import org.apache.avro.file.DataFileWriter import org.apache.avro.generic.GenericDatumWriter import org.apache.kafka.connect.data.Schema -import org.apache.kafka.connect.source.SourceRecord import org.scalatest.EitherValues -import org.scalatest.concurrent.Eventually.eventually import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers import java.time.Instant -import scala.jdk.CollectionConverters.IterableHasAsScala -import scala.jdk.CollectionConverters.ListHasAsScala -import scala.jdk.CollectionConverters.MapHasAsJava -import scala.jdk.CollectionConverters.MapHasAsScala +import scala.concurrent.duration.DurationInt +import scala.jdk.CollectionConverters._ class S3SourceAvroWithValueAsOptionalArrayEnvelopeTest extends S3ProxyContainerTest with AnyFlatSpecLike with Matchers with EitherValues - with CloudSourceSettingsKeys { + with CloudSourceSettingsKeys + with TempFileHelper { def DefaultProps: Map[String, String] = defaultProps + ( SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS -> "1000", @@ -79,8 +76,7 @@ class S3SourceAvroWithValueAsOptionalArrayEnvelopeTest metadata.put("offset", 0L) envelope.put("metadata", metadata) - val file = new java.io.File("00001.avro") - try { + withFile("00001.avro") { file => val outputStream = new java.io.BufferedOutputStream(new java.io.FileOutputStream(file)) val writer: GenericDatumWriter[Any] = new GenericDatumWriter[Any](EnvelopeSchema) val fileWriter: DataFileWriter[Any] = @@ -91,10 +87,7 @@ class S3SourceAvroWithValueAsOptionalArrayEnvelopeTest fileWriter.close() storageInterface.uploadFile(UploadableFile(file), BucketName, s"$MyPrefix/avro/0") - ().asRight - } finally { - file.delete() - () + .leftMap(e => new UploadException(e)) } } @@ -111,42 +104,36 @@ class S3SourceAvroWithValueAsOptionalArrayEnvelopeTest task.start(props) - var sourceRecords: Seq[SourceRecord] = List.empty try { - eventually { - - do { - sourceRecords = sourceRecords ++ task.poll().asScala - } while (sourceRecords.size != 1) - task.poll() should be(empty) - - } + val sourceRecords = SourceRecordsLoop.loop(task, 10.seconds.toMillis, 1).value + + task.poll() should be(empty) + //assert the record matches the envelope + val sourceRecord = sourceRecords.head + sourceRecord.keySchema().`type`() should be(Schema.Type.BYTES) + sourceRecord.key() shouldBe null + + sourceRecord.valueSchema().`type`() should be(Schema.Type.BYTES) + val value: Array[Byte] = sourceRecord.value().asInstanceOf[Array[Byte]] + value shouldBe null + + sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", + "header2" -> 123456789L, + )) + + sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") + val sourceOffsetMap = sourceRecord.sourceOffset().asScala + sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" + sourceOffsetMap("line") shouldBe "0" + sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true + + sourceRecord.topic() shouldBe TopicName + sourceRecord.kafkaPartition() shouldBe 3 + sourceRecord.timestamp() shouldBe 1234567890L } finally { task.stop() } - //assert the record matches the envelope - val sourceRecord = sourceRecords.head - sourceRecord.keySchema().`type`() should be(Schema.Type.BYTES) - sourceRecord.key() shouldBe null - - sourceRecord.valueSchema().`type`() should be(Schema.Type.BYTES) - val value: Array[Byte] = sourceRecord.value().asInstanceOf[Array[Byte]] - value shouldBe null - - sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", - "header2" -> 123456789L, - )) - - sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") - val sourceOffsetMap = sourceRecord.sourceOffset().asScala - sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" - sourceOffsetMap("line") shouldBe "0" - sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true - - sourceRecord.topic() shouldBe TopicName - sourceRecord.kafkaPartition() shouldBe 3 - sourceRecord.timestamp() shouldBe 1234567890L } } @@ -155,7 +142,8 @@ class S3SourceAvroWithValueAsOptionalArrayMixValuesEnvelopeTest with AnyFlatSpecLike with Matchers with EitherValues - with CloudSourceSettingsKeys { + with CloudSourceSettingsKeys + with TempFileHelper { def DefaultProps: Map[String, String] = defaultProps + ( SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS -> "1000", @@ -207,8 +195,7 @@ class S3SourceAvroWithValueAsOptionalArrayMixValuesEnvelopeTest metadata.put("offset", 0L) envelope.put("metadata", metadata) - val file = new java.io.File("00001.avro") - try { + withFile("00001.avro") { file => val outputStream = new java.io.BufferedOutputStream(new java.io.FileOutputStream(file)) val writer: GenericDatumWriter[Any] = new GenericDatumWriter[Any](EnvelopeSchema) val fileWriter: DataFileWriter[Any] = @@ -219,10 +206,7 @@ class S3SourceAvroWithValueAsOptionalArrayMixValuesEnvelopeTest fileWriter.close() storageInterface.uploadFile(UploadableFile(file), BucketName, s"$MyPrefix/avro/0") - ().asRight - } finally { - file.delete() - () + .leftMap(e => new UploadException(e)) } } @@ -239,39 +223,32 @@ class S3SourceAvroWithValueAsOptionalArrayMixValuesEnvelopeTest task.start(props) - var sourceRecords: Seq[SourceRecord] = List.empty try { - eventually { - do { - sourceRecords = sourceRecords ++ task.poll().asScala - } while (sourceRecords.size != 1) - task.poll() should be(empty) - } + val sourceRecords = SourceRecordsLoop.loop(task, 10.seconds.toMillis, 1).value + task.poll() should be(empty) + val sourceRecord = sourceRecords.head + sourceRecord.keySchema().`type`() should be(Schema.Type.BYTES) + sourceRecord.key() shouldBe null + + sourceRecord.valueSchema().`type`() should be(Schema.Type.BYTES) + val value: Array[Byte] = sourceRecord.value().asInstanceOf[Array[Byte]] + value shouldBe "value".getBytes() + + sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", + "header2" -> 123456789L, + )) + + sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") + val sourceOffsetMap = sourceRecord.sourceOffset().asScala + sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" + sourceOffsetMap("line") shouldBe "0" + sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true + + sourceRecord.topic() shouldBe TopicName + sourceRecord.kafkaPartition() shouldBe 3 + sourceRecord.timestamp() shouldBe 1234567890L } finally { task.stop() } - - //assert the record matches the envelope - val sourceRecord = sourceRecords.head - sourceRecord.keySchema().`type`() should be(Schema.Type.BYTES) - sourceRecord.key() shouldBe null - - sourceRecord.valueSchema().`type`() should be(Schema.Type.BYTES) - val value: Array[Byte] = sourceRecord.value().asInstanceOf[Array[Byte]] - value shouldBe "value".getBytes() - - sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", - "header2" -> 123456789L, - )) - - sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/avro/") - val sourceOffsetMap = sourceRecord.sourceOffset().asScala - sourceOffsetMap("path") shouldBe s"$MyPrefix/avro/0" - sourceOffsetMap("line") shouldBe "0" - sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true - - sourceRecord.topic() shouldBe TopicName - sourceRecord.kafkaPartition() shouldBe 3 - sourceRecord.timestamp() shouldBe 1234567890L } } diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceJsonEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceJsonEnvelopeTest.scala index d18df3146..21c39d919 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceJsonEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceJsonEnvelopeTest.scala @@ -1,19 +1,17 @@ package io.lenses.streamreactor.connect.aws.s3.source -import cats.implicits.catsSyntaxEitherId +import cats.implicits.toBifunctorOps import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceSettingsKeys -import org.apache.kafka.connect.source.SourceRecord import org.scalatest.EitherValues -import org.scalatest.concurrent.Eventually.eventually import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers import java.time.Instant +import scala.concurrent.duration.DurationInt import scala.jdk.CollectionConverters.IterableHasAsScala -import scala.jdk.CollectionConverters.ListHasAsScala import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala @@ -22,7 +20,8 @@ class S3SourceJsonEnvelopeTest with AnyFlatSpecLike with Matchers with EitherValues - with CloudSourceSettingsKeys { + with CloudSourceSettingsKeys + with TempFileHelper { def DefaultProps: Map[String, String] = defaultProps + ( SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS -> "1000", @@ -36,18 +35,13 @@ class S3SourceJsonEnvelopeTest override def setUpTestData(storageInterface: AwsS3StorageInterface): Either[Throwable, Unit] = { val envelopeJson = """{"key":{"id":"1"},"value":{"id":"1","name":"John Smith","email":"js@johnsmith.com","card":"1234567890","ip":"192.168.0.2","country":"UK","currency":"GBP","timestamp":"2020-01-01T00:00:00.000Z"},"headers":{"header1":"value1","header2":123456789},"metadata":{"timestamp":1234567890,"topic":"myTopic","partition":3,"offset":0}}""".stripMargin - val file = new java.io.File("00001.json") - try { - //write the json to file as text + withFile("00001.json") { file => val bw = new java.io.BufferedWriter(new java.io.FileWriter(file)) bw.write(envelopeJson) bw.flush() bw.close() storageInterface.uploadFile(UploadableFile(file), BucketName, s"$MyPrefix/json/0") - ().asRight - } finally { - file.delete() - () + .leftMap(e => new UploadException(e)) } } @@ -63,37 +57,30 @@ class S3SourceJsonEnvelopeTest task.start(props) - var sourceRecords: Seq[SourceRecord] = List.empty try { - eventually { - do { - sourceRecords = sourceRecords ++ task.poll().asScala - } while (sourceRecords.size != 1) - task.poll() should be(empty) - } + val sourceRecords = SourceRecordsLoop.loop(task, 10.seconds.toMillis, 1).value + task.poll() should be(empty) + val sourceRecord = sourceRecords.head + sourceRecord.key() should be("""{"id":"1"}""") + + sourceRecord.value() shouldBe """{"id":"1","name":"John Smith","email":"js@johnsmith.com","card":"1234567890","ip":"192.168.0.2","country":"UK","currency":"GBP","timestamp":"2020-01-01T00:00:00.000Z"}""" + + sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", + "header2" -> 123456789L, + )) + + sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/json/") + val sourceOffsetMap = sourceRecord.sourceOffset().asScala + sourceOffsetMap("path") shouldBe s"$MyPrefix/json/0" + sourceOffsetMap("line") shouldBe "0" + sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true + + sourceRecord.topic() shouldBe TopicName + sourceRecord.kafkaPartition() shouldBe 3 + sourceRecord.timestamp() shouldBe 1234567890L } finally { task.stop() } - - //assert the record matches the envelope - val sourceRecord = sourceRecords.head - sourceRecord.key() should be("""{"id":"1"}""") - - sourceRecord.value() shouldBe """{"id":"1","name":"John Smith","email":"js@johnsmith.com","card":"1234567890","ip":"192.168.0.2","country":"UK","currency":"GBP","timestamp":"2020-01-01T00:00:00.000Z"}""" - - sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", - "header2" -> 123456789L, - )) - - sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/json/") - val sourceOffsetMap = sourceRecord.sourceOffset().asScala - sourceOffsetMap("path") shouldBe s"$MyPrefix/json/0" - sourceOffsetMap("line") shouldBe "0" - sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true - - sourceRecord.topic() shouldBe TopicName - sourceRecord.kafkaPartition() shouldBe 3 - sourceRecord.timestamp() shouldBe 1234567890L } } diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceParquetEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceParquetEnvelopeTest.scala index 42f24ed3f..e2df1bd05 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceParquetEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceParquetEnvelopeTest.scala @@ -1,6 +1,6 @@ package io.lenses.streamreactor.connect.aws.s3.source -import cats.implicits.catsSyntaxEitherId +import cats.implicits.toBifunctorOps import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.formats.writer.parquet.ParquetOutputFile @@ -8,18 +8,16 @@ import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceSettingsKeys import io.lenses.streamreactor.connect.cloud.common.stream.CloudByteArrayOutputStream import org.apache.avro.SchemaBuilder -import org.apache.kafka.connect.source.SourceRecord import org.apache.parquet.avro.AvroParquetWriter import org.apache.parquet.hadoop.ParquetWriter import org.apache.parquet.hadoop.metadata.CompressionCodecName import org.scalatest.EitherValues -import org.scalatest.concurrent.Eventually.eventually import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers import java.time.Instant +import scala.concurrent.duration.DurationInt import scala.jdk.CollectionConverters.IterableHasAsScala -import scala.jdk.CollectionConverters.ListHasAsScala import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala @@ -28,7 +26,8 @@ class S3SourceParquetEnvelopeTest with AnyFlatSpecLike with Matchers with EitherValues - with CloudSourceSettingsKeys { + with CloudSourceSettingsKeys + with TempFileHelper { def DefaultProps: Map[String, String] = defaultProps + ( SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS -> "1000", @@ -102,8 +101,7 @@ class S3SourceParquetEnvelopeTest metadata.put("offset", 0L) envelope.put("metadata", metadata) - val file = new java.io.File("00001.parquet") - try { + withFile("00001.parquet") { file => val outputStream = new java.io.BufferedOutputStream(new java.io.FileOutputStream(file)) val s3OutputStream = new CloudByteArrayOutputStream val outputFile = new ParquetOutputFile(s3OutputStream) @@ -122,10 +120,7 @@ class S3SourceParquetEnvelopeTest outputStream.flush() outputStream.close() storageInterface.uploadFile(UploadableFile(file), BucketName, s"$MyPrefix/parquet/0") - ().asRight - } finally { - file.delete() - () + .leftMap(e => new UploadException(e)) } } @@ -143,47 +138,41 @@ class S3SourceParquetEnvelopeTest task.start(props) - var sourceRecords: Seq[SourceRecord] = List.empty try { - eventually { - do { - sourceRecords = sourceRecords ++ task.poll().asScala - } while (sourceRecords.size != 1) - task.poll() should be(empty) - } + val sourceRecords = SourceRecordsLoop.loop(task, 10.seconds.toMillis, 1).value + task.poll() should be(empty) + val sourceRecord = sourceRecords.head + sourceRecord.keySchema().name() should be("transactionId") + sourceRecord.key().asInstanceOf[org.apache.kafka.connect.data.Struct].getString("id") should be("1") + + sourceRecord.valueSchema().name() should be("transaction") + val valStruct = sourceRecord.value().asInstanceOf[org.apache.kafka.connect.data.Struct] + valStruct.getString("id") should be("1") + valStruct.getString("name") should be("John Smith") + valStruct.getString("email") should be("jn@johnsmith.com") + valStruct.getString("card") should be("1234567890") + valStruct.getString("ip") should be("192.168.0.2") + valStruct.getString("country") should be("UK") + valStruct.getString("currency") should be("GBP") + valStruct.getString("timestamp") should be("2020-01-01T00:00:00.000Z") + + sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", + "header2" -> 123456789L, + )) + + sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/parquet/") + val sourceOffsetMap = sourceRecord.sourceOffset().asScala + sourceOffsetMap("path") shouldBe s"$MyPrefix/parquet/0" + sourceOffsetMap("line") shouldBe "0" + sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true + + sourceRecord.topic() shouldBe TopicName + sourceRecord.kafkaPartition() shouldBe 3 + sourceRecord.timestamp() shouldBe 1234567890L } finally { task.stop() } - //assert the record matches the envelope - val sourceRecord = sourceRecords.head - sourceRecord.keySchema().name() should be("transactionId") - sourceRecord.key().asInstanceOf[org.apache.kafka.connect.data.Struct].getString("id") should be("1") - - sourceRecord.valueSchema().name() should be("transaction") - val valStruct = sourceRecord.value().asInstanceOf[org.apache.kafka.connect.data.Struct] - valStruct.getString("id") should be("1") - valStruct.getString("name") should be("John Smith") - valStruct.getString("email") should be("jn@johnsmith.com") - valStruct.getString("card") should be("1234567890") - valStruct.getString("ip") should be("192.168.0.2") - valStruct.getString("country") should be("UK") - valStruct.getString("currency") should be("GBP") - valStruct.getString("timestamp") should be("2020-01-01T00:00:00.000Z") - - sourceRecord.headers().asScala.map(h => h.key() -> h.value()).toMap should be(Map("header1" -> "value1", - "header2" -> 123456789L, - )) - - sourceRecord.sourcePartition().asScala shouldBe Map("container" -> BucketName, "prefix" -> s"$MyPrefix/parquet/") - val sourceOffsetMap = sourceRecord.sourceOffset().asScala - sourceOffsetMap("path") shouldBe s"$MyPrefix/parquet/0" - sourceOffsetMap("line") shouldBe "0" - sourceOffsetMap("ts").toString.toLong < Instant.now().toEpochMilli shouldBe true - - sourceRecord.topic() shouldBe TopicName - sourceRecord.kafkaPartition() shouldBe 3 - sourceRecord.timestamp() shouldBe 1234567890L } } diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskBucketRootTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskBucketRootTest.scala index 91c4c828c..d8d4287ea 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskBucketRootTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskBucketRootTest.scala @@ -7,9 +7,11 @@ import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceSettingsKeys import org.scalatest.EitherValues import org.scalatest.concurrent.Eventually.eventually +import org.scalatest.concurrent.PatienceConfiguration import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers import org.scalatest.prop.TableDrivenPropertyChecks._ +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import software.amazon.awssdk.services.s3.model.CreateBucketRequest import scala.jdk.CollectionConverters.ListHasAsScala @@ -54,11 +56,12 @@ class S3SourceTaskBucketRootTest task.start(props) withCleanup(task.stop()) { - val sourceRecords1 = eventually { - val records = task.poll() - records.size() shouldBe 190 - records - } + val sourceRecords1 = + eventually(PatienceConfiguration.Timeout(3.seconds), PatienceConfiguration.Interval(500.millis)) { + val records = task.poll() + records.size() shouldBe 190 + records + } val sourceRecords2 = task.poll() val sourceRecords3 = task.poll() diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskTest.scala index 42c2e8d9b..2b0a2b9b4 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskTest.scala @@ -9,9 +9,9 @@ import io.lenses.streamreactor.connect.aws.s3.source.S3SourceTaskTest.formats import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3DirectoryLister import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest +import io.lenses.streamreactor.connect.cloud.common.config.Format.Bytes import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.Format -import io.lenses.streamreactor.connect.cloud.common.config.Format.Bytes import io.lenses.streamreactor.connect.cloud.common.config.FormatOptions import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocationValidator @@ -20,12 +20,11 @@ import org.apache.kafka.connect.source.SourceTaskContext import org.apache.kafka.connect.storage.OffsetStorageReader import org.scalatest.BeforeAndAfter import org.scalatest.concurrent.Eventually +import org.scalatest.concurrent.PatienceConfiguration import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.scalatest.prop.TableDrivenPropertyChecks._ -import org.scalatest.time.Milliseconds -import org.scalatest.time.Seconds -import org.scalatest.time.Span +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import java.util import scala.jdk.CollectionConverters.ListHasAsScala @@ -51,7 +50,7 @@ class S3SourceTaskTest with CloudSourceSettingsKeys { override implicit def patienceConfig: PatienceConfig = - PatienceConfig(timeout = Span(10, Seconds), interval = Span(500, Milliseconds)) + PatienceConfig(timeout = 10.seconds, interval = 500.millis) implicit val cloudLocationValidator: CloudLocationValidator = S3LocationValidator private var bucketSetupOpt: Option[BucketSetup] = None def bucketSetup: BucketSetup = bucketSetupOpt.getOrElse(throw new IllegalStateException("Not initialised")) @@ -240,7 +239,6 @@ class S3SourceTaskTest task.start(props) withCleanup(task.stop()) { - //Let the partitions scan do its work val sourceRecords1 = eventually { val records = task.poll() records.size() shouldBe 190 @@ -289,11 +287,12 @@ class S3SourceTaskTest task.start(props) withCleanup(task.stop()) { //Let the partitions scan do its work - val sourceRecords1 = eventually { - val records = task.poll() - records.size() shouldBe 5 - records - } + val sourceRecords1 = + eventually(PatienceConfiguration.Timeout(5.seconds), PatienceConfiguration.Interval(100.millis)) { + val records = task.poll() + records.size() shouldBe 5 + records + } val sourceRecords2 = task.poll() task.stop() diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderTest.scala index 793a224cf..7504b08cc 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderTest.scala @@ -7,12 +7,11 @@ import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceSettingsKeys import org.apache.kafka.connect.source.SourceRecord import org.scalatest.EitherValues -import org.scalatest.concurrent.Eventually.eventually import org.scalatest.flatspec.AnyFlatSpecLike import org.scalatest.matchers.should.Matchers import java.io.File -import scala.jdk.CollectionConverters.ListHasAsScala +import scala.concurrent.duration.DurationInt import scala.jdk.CollectionConverters.MapHasAsJava import scala.util.Try import scala.xml.XML @@ -53,40 +52,36 @@ class S3SourceTaskXmlReaderTest task.start(props) - var sourceRecords: Seq[SourceRecord] = List.empty try { - eventually { - do { - sourceRecords = sourceRecords ++ task.poll().asScala - } while (sourceRecords.size != 20000) - val sourceRecordsMopUp = task.poll() - sourceRecordsMopUp should be(empty) - } + val sourceRecords = SourceRecordsLoop.loop(task, 10.seconds.toMillis, 20000).value + val sourceRecordsMopUp = task.poll() + sourceRecordsMopUp should be(empty) + + val firstEmployee = Employee.fromSourceRecord(sourceRecords.head) + firstEmployee.value should be( + Employee( + "1", + "3-1991", + "B1", + "Fairly Paid", + "Skydiving Instructor Extraordinaire", + ), + ) + + val lastEmployee = Employee.fromSourceRecord(sourceRecords.last) + lastEmployee.value should be( + Employee( + "20,000", + "1-2011", + "C1", + "Massively Underpaid", + "Skydiving Instructor for Schools", + ), + ) } finally { task.stop() } - val firstEmployee = Employee.fromSourceRecord(sourceRecords.head) - firstEmployee.value should be( - Employee( - "1", - "3-1991", - "B1", - "Fairly Paid", - "Skydiving Instructor Extraordinaire", - ), - ) - - val lastEmployee = Employee.fromSourceRecord(sourceRecords.last) - lastEmployee.value should be( - Employee( - "20,000", - "1-2011", - "C1", - "Massively Underpaid", - "Skydiving Instructor for Schools", - ), - ) } case class Employee(number: String, startMonth: String, bracket: String, bracketDescription: String, category: String) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/SourceRecordsLoop.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/SourceRecordsLoop.scala new file mode 100644 index 000000000..72c6a38a9 --- /dev/null +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/SourceRecordsLoop.scala @@ -0,0 +1,31 @@ +package io.lenses.streamreactor.connect.aws.s3.source + +import cats.implicits.catsSyntaxEitherId +import org.apache.kafka.connect.source.SourceRecord + +import scala.annotation.tailrec +import scala.jdk.CollectionConverters.ListHasAsScala + +object SourceRecordsLoop { + + def loop(task: S3SourceTask, timeout: Long, expectedSize: Int): Either[Throwable, Seq[SourceRecord]] = { + @tailrec + def loopUntilTimeout( + records: Seq[SourceRecord], + endTime: Long, + currentTime: Long, + ): Either[Throwable, Seq[SourceRecord]] = + if (records.size == expectedSize) { + records.asRight + } else if (currentTime >= endTime) { + new RuntimeException(s"Timeout of $timeout ms reached while polling for records").asLeft + } else { + val polledRecords = task.poll().asScala + val updatedRecords = records ++ polledRecords + loopUntilTimeout(updatedRecords, endTime, System.currentTimeMillis()) + } + + loopUntilTimeout(Seq.empty, System.currentTimeMillis() + timeout, 0L) + + } +} diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/TempFileHelper.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/TempFileHelper.scala new file mode 100644 index 000000000..f221ef911 --- /dev/null +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/TempFileHelper.scala @@ -0,0 +1,25 @@ +package io.lenses.streamreactor.connect.aws.s3.source + +import java.io.File +import java.util.UUID + +trait TempFileHelper { + + def withFile(fileName: String)(f: File => Either[Throwable, Unit]): Either[Throwable, Unit] = { + val folderName = UUID.randomUUID().toString + val folder = new File(folderName) + try { + folder.mkdir() + folder.deleteOnExit() + val file = new File(folder, fileName) + file.deleteOnExit() + f(file) + } catch { + case e: Throwable => Left(e) + } finally { + folder.listFiles().foreach(_.delete()) + folder.delete() + () + } + } +} diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/UploadException.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/UploadException.scala new file mode 100644 index 000000000..5b59b2068 --- /dev/null +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/UploadException.scala @@ -0,0 +1,9 @@ +package io.lenses.streamreactor.connect.aws.s3.source + +import io.lenses.streamreactor.connect.cloud.common.storage.UploadError + +class UploadException(message: String, inner: Throwable) extends RuntimeException(message, inner) with UploadError { + def this(throwable: Throwable) = this(throwable.getMessage, throwable) + def this(error: UploadError) = this(error.message(), null) + override def message(): String = message +} diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala index 5ae2a61b4..3a1041211 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala @@ -1,9 +1,9 @@ package io.lenses.streamreactor.connect.aws.s3.utils import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.connect.aws.s3.auth.AwsS3ClientCreator +import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ import io.lenses.streamreactor.connect.aws.s3.config.AuthMode import io.lenses.streamreactor.connect.aws.s3.config.S3ConnectionConfig -import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ import io.lenses.streamreactor.connect.aws.s3.sink.S3SinkTask import io.lenses.streamreactor.connect.aws.s3.sink.config.S3SinkConfig import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala index f7a30be3a..a2f9467b9 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala @@ -49,7 +49,7 @@ trait RemoteFileHelper[SI <: StorageInterface[_]] { def remoteFileAsStream(bucketName: String, fileName: String): InputStream = storageInterface.getBlob(bucketName, fileName) - .leftMap((f: FileLoadError) => fail(f.exception)).merge + .leftMap((f: FileLoadError) => fail(f.message(), f.exception)).merge def remoteFileAsString(bucketName: String, fileName: String): String = streamToString(remoteFileAsStream(bucketName, fileName)) diff --git a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/HierarchicalPartitionExtractorTest.scala b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/HierarchicalPartitionExtractorTest.scala index b45cdf707..e77e60835 100644 --- a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/HierarchicalPartitionExtractorTest.scala +++ b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/HierarchicalPartitionExtractorTest.scala @@ -23,7 +23,7 @@ import org.scalatest.matchers.should.Matchers class HierarchicalPartitionExtractorTest extends AnyFlatSpecLike with Matchers with LazyLogging { - private val hpe = new HierarchicalPartitionExtractor() + private val hpe = HierarchicalPartitionExtractor "apply" should "parse a flattish path" in { val path = "topic/123/1.csv" @@ -40,4 +40,13 @@ class HierarchicalPartitionExtractorTest extends AnyFlatSpecLike with Matchers w hpe.extract(path) should be(789.some) } + "apply" should "parse a path with a single level" in { + val path = "topic/1.json" + hpe.extract(path) should be(None) + } + + "apply" should "handle left 0 zero padding for the partition" in { + val path = "/topic/000789/1.json" + hpe.extract(path) should be(789.some) + } } diff --git a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/PartitionExtractorTest.scala b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/PartitionExtractorTest.scala index ac3544393..ac4d1df83 100644 --- a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/PartitionExtractorTest.scala +++ b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/model/PartitionExtractorTest.scala @@ -22,9 +22,8 @@ import org.scalatest.matchers.should.Matchers class PartitionExtractorTest extends AnyFlatSpec with Matchers { "HierarchicalPartitionExtractor" should "extract path" in { - val hierarchicalPath = "streamReactorBackups/myTopic/1/2.json" - val partitionExtractor = new HierarchicalPartitionExtractor() - partitionExtractor.extract(hierarchicalPath) should be(Some(1)) + val hierarchicalPath = "streamReactorBackups/myTopic/1/2.json" + HierarchicalPartitionExtractor.extract(hierarchicalPath) should be(Some(1)) } } diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/formats/writer/MessageDetail.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/formats/writer/MessageDetail.scala index 2a1d9b83d..0339f9d14 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/formats/writer/MessageDetail.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/formats/writer/MessageDetail.scala @@ -28,4 +28,6 @@ case class MessageDetail( topic: Topic, partition: Int, offset: Offset, -) +) { + def epochTimestamp: Long = timestamp.map(_.toEpochMilli).getOrElse(-1L) +} diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala index 12b20703f..4bd69b6a7 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala @@ -28,6 +28,7 @@ import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation import io.lenses.streamreactor.connect.cloud.common.sink.commit.CommitPolicy import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkBucketOptions import io.lenses.streamreactor.connect.cloud.common.sink.config.PartitionField +import io.lenses.streamreactor.connect.cloud.common.sink.naming.ObjectKeyBuilder import io.lenses.streamreactor.connect.cloud.common.sink.naming.KeyNamer import io.lenses.streamreactor.connect.cloud.common.sink.seek.IndexManager import io.lenses.streamreactor.connect.cloud.common.sink.transformers.TopicsTransformers @@ -61,7 +62,7 @@ class WriterManagerCreator[MD <: FileMetadata, SC <: CloudSinkConfig] extends La case None => fatalErrorTopicNotConfigured(topicPartition).asLeft } - val keyNamerFn: TopicPartition => Either[SinkError, KeyNamer] = topicPartition => + val keyNamerBuilderFn: TopicPartition => Either[SinkError, KeyNamer] = topicPartition => bucketOptsForTopic(config, topicPartition.topic) match { case Some(bucketOptions) => bucketOptions.keyNamer.asRight case None => fatalErrorTopicNotConfigured(topicPartition).asLeft @@ -72,11 +73,11 @@ class WriterManagerCreator[MD <: FileMetadata, SC <: CloudSinkConfig] extends La bucketOptsForTopic(config, topicPartition.topic) match { case Some(bucketOptions) => for { - keyNamer <- keyNamerFn(topicPartition) - stagingFilename <- keyNamer.stagingFile(bucketOptions.localStagingArea.dir, - bucketOptions.bucketAndPrefix, - topicPartition, - partitionValues, + keyNamer <- keyNamerBuilderFn(topicPartition) + stagingFilename <- keyNamer.staging(bucketOptions.localStagingArea.dir, + bucketOptions.bucketAndPrefix, + topicPartition, + partitionValues, ) } yield stagingFilename case None => fatalErrorTopicNotConfigured(topicPartition).asLeft @@ -85,18 +86,23 @@ class WriterManagerCreator[MD <: FileMetadata, SC <: CloudSinkConfig] extends La val finalFilenameFn: ( TopicPartition, immutable.Map[PartitionField, String], - Offset, - ) => Either[SinkError, CloudLocation] = (topicPartition, partitionValues, offset) => - bucketOptsForTopic(config, topicPartition.topic) match { - case Some(bucketOptions) => - for { - keyNamer <- keyNamerFn(topicPartition) - stagingFilename <- keyNamer.finalFilename(bucketOptions.bucketAndPrefix, - topicPartition.withOffset(offset), - partitionValues, - ) - } yield stagingFilename - case None => fatalErrorTopicNotConfigured(topicPartition).asLeft + ) => ObjectKeyBuilder = (topicPartition, partitionValues) => + (offset: Offset, earliestRecordTimestamp: Long, latestRecordTimestamp: Long) => { + bucketOptsForTopic(config, topicPartition.topic) match { + case Some(bucketOptions) => + for { + keyNamer <- keyNamerBuilderFn(topicPartition) + finalFilename <- keyNamer.value( + bucketOptions.bucketAndPrefix, + topicPartition.withOffset(offset), + partitionValues, + earliestRecordTimestamp, + latestRecordTimestamp, + ) + } yield finalFilename + case None => fatalErrorTopicNotConfigured(topicPartition).asLeft + } + } val formatWriterFn: (TopicPartition, File) => Either[SinkError, FormatWriter] = @@ -119,7 +125,7 @@ class WriterManagerCreator[MD <: FileMetadata, SC <: CloudSinkConfig] extends La new WriterManager( commitPolicyFn, bucketAndPrefixFn, - keyNamerFn, + keyNamerBuilderFn, stagingFilenameFn, finalFilenameFn, formatWriterFn, diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala index 46ff7fb06..e2e187c81 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala @@ -26,6 +26,7 @@ import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnu import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushSize import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.PartitionIncludeKeys +import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.KeyNamerVersion import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocationValidator import io.lenses.streamreactor.connect.cloud.common.sink.commit.CloudCommitPolicy @@ -34,11 +35,7 @@ import io.lenses.streamreactor.connect.cloud.common.sink.commit.Count import io.lenses.streamreactor.connect.cloud.common.sink.config.kcqlprops.CloudSinkProps import io.lenses.streamreactor.connect.cloud.common.sink.config.kcqlprops.SinkPropsSchema import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingService -import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.KeyNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.FileExtensionNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.TopicPartitionOffsetFileNamer +import io.lenses.streamreactor.connect.cloud.common.sink.naming._ import org.apache.kafka.common.config.ConfigException object CloudSinkBucketOptions extends LazyLogging { @@ -77,26 +74,17 @@ object CloudSinkBucketOptions extends LazyLogging { sinkProps = CloudSinkProps.fromKcql(kcql) partitionSelection = PartitionSelection(kcql, sinkProps) paddingService <- PaddingService.fromConfig(config, sinkProps) + storageSettings <- DataStorageSettings.from(sinkProps) + keyNameVersion = KeyNamerVersion(sinkProps, KeyNamerVersion.V1) + fileNamer <- getFileNamer(keyNameVersion, storageSettings, fileExtension, partitionSelection, paddingService) + _ = println("File Namer: " + fileNamer) + keyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) + stagingArea <- config.getLocalStagingArea()(connectorTaskId) + target <- CloudLocation.splitAndValidate(kcql.getTarget) - fileNamer = if (partitionSelection.isCustom) { - new TopicPartitionOffsetFileNamer( - paddingService.padderFor("partition"), - paddingService.padderFor("offset"), - fileExtension, - ) - } else { - new OffsetFileNamer( - paddingService.padderFor("offset"), - fileExtension, - ) - } - keyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) - stagingArea <- config.getLocalStagingArea()(connectorTaskId) - target <- CloudLocation.splitAndValidate(kcql.getTarget) - storageSettings <- DataStorageSettings.from(sinkProps) - _ <- validateEnvelopeAndFormat(formatSelection, storageSettings) - commitPolicy = config.commitPolicy(kcql) - _ <- validateCommitPolicyForBytesFormat(formatSelection, commitPolicy) + _ <- validateEnvelopeAndFormat(formatSelection, storageSettings) + commitPolicy = config.commitPolicy(kcql) + _ <- validateCommitPolicyForBytesFormat(formatSelection, commitPolicy) } yield { CloudSinkBucketOptions( Option(kcql.getSource).filterNot(Set("*", "`*`").contains(_)), @@ -110,6 +98,69 @@ object CloudSinkBucketOptions extends LazyLogging { } }.toSeq.traverse(identity) + /** + * When non-envelope storage is used the fast seeks cannot not be achieved since the data stored does + * not guarantee the timestamp is present. Therefore, we use the V0 key namer. + * + * @param keyNameVersion + * @param storageSettings + * @param fileExtension + * @param partitionSelection + * @param paddingService + * @return + */ + private def getFileNamer( + keyNameVersion: KeyNamerVersion, + storageSettings: DataStorageSettings, + fileExtension: String, + partitionSelection: PartitionSelection, + paddingService: PaddingService, + ): Either[Throwable, FileNamer] = + if (!storageSettings.envelope) { + if (partitionSelection.isCustom) { + new TopicPartitionOffsetFileNamerV0( + paddingService.padderFor("partition"), + paddingService.padderFor("offset"), + fileExtension, + ).asRight + } else { + new OffsetFileNamerV0( + paddingService.padderFor("offset"), + fileExtension, + ).asRight + } + } else { + keyNameVersion match { + case KeyNamerVersion.V0 => + if (partitionSelection.isCustom) { + new TopicPartitionOffsetFileNamerV0( + paddingService.padderFor("partition"), + paddingService.padderFor("offset"), + fileExtension, + ).asRight + } else { + new OffsetFileNamerV0( + paddingService.padderFor("offset"), + fileExtension, + ).asRight + + } + case KeyNamerVersion.V1 => + if (partitionSelection.isCustom) { + new TopicPartitionOffsetFileNamerV1( + paddingService.padderFor("partition"), + paddingService.padderFor("offset"), + fileExtension, + ).asRight + } else { + new OffsetFileNamerV1( + paddingService.padderFor("offset"), + fileExtension, + ).asRight + } + } + } + private def validateWithFlush(kcql: Kcql): Either[Throwable, Unit] = { val sql = kcql.getQuery.toUpperCase() if ( diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamer.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamer.scala index 50b646744..6a1a027f6 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamer.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamer.scala @@ -78,7 +78,7 @@ class CloudKeyNamer( private def prefix(bucketAndPrefix: CloudLocation): String = bucketAndPrefix.prefix.map(addTrailingSlash).getOrElse(DefaultPrefix) - override def stagingFile( + override def staging( stagingDirectory: File, bucketAndPrefix: CloudLocation, topicPartition: TopicPartition, @@ -112,14 +112,16 @@ class CloudKeyNamer( private def partitionValuePrefix(partition: PartitionField): String = if (partitionSelection.partitionDisplay == KeysAndValues) s"${partition.name()}=" else "" - override def finalFilename( - bucketAndPrefix: CloudLocation, - topicPartitionOffset: TopicPartitionOffset, - partitionValues: Map[PartitionField, String], + override def value( + bucketAndPrefix: CloudLocation, + topicPartitionOffset: TopicPartitionOffset, + partitionValues: Map[PartitionField, String], + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, ): Either[FatalCloudSinkError, CloudLocation] = Try( bucketAndPrefix.withPath( - s"${prefix(bucketAndPrefix)}${buildPartitionPrefix(partitionValues)}/${fileNamer.fileName(topicPartitionOffset)}", + s"${prefix(bucketAndPrefix)}${buildPartitionPrefix(partitionValues)}/${fileNamer.fileName(topicPartitionOffset, earliestRecordTimestamp, latestRecordTimestamp)}", ), ).toEither.left.map(ex => FatalCloudSinkError(ex.getMessage, topicPartitionOffset.toTopicPartition)) diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala index 4744cdb76..b20208599 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala @@ -20,28 +20,62 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingS trait FileNamer { def fileName( - topicPartitionOffset: TopicPartitionOffset, + topicPartitionOffset: TopicPartitionOffset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, ): String } -class OffsetFileNamer( +class OffsetFileNamerV0( offsetPaddingStrategy: PaddingStrategy, extension: String, ) extends FileNamer { def fileName( - topicPartitionOffset: TopicPartitionOffset, + topicPartitionOffset: TopicPartitionOffset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, ): String = s"${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}.$extension" } -class TopicPartitionOffsetFileNamer( + +class OffsetFileNamerV1( + offsetPaddingStrategy: PaddingStrategy, + extension: String, +) extends FileNamer { + def fileName( + topicPartitionOffset: TopicPartitionOffset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, + ): String = + s"${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}_${earliestRecordTimestamp}_$latestRecordTimestamp.$extension" +} +class TopicPartitionOffsetFileNamerV0( partitionPaddingStrategy: PaddingStrategy, offsetPaddingStrategy: PaddingStrategy, extension: String, ) extends FileNamer { def fileName( - topicPartitionOffset: TopicPartitionOffset, + topicPartitionOffset: TopicPartitionOffset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, ): String = s"${topicPartitionOffset.topic.value}(${partitionPaddingStrategy.padString( topicPartitionOffset.partition.toString, )}_${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}).$extension" } + +class TopicPartitionOffsetFileNamerV1( + partitionPaddingStrategy: PaddingStrategy, + offsetPaddingStrategy: PaddingStrategy, + extension: String, +) extends FileNamer { + def fileName( + topicPartitionOffset: TopicPartitionOffset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, + ): String = + s"${topicPartitionOffset.topic.value}(${partitionPaddingStrategy.padString( + topicPartitionOffset.partition.toString, + )}_${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}_${earliestRecordTimestamp}_$latestRecordTimestamp).$extension" + +} diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/KeyNamer.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/KeyNamer.scala index c40ff3f97..bf8bd05f9 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/KeyNamer.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/KeyNamer.scala @@ -30,17 +30,19 @@ trait KeyNamer { def partitionSelection: PartitionSelection - def stagingFile( + def staging( stagingDirectory: File, bucketAndPrefix: CloudLocation, topicPartition: TopicPartition, partitionValues: Map[PartitionField, String], ): Either[FatalCloudSinkError, File] - def finalFilename( - bucketAndPrefix: CloudLocation, - topicPartitionOffset: TopicPartitionOffset, - partitionValues: Map[PartitionField, String], + def value( + bucketAndPrefix: CloudLocation, + topicPartitionOffset: TopicPartitionOffset, + partitionValues: Map[PartitionField, String], + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, ): Either[FatalCloudSinkError, CloudLocation] def processPartitionValues( diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/ObjectKeyBuilder.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/ObjectKeyBuilder.scala new file mode 100644 index 000000000..8d6cee644 --- /dev/null +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/ObjectKeyBuilder.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.cloud.common.sink.naming + +import io.lenses.streamreactor.connect.cloud.common.model.Offset +import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation +import io.lenses.streamreactor.connect.cloud.common.sink.SinkError + +/** + * Creates the object key for the cloud storage + */ +trait ObjectKeyBuilder { + + /** + * Builds the key + * + * @param offset the offset of the last record + * @param earliestRecordTimestamp the earliest record timestamp + * @param latestRecordTimestamp the latest record timestamp + * @return the final file name + */ + def build( + offset: Offset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, + ): Either[SinkError, CloudLocation] +} diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriteState.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriteState.scala index c989a7112..6e09c037c 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriteState.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriteState.scala @@ -23,7 +23,7 @@ import org.apache.kafka.connect.data.Schema import java.io.File sealed abstract class WriteState(commitState: CommitState) { - def getCommitState = commitState + def getCommitState: CommitState = commitState } case class NoWriter(commitState: CommitState) extends WriteState(commitState) with LazyLogging { @@ -32,24 +32,28 @@ case class NoWriter(commitState: CommitState) extends WriteState(commitState) wi formatWriter: FormatWriter, file: File, uncommittedOffset: Offset, + recordTimestamp: Long, ): Writing = { logger.debug("state transition: NoWriter => Writing") - Writing(commitState, formatWriter, file, uncommittedOffset) + Writing(commitState, formatWriter, file, uncommittedOffset, recordTimestamp, recordTimestamp) } } case class Writing( - commitState: CommitState, - formatWriter: FormatWriter, - file: File, - uncommittedOffset: Offset, + commitState: CommitState, + formatWriter: FormatWriter, + file: File, + uncommittedOffset: Offset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, ) extends WriteState(commitState) with LazyLogging { - //TODO: it's not clear why we are only keeping track of one schema (VALUE) and not key/and headers - def updateOffset(o: Offset, schema: Option[Schema]): WriteState = { - logger.debug(s"state update: Uncommitted offset update ${uncommittedOffset} => $o") + def update(o: Offset, recordTimestamp: Long, schema: Option[Schema]): WriteState = { + logger.debug( + s"state update: Uncommitted offset update $uncommittedOffset => $o, earliest record timestamp $earliestRecordTimestamp => $recordTimestamp", + ) copy( uncommittedOffset = o, commitState = commitState @@ -57,23 +61,27 @@ case class Writing( schema, formatWriter.getPointer, ), + earliestRecordTimestamp = math.min(earliestRecordTimestamp, recordTimestamp), + latestRecordTimestamp = math.max(latestRecordTimestamp, recordTimestamp), ) } - def toUploading(): Uploading = { + def toUploading: Uploading = { logger.debug("state transition: Writing => Uploading") - Uploading(commitState.reset(), file, uncommittedOffset) + Uploading(commitState.reset(), file, uncommittedOffset, earliestRecordTimestamp, latestRecordTimestamp) } } case class Uploading( - commitState: CommitState, - file: File, - uncommittedOffset: Offset, + commitState: CommitState, + file: File, + uncommittedOffset: Offset, + earliestRecordTimestamp: Long, + latestRecordTimestamp: Long, ) extends WriteState(commitState) with LazyLogging { - def toNoWriter(): NoWriter = { + def toNoWriter: NoWriter = { logger.debug("state transition: Uploading => NoWriter") NoWriter(commitState.withCommittedOffset(uncommittedOffset)) } diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/Writer.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/Writer.scala index 124bb4a05..972310fc0 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/Writer.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/Writer.scala @@ -23,12 +23,12 @@ import io.lenses.streamreactor.connect.cloud.common.formats.writer.MessageDetail import io.lenses.streamreactor.connect.cloud.common.model.Offset import io.lenses.streamreactor.connect.cloud.common.model.TopicPartition import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile -import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation import io.lenses.streamreactor.connect.cloud.common.sink.FatalCloudSinkError import io.lenses.streamreactor.connect.cloud.common.sink.NonFatalCloudSinkError import io.lenses.streamreactor.connect.cloud.common.sink.SinkError import io.lenses.streamreactor.connect.cloud.common.sink.commit.CloudCommitContext import io.lenses.streamreactor.connect.cloud.common.sink.commit.CommitPolicy +import io.lenses.streamreactor.connect.cloud.common.sink.naming.ObjectKeyBuilder import io.lenses.streamreactor.connect.cloud.common.sink.seek.IndexManager import io.lenses.streamreactor.connect.cloud.common.storage._ import org.apache.kafka.connect.data.Schema @@ -42,7 +42,7 @@ class Writer[SM <: FileMetadata]( commitPolicy: CommitPolicy, indexManager: IndexManager[SM], stagingFilenameFn: () => Either[SinkError, File], - finalFilenameFn: Offset => Either[SinkError, CloudLocation], + objectKeyBuilder: ObjectKeyBuilder, formatWriterFn: File => Either[SinkError, FormatWriter], lastSeekedOffset: Option[Offset], )( @@ -61,26 +61,31 @@ class Writer[SM <: FileMetadata]( logger.error(err.getMessage) NonFatalCloudSinkError(err.getMessage, err.some).asLeft case Right(_) => - writeState = writingState.updateOffset(messageDetail.offset, messageDetail.value.schema()) + writeState = + writingState.update(messageDetail.offset, messageDetail.epochTimestamp, messageDetail.value.schema()) ().asRight } writeState match { - case writingWS @ Writing(_, _, _, _) => + case writingWS: Writing => innerMessageWrite(writingWS) case noWriter @ NoWriter(_) => val writingStateEither = for { file <- stagingFilenameFn() formatWriter <- formatWriterFn(file) - writingState <- noWriter.toWriting(formatWriter, file, messageDetail.offset).asRight + writingState <- noWriter.toWriting(formatWriter, + file, + messageDetail.offset, + messageDetail.epochTimestamp, + ).asRight } yield writingState writingStateEither.flatMap { writingState => writeState = writingState innerMessageWrite(writingState) } - case Uploading(_, _, _) => + case _: Uploading => // before we write we need to retry the upload NonFatalCloudSinkError("Attempting Write in Uploading State").asLeft } @@ -89,13 +94,13 @@ class Writer[SM <: FileMetadata]( def commit: Either[SinkError, Unit] = { writeState match { - case writingState @ Writing(_, formatWriter, _, _) => - formatWriter.complete() match { + case writingState: Writing => + writingState.formatWriter.complete() match { case Left(ex) => return ex.asLeft case Right(_) => } - writeState = writingState.toUploading() - case Uploading(_, _, _) => + writeState = writingState.toUploading + case _: Uploading => // your turn will come, nothing to do here because we're already in the correct state case NoWriter(_) => // nothing to commit, get out of here @@ -103,16 +108,21 @@ class Writer[SM <: FileMetadata]( } writeState match { - case uploadState @ Uploading(commitState, file, uncommittedOffset) => + case uploadState @ Uploading(commitState, + file, + uncommittedOffset, + earliestRecordTimestamp, + latestRecordTimestamp, + ) => for { - finalFileName <- finalFilenameFn(uncommittedOffset) - path <- finalFileName.path.toRight(NonFatalCloudSinkError("No path exists within cloud location")) + key <- objectKeyBuilder.build(uncommittedOffset, earliestRecordTimestamp, latestRecordTimestamp) + path <- key.path.toRight(NonFatalCloudSinkError("No path exists within cloud location")) indexFileName <- indexManager.write( - finalFileName.bucket, + key.bucket, path, topicPartition.withOffset(uncommittedOffset), ) - _ <- storageInterface.uploadFile(UploadableFile(file), finalFileName.bucket, path) + _ <- storageInterface.uploadFile(UploadableFile(file), key.bucket, path) .recover { case _: NonExistingFileError => () case _: ZeroByteFileError => () @@ -120,10 +130,10 @@ class Writer[SM <: FileMetadata]( .leftMap { case UploadFailedError(exception, _) => NonFatalCloudSinkError(exception.getMessage, exception.some) } - _ <- indexManager.clean(finalFileName.bucket, indexFileName, topicPartition) + _ <- indexManager.clean(key.bucket, indexFileName, topicPartition) stateReset <- Try { logger.debug(s"[{}] Writer.resetState: Resetting state $writeState", connectorTaskId.show) - writeState = uploadState.toNoWriter() + writeState = uploadState.toNoWriter file.delete() logger.debug(s"[{}] Writer.resetState: New state $writeState", connectorTaskId.show) }.toEither.leftMap(e => FatalCloudSinkError(e.getMessage, commitState.topicPartition)) @@ -137,11 +147,11 @@ class Writer[SM <: FileMetadata]( def close(): Unit = writeState = writeState match { case state @ NoWriter(_) => state - case Writing(commitState, formatWriter, file, _) => + case Writing(commitState, formatWriter, file, _, _, _) => Try(formatWriter.close()) Try(file.delete()) NoWriter(commitState.reset()) - case Uploading(commitState, file, _) => + case Uploading(commitState, file, _, _, _) => Try(file.delete()) NoWriter(commitState.reset()) } @@ -150,7 +160,7 @@ class Writer[SM <: FileMetadata]( def shouldFlush: Boolean = writeState match { - case Writing(commitState, _, file, uncommittedOffset) => commitPolicy.shouldFlush( + case Writing(commitState, _, file, uncommittedOffset, _, _) => commitPolicy.shouldFlush( CloudCommitContext( topicPartition.withOffset(uncommittedOffset), commitState.recordCount, @@ -160,8 +170,8 @@ class Writer[SM <: FileMetadata]( file.getName, ), ) - case NoWriter(_) => false - case Uploading(_, _, _) => false + case NoWriter(_) => false + case _: Uploading => false } /** @@ -186,7 +196,7 @@ class Writer[SM <: FileMetadata]( def logSkipOutcome(currentOffset: Offset, latestOffset: Option[Offset], skipRecord: Boolean): Unit = { val skipping = if (skipRecord) "SKIPPING" else "PROCESSING" logger.debug( - s"[${connectorTaskId.show}] lastSeeked=${lastSeekedOffset} current=${currentOffset.value} latest=$latestOffset - $skipping", + s"[${connectorTaskId.show}] lastSeeked=$lastSeekedOffset current=${currentOffset.value} latest=$latestOffset - $skipping", ) } @@ -204,17 +214,17 @@ class Writer[SM <: FileMetadata]( writeState match { case NoWriter(commitState) => shouldSkipInternal(currentOffset, commitState.committedOffset) - case Uploading(commitState, _, uncommittedOffset) => + case Uploading(commitState, _, uncommittedOffset, _, _) => shouldSkipInternal(currentOffset, Option(largestOffset(commitState.committedOffset, uncommittedOffset))) - case Writing(commitState, _, _, uncommittedOffset) => + case Writing(commitState, _, _, uncommittedOffset, _, _) => shouldSkipInternal(currentOffset, Option(largestOffset(commitState.committedOffset, uncommittedOffset))) } } - def hasPendingUpload(): Boolean = + def hasPendingUpload: Boolean = writeState match { - case Uploading(_, _, _) => true - case _ => false + case _: Uploading => true + case _ => false } def shouldRollover(schema: Schema): Boolean = @@ -226,8 +236,8 @@ class Writer[SM <: FileMetadata]( private def rolloverOnSchemaChange: Boolean = writeState match { - case Writing(_, formatWriter, _, _) => formatWriter.rolloverFileOnSchemaChange() - case _ => false + case w: Writing => w.formatWriter.rolloverFileOnSchemaChange() + case _ => false } } diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriterManager.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriterManager.scala index 9889b34a6..89e9aeec7 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriterManager.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/writer/WriterManager.scala @@ -27,6 +27,7 @@ import io.lenses.streamreactor.connect.cloud.common.model.TopicPartitionOffset import io.lenses.streamreactor.connect.cloud.common.sink import io.lenses.streamreactor.connect.cloud.common.sink.commit.CommitPolicy import io.lenses.streamreactor.connect.cloud.common.sink.config.PartitionField +import io.lenses.streamreactor.connect.cloud.common.sink.naming.ObjectKeyBuilder import io.lenses.streamreactor.connect.cloud.common.sink.naming.KeyNamer import io.lenses.streamreactor.connect.cloud.common.sink.seek.IndexManager import io.lenses.streamreactor.connect.cloud.common.sink.BatchCloudSinkError @@ -58,7 +59,7 @@ class WriterManager[SM <: FileMetadata]( bucketAndPrefixFn: TopicPartition => Either[SinkError, CloudLocation], keyNamerFn: TopicPartition => Either[SinkError, KeyNamer], stagingFilenameFn: (TopicPartition, Map[PartitionField, String]) => Either[SinkError, File], - finalFilenameFn: (TopicPartition, Map[PartitionField, String], Offset) => Either[SinkError, CloudLocation], + objKeyBuilderFn: (TopicPartition, Map[PartitionField, String]) => ObjectKeyBuilder, formatWriterFn: (TopicPartition, File) => Either[SinkError, FormatWriter], indexManager: IndexManager[SM], transformerF: MessageDetail => Either[RuntimeException, MessageDetail], @@ -86,7 +87,7 @@ class WriterManager[SM <: FileMetadata]( def recommitPending(): Either[SinkError, Unit] = { logger.debug(s"[{}] Retry Pending", connectorTaskId.show) - val result = commitWritersWithFilter(_._2.hasPendingUpload()) + val result = commitWritersWithFilter(_._2.hasPendingUpload) logger.debug(s"[{}] Retry Pending Complete", connectorTaskId.show) result } @@ -245,7 +246,7 @@ class WriterManager[SM <: FileMetadata]( commitPolicy, indexManager, () => stagingFilenameFn(topicPartition, partitionValues), - finalFilenameFn.curried(topicPartition)(partitionValues), + objKeyBuilderFn(topicPartition, partitionValues), formatWriterFn.curried(topicPartition), seekedOffsets.get(topicPartition), ) diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/PartitionExtractor.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/PartitionExtractor.scala index a3217a66a..0b9e2e791 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/PartitionExtractor.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/PartitionExtractor.scala @@ -16,7 +16,6 @@ package io.lenses.streamreactor.connect.cloud.common.source.config import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.connect.cloud.common.config.Format /** * For a source, can extract the original partition so that the message can be returned to the original partition. @@ -35,7 +34,7 @@ object PartitionExtractor extends LazyLogging { logger.info("No regex provided for regex mode, defaulting to NoOp mode") Option.empty[PartitionExtractor] }(rex => Some(new RegexPartitionExtractor(rex))) - case "hierarchical" => Some(new HierarchicalPartitionExtractor()) + case "hierarchical" => Some(HierarchicalPartitionExtractor) case _ => Option.empty[PartitionExtractor] } } @@ -49,7 +48,18 @@ class RegexPartitionExtractor( Option(rc.findAllIn(remotePath).group(1)).flatMap(_.toIntOption) } -class HierarchicalPartitionExtractor() - extends RegexPartitionExtractor( - s"(?i)^(?:.*)\\/([0-9]*)\\/(?:[0-9]*)[.](?:${Format.values.map(_.entryName).mkString("|")})$$", - ) {} +object HierarchicalPartitionExtractor extends PartitionExtractor { + override def extract(input: String): Option[Int] = { + // Find the index of the last '/' + val lastSlashIndex = input.lastIndexOf('/') + + // Find the index of the second to last '/' + val secondLastSlashIndex = input.substring(0, lastSlashIndex).lastIndexOf('/') + if (secondLastSlashIndex == -1) + None + else { + val substring = input.substring(secondLastSlashIndex + 1, lastSlashIndex) + Some(substring.toInt) + } + } +} diff --git a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala index 551304918..ea7a723cb 100644 --- a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala +++ b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala @@ -49,9 +49,6 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with private val paddingStrategy: PaddingStrategy = LeftPadPaddingStrategy(3, '0') private val partitionSelection: PartitionSelection = defaultPartitionSelection(Values) - private val fileNamer: FileNamer = - new OffsetFileNamer(paddingStrategy, JsonFormatSelection.extension) - private val bucketAndPrefix = CloudLocation("my-bucket", Some("prefix")) private val bucketNoPrefix = CloudLocation("my-bucket", none) private val TopicName = "my-topic" @@ -68,11 +65,13 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with private val paddingService = mock[PaddingService] when(paddingService.padderFor(anyString)).thenReturn(paddingStrategy) - private val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) - test("the partition values do not replace / or \\ characters") { val partitionSelection = PartitionSelection(isCustom = false, List(HeaderPartitionField(PartitionNamePath("h"))), Values) + + val fileNamer: FileNamer = + new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + val keyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val either: Either[SinkError, Map[PartitionField, String]] = keyNamer.processPartitionValues( MessageDetail( @@ -93,8 +92,12 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with test("stagingFile should generate the correct staging file path with no prefix") { val stagingDirectory = Files.createTempDirectory("myTempDir").toFile + val fileNamer: FileNamer = + new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) + val result = - s3KeyNamer.stagingFile(stagingDirectory, bucketNoPrefix, topicPartition.toTopicPartition, partitionValues) + s3KeyNamer.staging(stagingDirectory, bucketNoPrefix, topicPartition.toTopicPartition, partitionValues) val fullPath = result.value.getPath.replace(stagingDirectory.toString, "") val (path, uuid) = fullPath.splitAt(fullPath.length - 36) @@ -102,11 +105,14 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with UUID.fromString(uuid) } - test("stagingFile should generate the correct staging file path") { + test("should generate the correct staging file path") { val stagingDirectory = Files.createTempDirectory("myTempDir").toFile + val fileNamer: FileNamer = + new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val result = - s3KeyNamer.stagingFile(stagingDirectory, bucketAndPrefix, topicPartition.toTopicPartition, partitionValues) + s3KeyNamer.staging(stagingDirectory, bucketAndPrefix, topicPartition.toTopicPartition, partitionValues) val fullPath = result.value.getPath.replace(stagingDirectory.toString, "") val (path, uuid) = fullPath.splitAt(fullPath.length - 36) @@ -114,18 +120,54 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with UUID.fromString(uuid) } - test("finalFilename should write to the root of the bucket with no prefix") { + test("should write to the root of the bucket with no prefix") { + val fileNamer: FileNamer = + new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) - val result = s3KeyNamer.finalFilename(bucketNoPrefix, topicPartition, partitionValues) + val result = s3KeyNamer.value(bucketNoPrefix, topicPartition, partitionValues, 0L, 10L) result.value.path.value shouldEqual s"$TopicName/00$Partition/0$Offset.json" } - test("finalFilename should generate the correct final S3 location") { + test("should generate the correct final S3 location for old format") { + val fileNamer: FileNamer = + new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) - val result = s3KeyNamer.finalFilename(bucketAndPrefix, topicPartition, partitionValues) + val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 0L, 10L) result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/0$Offset.json" } + test("should generate the correct final S3 location for v1 OffsetFileNamerV1 format") { + val fileNamer: FileNamer = + new OffsetFileNamerV1(paddingStrategy, JsonFormatSelection.extension) + val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) + + val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 101L, 9999L) + + result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/0${Offset}_101_9999.json" + } + + test("should generate the correct final S3 location for TopicPartitionOffsetFileNamerV0 format") { + val fileNamer: FileNamer = + new TopicPartitionOffsetFileNamerV0(paddingStrategy, paddingStrategy, JsonFormatSelection.extension) + val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) + + val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 101L, 1000L) + + result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/${topicPartition.topic.value}(009_0$Offset).json" + } + + test("should generate the correct final S3 location for TopicPartitionOffsetFileNamerV1 format") { + val fileNamer: FileNamer = + new TopicPartitionOffsetFileNamerV1(paddingStrategy, paddingStrategy, JsonFormatSelection.extension) + val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) + + val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 101L, 1000L) + + result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/${topicPartition.topic.value}(009_0${Offset}_101_1000).json" + } + } diff --git a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala index 5f57229e7..1c386610b 100644 --- a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala +++ b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala @@ -25,17 +25,27 @@ class FileNamerTest extends AnyFunSuite with Matchers { private val paddingStrategy = LeftPad.toPaddingStrategy(5, '0') private val topicPartitionOffset = Topic("topic").withPartition(9).atOffset(81) - test("OffsetFileNamer.fileName should generate the correct file name") { + test("OffsetFileNamerV0.fileName should generate the correct file name") { - val result = new OffsetFileNamer(paddingStrategy, extension).fileName(topicPartitionOffset) + val result = new OffsetFileNamerV0(paddingStrategy, extension).fileName(topicPartitionOffset, 0L, 0L) result shouldEqual "00081.avro" } + test("OffsetFileNamerV1.fileName should generate the correct file name") { + + val result = new OffsetFileNamerV1(paddingStrategy, extension).fileName(topicPartitionOffset, 1L, 9L) + + result shouldEqual "00081_1_9.avro" + } + test("TopicPartitionOffsetFileNamer.fileName should generate the correct file name") { val result = - new TopicPartitionOffsetFileNamer(paddingStrategy, paddingStrategy, extension).fileName(topicPartitionOffset) + new TopicPartitionOffsetFileNamerV0(paddingStrategy, paddingStrategy, extension).fileName(topicPartitionOffset, + 0L, + 0L, + ) result shouldEqual "topic(00009_00081).avro" } diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala index 026ef3802..117faf9e1 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala @@ -8,7 +8,9 @@ import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushSize +import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.KeyNameFormatVersion import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.PartitionIncludeKeys +import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.StoreEnvelope import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig import io.lenses.streamreactor.connect.cloud.common.formats.AvroFormatReader import io.lenses.streamreactor.connect.cloud.common.formats.reader.ParquetFormatReader @@ -17,6 +19,7 @@ import io.lenses.streamreactor.connect.cloud.common.storage.FileMetadata import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData._ import org.apache.avro.generic.GenericData +import org.apache.avro.generic.GenericRecord import org.apache.avro.util.Utf8 import org.apache.commons.io.FileUtils import org.apache.commons.io.IOUtils @@ -86,8 +89,8 @@ abstract class CoreSinkTaskTestCases[ schema, user, k.toLong, - null, - null, + k.toLong, + TimestampType.CREATE_TIME, createHeaders(("headerPartitionKey", (k % 2).toString)), ) } @@ -98,7 +101,7 @@ abstract class CoreSinkTaskTestCases[ } private def toSinkRecord(user: Struct, k: Int, topicName: String = TopicName) = - new SinkRecord(topicName, 1, null, null, schema, user, k.toLong) + new SinkRecord(topicName, 1, null, null, schema, user, k.toLong, k.toLong, TimestampType.CREATE_TIME) private val keySchema = SchemaBuilder.struct() .field("phonePrefix", SchemaBuilder.string().required().build()) @@ -363,7 +366,7 @@ abstract class CoreSinkTaskTestCases[ } - unitUnderTest should "write to parquet format" in { + unitUnderTest should "write to parquet format not using the envelope data storage" in { val props = (defaultProps + ( s"$prefix.kcql" -> s"""insert into $BucketName:$PrefixName select * from $TopicName STOREAS PARQUET PROPERTIES('${FlushCount.entryName}'=1)""", @@ -385,6 +388,49 @@ abstract class CoreSinkTaskTestCases[ } + unitUnderTest should "write to parquet format using the envelope data storage" in { + + val props = (defaultProps + ( + s"$prefix.kcql" -> s"""insert into $BucketName:$PrefixName select * from $TopicName STOREAS PARQUET PROPERTIES('${FlushCount.entryName}'=1, '${StoreEnvelope.entryName}'= true)""", + )).asJava + val task = createTask(context, props) + + task.open(Seq(new TopicPartition(TopicName, 1)).asJava) + task.put(records.asJava) + task.close(Seq(new TopicPartition(TopicName, 1)).asJava) + task.stop() + + listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) + + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.parquet") + + val genericRecords = parquetFormatReader.read(bytes) + genericRecords.size should be(1) + checkRecord(genericRecords.head.get("value").asInstanceOf[GenericRecord], "sam", "mr", 100.43) + } + + unitUnderTest should "write to parquet format using V0 key format" in { + + val props = (defaultProps + ( + s"$prefix.kcql" -> s"""insert into $BucketName:$PrefixName select * from $TopicName STOREAS PARQUET PROPERTIES('${FlushCount.entryName}'=1, '${KeyNameFormatVersion.entryName}'=0)""", + )).asJava + val task = createTask(context, props) + + task.open(Seq(new TopicPartition(TopicName, 1)).asJava) + task.put(records.asJava) + task.close(Seq(new TopicPartition(TopicName, 1)).asJava) + task.stop() + + listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) + + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet") + + val genericRecords = parquetFormatReader.read(bytes) + genericRecords.size should be(1) + checkRecord(genericRecords.head, "sam", "mr", 100.43) + + } + unitUnderTest should "write to avro format" in { val task = createSinkTask() @@ -409,6 +455,53 @@ abstract class CoreSinkTaskTestCases[ } + unitUnderTest should "write to avro format using the envelope data storage" in { + + val task = createSinkTask() + + val props = ( + defaultProps + (s"$prefix.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS `AVRO` PROPERTIES('${FlushCount.entryName}'=1, '${StoreEnvelope.entryName}'= true)") + ).asJava + + task.start(props) + task.open(Seq(new TopicPartition(TopicName, 1)).asJava) + task.put(records.asJava) + task.close(Seq(new TopicPartition(TopicName, 1)).asJava) + task.stop() + + listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) + + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.avro") + + val genericRecords = avroFormatReader.read(bytes) + genericRecords.size should be(1) + checkRecord(genericRecords.head.get("value").asInstanceOf[GenericRecord], "sam", "mr", 100.43) + + } + + unitUnderTest should "write to avro format using V0 key format and envelope data storage" in { + + val task = createSinkTask() + + val props = ( + defaultProps + (s"$prefix.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS `AVRO` PROPERTIES('${FlushCount.entryName}'=1, '${KeyNameFormatVersion.entryName}'=0, '${StoreEnvelope.entryName}'= true)") + ).asJava + + task.start(props) + task.open(Seq(new TopicPartition(TopicName, 1)).asJava) + task.put(records.asJava) + task.close(Seq(new TopicPartition(TopicName, 1)).asJava) + task.stop() + + listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) + + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.avro") + + val genericRecords = avroFormatReader.read(bytes) + genericRecords.size should be(1) + checkRecord(genericRecords.head.get("value").asInstanceOf[GenericData.Record], "sam", "mr", 100.43) + + } unitUnderTest should "error when trying to write AVRO to text format" in { val task = createSinkTask() diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala index f7a30be3a..a2f9467b9 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala @@ -49,7 +49,7 @@ trait RemoteFileHelper[SI <: StorageInterface[_]] { def remoteFileAsStream(bucketName: String, fileName: String): InputStream = storageInterface.getBlob(bucketName, fileName) - .leftMap((f: FileLoadError) => fail(f.exception)).merge + .leftMap((f: FileLoadError) => fail(f.message(), f.exception)).merge def remoteFileAsString(bucketName: String, fileName: String): String = streamToString(remoteFileAsStream(bucketName, fileName)) diff --git a/kafka-connect-mongodb/src/test/scala/io/lenses/streamreactor/connect/mongodb/converters/SinkRecordConverterTest.scala b/kafka-connect-mongodb/src/test/scala/io/lenses/streamreactor/connect/mongodb/converters/SinkRecordConverterTest.scala index 9af863ea0..7939be475 100644 --- a/kafka-connect-mongodb/src/test/scala/io/lenses/streamreactor/connect/mongodb/converters/SinkRecordConverterTest.scala +++ b/kafka-connect-mongodb/src/test/scala/io/lenses/streamreactor/connect/mongodb/converters/SinkRecordConverterTest.scala @@ -111,9 +111,9 @@ class SinkRecordConverterTest extends AnyWordSpec with Matchers { set.foreach { entry => entry.getValue match { case _: String => // OK - case _: java.util.Date => println(s"ERROR: entry is $entry"); fail() + case _: java.util.Date => fail("Input is a java Date") case doc: Document => check(doc.entrySet().asScala.toSet) - case _ => println(s"UNKNOWN TYPE ERROR: entry is $entry"); fail() + case _ => fail(s"UNKNOWN TYPE ERROR: entry is $entry") } } check(map) @@ -142,14 +142,13 @@ class SinkRecordConverterTest extends AnyWordSpec with Matchers { def check(set: Set[JavaMap.Entry[String, AnyRef]], parents: List[String] = Nil): Unit = set.foreach { entry => val fullPath = parents :+ entry.getKey() mkString "." - println(s"fullPath = $fullPath") entry.getValue match { case _: String => expectedDates.contains(fullPath) shouldBe false case _: java.util.Date => expectedDates.get(fullPath) shouldBe Some(entry.getValue) case doc: Document => check(doc.entrySet().asScala.toSet, parents :+ entry.getKey) - case _ => { println(s"UNKNOWN TYPE ERROR: entry is $entry; parents: $parents"); fail() } + case _ => fail(s"UNKNOWN TYPE ERROR: entry is $entry; parents: $parents") } } check(map) @@ -157,7 +156,6 @@ class SinkRecordConverterTest extends AnyWordSpec with Matchers { // convert ints as epoch timestamps if requested "add java.util.Date datetime values for Int fields when jsonDateTimeFields are specified" in { - println(s"jsonInt = $jsonInt") implicit val settings = MongoSettings( MongoConfig(baseConfig ++ @@ -180,7 +178,7 @@ class SinkRecordConverterTest extends AnyWordSpec with Matchers { case doc: Document => check(doc.entrySet().asScala.toSet, parents :+ entry.getKey) case other => - println(s"UNKNOWN TYPE ERROR: other is $other; entry is $entry; parents: $parents"); fail() + fail(s"UNKNOWN TYPE ERROR: other is $other; entry is $entry; parents: $parents") } } check(map) @@ -213,7 +211,7 @@ class SinkRecordConverterTest extends AnyWordSpec with Matchers { case _: String => // OK case _: Date => fail() case doc: Document => check(doc.entrySet().asScala.toSet, parents :+ entry.getKey) - case _ => println(s"UNKNOWN TYPE ERROR: entry is $entry; parents: $parents"); fail() + case _ => fail(s"UNKNOWN TYPE ERROR: entry is $entry; parents: $parents") } } check(map) @@ -268,7 +266,7 @@ class SinkRecordConverterTest extends AnyWordSpec with Matchers { case doc: Document => { check(doc.entrySet().asScala.toSet, parents :+ entry.getKey) } - case _ => println(s"UNKNOWN TYPE ERROR: entry is $entry; parents: $parents"); fail() + case _ => fail(s"UNKNOWN TYPE ERROR: entry is $entry; parents: $parents") } } check(map) From 904e9ba255d6fa5f4e82ab1cb08af2d2bfd99fa5 Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Wed, 1 May 2024 17:24:28 +0100 Subject: [PATCH 11/30] LC-137 Key names (#1189) * LC-137 Key names This is a rollback of some of the changes around key names in order to support data reloads from specific timestamp. The change reverts back the TopicPartitionOffsetFileNamer since it is not impacted by the data reload. For OffsetFileNamer the two versions (v0 and v1) were removed since the change is backwards compatible. * Fix the broken tests * Linting fix --------- Co-authored-by: stheppi --- .../connect/S3CompressionTest.scala | 5 +- .../lenses/streamreactor/connect/S3Test.scala | 4 +- .../aws/s3/sink/S3AvroWriterManagerTest.scala | 45 +--- .../aws/s3/sink/S3JsonWriterManagerTest.scala | 74 +------ .../s3/sink/S3ParquetWriterManagerTest.scala | 4 +- .../s3/sink/S3SinkTaskAvroEnvelopeTest.scala | 11 - .../s3/sink/S3SinkTaskJsonEnvelopeTest.scala | 2 +- .../sink/S3SinkTaskParquetEnvelopeTest.scala | 9 - .../common/sink/CoreSinkTaskTestCases.scala | 192 ++++++++++------- .../cloud/common/utils/RemoteFileHelper.scala | 9 +- .../config/kcqlprops/KeyNamerVersion.scala | 43 ---- .../config/kcqlprops/PropsKeyEnum.scala | 1 - .../sink/config/CloudSinkBucketOptions.scala | 57 +---- .../config/kcqlprops/SinkPropsSchema.scala | 1 - .../cloud/common/sink/naming/FileNamer.scala | 31 +-- .../sink/naming/CloudKeyNamerTest.scala | 36 +--- .../common/sink/naming/FileNamerTest.scala | 17 +- .../common/sink/CoreSinkTaskTestCases.scala | 201 ++++++++---------- .../cloud/common/utils/RemoteFileHelper.scala | 6 +- 19 files changed, 265 insertions(+), 483 deletions(-) delete mode 100644 kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/KeyNamerVersion.scala diff --git a/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3CompressionTest.scala b/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3CompressionTest.scala index bfbe77c52..c19e4f1c6 100644 --- a/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3CompressionTest.scala +++ b/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3CompressionTest.scala @@ -101,13 +101,12 @@ class S3CompressionTest val order = Order(1, "OP-DAX-P-20150201-95.7", 94.2, 100, UUID.randomUUID().toString) val record = order.toRecord - producer.send(new ProducerRecord[String, GenericRecord](topic, record)).get + producer.send(new ProducerRecord[String, GenericRecord](topic, null, 0L, null, record)).get producer.flush() eventually { val files = s3Client.listObjectsV2(ListObjectsV2Request.builder().bucket(bucketName).prefix(prefix).build()) - logger.debug("files: {}", files) assert(files.contents().size() == 1) val firstFormatFile = files.contents().asScala.head // avoid temporary files @@ -117,7 +116,7 @@ class S3CompressionTest } }.asserting { file => - file.key() should be(s"$prefix/$topic/0/000000000000.$format") + file.key() should be(s"$prefix/$topic/0/000000000000_0_0.$format") } } } diff --git a/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3Test.scala b/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3Test.scala index 6c3b71e39..4c9f4d19c 100644 --- a/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3Test.scala +++ b/kafka-connect-aws-s3/src/fun/scala/io/lenses/streamreactor/connect/S3Test.scala @@ -76,7 +76,7 @@ class S3Test IO { // Write records to topic - producer.send(new ProducerRecord[String, Order]("orders", order)).get + producer.send(new ProducerRecord[String, Order]("orders", null, 0L, null, order)).get producer.flush() eventually { @@ -86,7 +86,7 @@ class S3Test assert(files.contents().size() == 1) } - readKeyToOrder(s3Client, bucketName, "myfiles/orders/0/000000000000.json") + readKeyToOrder(s3Client, bucketName, "myfiles/orders/0/000000000000_0_0.json") }.asserting { key: Order => key should be(order) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala index 82e380df6..24c5aae00 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala @@ -50,8 +50,7 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingS import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingStrategy import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer import io.lenses.streamreactor.connect.cloud.common.sink.naming.FileNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV0 -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV1 +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamer import io.lenses.streamreactor.connect.cloud.common.utils.SampleData.UsersSchemaDecimal import org.apache.avro.generic.GenericRecord import org.apache.kafka.connect.data.Decimal @@ -108,7 +107,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ) "avro sink" should "write 2 records to avro format in s3" in { - val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamer( identity[String], AvroFormatSelection.extension, ))) @@ -144,7 +143,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont } "avro sink" should "write multiple files and keeping the earliest timestamp" in { - val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamer( identity[String], AvroFormatSelection.extension, ))) @@ -178,42 +177,8 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont } - "avro sink" should "write 2 records to avro format in s3 using v0 namer" in { - val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV0( - identity[String], - AvroFormatSelection.extension, - ))) - firstUsers.zipWithIndex.foreach { - case (struct: Struct, index: Int) => - val writeRes = sink.write( - TopicPartitionOffset(Topic(TopicName), 1, Offset((index + 1).toLong)), - MessageDetail(NullSinkData(None), - StructSinkData(struct), - Map.empty[String, SinkData], - None, - Topic(TopicName), - 1, - Offset((index + 1).toLong), - ), - ) - writeRes.isRight should be(true) - } - - sink.close() - - listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - - val byteArray = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/2.avro") - val genericRecords: List[GenericRecord] = avroFormatReader.read(byteArray) - genericRecords.size should be(2) - - genericRecords(0).get("name").toString should be("sam") - genericRecords(1).get("name").toString should be("laura") - - } - "avro sink" should "write BigDecimal" in { - val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamer( identity[String], AvroFormatSelection.extension, ))) @@ -292,7 +257,7 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont new Struct(secondSchema).put("name", "coco").put("designation", null).put("salary", 395.44), ) - val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamerV1( + val sink = writerManagerCreator.from(avroConfig(new OffsetFileNamer( identity[String], AvroFormatSelection.extension, ))) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala index 2ca1be6a8..08e1ced45 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala @@ -48,8 +48,7 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.NoOpPadd import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingService import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingStrategy import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV0 -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV1 +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamer import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData.firstUsers import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData.users import io.lenses.streamreactor.connect.cloud.common.utils.SampleData.UsersSchemaDecimal @@ -70,7 +69,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont private val PathPrefix = "streamReactorBackups" private implicit val cloudLocationValidator: S3LocationValidator.type = S3LocationValidator - "json sink" should "write single json record using v0 key naming" in { + "json sink" should "write single json record using offset key naming" in { val bucketAndPrefix = CloudLocation(BucketName, PathPrefix.some) val config = S3SinkConfig( @@ -89,68 +88,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( JsonFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamerV0( - identity[String], - JsonFormatSelection.extension, - ), - new PaddingService(Map[String, PaddingStrategy]( - "partition" -> NoOpPaddingStrategy, - "offset" -> LeftPadPaddingStrategy(12, 0), - )), - ), - localStagingArea = LocalStagingArea(localRoot), - dataStorage = DataStorageSettings.disabled, - ), // JsonS3Format - ), - offsetSeekerOptions = OffsetSeekerOptions(5), - compressionCodec, - batchDelete = true, - ) - - val sink = writerManagerCreator.from(config) - val topic = Topic(TopicName) - val offset = Offset(1) - sink.write( - TopicPartitionOffset(topic, 1, offset), - MessageDetail( - NullSinkData(None), - StructSinkData(users.head), - Map.empty[String, SinkData], - Some(Instant.ofEpochMilli(1001L)), - topic, - 1, - offset, - ), - ) - sink.close() - - listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/1.json") should be( - """{"name":"sam","title":"mr","salary":100.43}""", - ) - } - - "json sink" should "write single json record using v1 key naming" in { - - val bucketAndPrefix = CloudLocation(BucketName, PathPrefix.some) - val config = S3SinkConfig( - S3ConnectionConfig( - None, - Some(s3Container.identity.identity), - Some(s3Container.identity.credential), - AuthMode.Credentials, - ), - bucketOptions = Seq( - CloudSinkBucketOptions( - TopicName.some, - bucketAndPrefix, - commitPolicy = CommitPolicy(Count(1)), - formatSelection = JsonFormatSelection, - keyNamer = new CloudKeyNamer( - JsonFormatSelection, - defaultPartitionSelection(Values), - new OffsetFileNamerV1( + new OffsetFileNamer( identity[String], JsonFormatSelection.extension, ), @@ -211,7 +149,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( AvroFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamerV1( + new OffsetFileNamer( identity[String], JsonFormatSelection.extension, ), @@ -276,7 +214,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( AvroFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamerV1( + new OffsetFileNamer( identity[String], JsonFormatSelection.extension, ), @@ -347,7 +285,7 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont keyNamer = new CloudKeyNamer( AvroFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamerV1( + new OffsetFileNamer( identity[String], JsonFormatSelection.extension, ), diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala index 6f1adf283..0a9545322 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala @@ -48,7 +48,7 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.NoOpPadd import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingService import io.lenses.streamreactor.connect.cloud.common.sink.config.padding.PaddingStrategy import io.lenses.streamreactor.connect.cloud.common.sink.naming.CloudKeyNamer -import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamerV1 +import io.lenses.streamreactor.connect.cloud.common.sink.naming.OffsetFileNamer import org.apache.avro.generic.GenericRecord import org.apache.kafka.connect.data.Schema import org.apache.kafka.connect.data.SchemaBuilder @@ -85,7 +85,7 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC keyNamer = new CloudKeyNamer( ParquetFormatSelection, defaultPartitionSelection(Values), - new OffsetFileNamerV1( + new OffsetFileNamer( identity[String], ParquetFormatSelection.extension, ), diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala index 9328cc782..2b2026aee 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskAvroEnvelopeTest.scala @@ -21,7 +21,6 @@ import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData. import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval -import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.KeyNameFormatVersion import io.lenses.streamreactor.connect.cloud.common.formats.AvroFormatReader import org.apache.avro.generic.GenericRecord import org.apache.kafka.common.TopicPartition @@ -96,16 +95,6 @@ class S3SinkTaskAvroEnvelopeTest ) } - "S3SinkTask" should "write to avro format using V0 format" in { - testWritingAvro( - ( - defaultProps + - ("connect.s3.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS AVRO PROPERTIES('store.envelope'=true, 'padding.length.partition'='12', 'padding.length.offset'='12', '${FlushCount.entryName}'=3, '${KeyNameFormatVersion.entryName}'=0)") - ).asJava, - "streamReactorBackups/myTopic/000000000001/000000000003.avro", - ) - } - private def testWritingAvro(props: util.Map[String, String], expected: String) = { val task = new S3SinkTask() val ctx = mock[SinkTaskContext] diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala index c85d6ab4d..d67576975 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskJsonEnvelopeTest.scala @@ -343,7 +343,7 @@ class S3SinkTaskJsonEnvelopeTest val files = listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/") files.size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003.json") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.json") val jsonRecords = new String(bytes).split("\n") jsonRecords.size should be(3) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala index 4d2596fbe..704f5d769 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskParquetEnvelopeTest.scala @@ -20,7 +20,6 @@ import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.connect.cloud.common.utils.ITSampleSchemaAndData._ import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount -import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.KeyNameFormatVersion import io.lenses.streamreactor.connect.cloud.common.formats.reader.ParquetFormatReader import org.apache.avro.generic.GenericRecord import org.apache.kafka.common.TopicPartition @@ -71,14 +70,6 @@ class S3SinkTaskParquetEnvelopeTest testScenario(props, "streamReactorBackups/myTopic/000000000001/000000000003_10001_10003.parquet") } - "S3SinkTask" should "write to avro format using V0 key format" in { - - val props = (defaultProps + ( - "connect.s3.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS `PARQUET` PROPERTIES('store.envelope'=true,'padding.length.partition'='12', 'padding.length.offset'='12', '${FlushCount.entryName}'=3, '${KeyNameFormatVersion.entryName}'=0)", - )).asJava - testScenario(props, "streamReactorBackups/myTopic/000000000001/000000000003.parquet") - } - private def testScenario(props: java.util.Map[String, String], expectedFile: String) = { val task = new S3SinkTask() task.start(props) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala index 6a98265a3..fc325da31 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala @@ -33,6 +33,7 @@ import org.apache.kafka.connect.sink.SinkRecord import org.apache.kafka.connect.sink.SinkTask import org.apache.kafka.connect.sink.SinkTaskContext import org.mockito.MockitoSugar +import org.scalatest.EitherValues import org.scalatest.matchers.should.Matchers import java.io.StringReader @@ -58,7 +59,8 @@ abstract class CoreSinkTaskTestCases[ ) extends CloudPlatformEmulatorSuite[SM, SI, CSC, C, T] with Matchers with MockitoSugar - with LazyLogging { + with LazyLogging + with EitherValues { private val context = mock[SinkTaskContext] @@ -86,8 +88,8 @@ abstract class CoreSinkTaskTestCases[ schema, user, k.toLong, - null, - null, + k, + TimestampType.CREATE_TIME, createHeaders(("headerPartitionKey", (k % 2).toString)), ) } @@ -98,7 +100,7 @@ abstract class CoreSinkTaskTestCases[ } private def toSinkRecord(user: Struct, k: Int, topicName: String = TopicName) = - new SinkRecord(topicName, 1, null, null, schema, user, k.toLong) + new SinkRecord(topicName, 1, null, null, schema, user, k.toLong, k, TimestampType.CREATE_TIME) private val keySchema = SchemaBuilder.struct() .field("phonePrefix", SchemaBuilder.string().required().build()) @@ -113,8 +115,8 @@ abstract class CoreSinkTaskTestCases[ null, users(0), 0, - null, - null, + 0, + TimestampType.CREATE_TIME, ), new SinkRecord(TopicName, 1, @@ -123,8 +125,8 @@ abstract class CoreSinkTaskTestCases[ null, users(1), 1, - null, - null, + 1, + TimestampType.CREATE_TIME, ), new SinkRecord(TopicName, 1, @@ -133,8 +135,8 @@ abstract class CoreSinkTaskTestCases[ null, users(2), 2, - null, - null, + 2, + TimestampType.CREATE_TIME, ), ) @@ -164,7 +166,7 @@ abstract class CoreSinkTaskTestCases[ task.stop() listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_0_2.json") should be( """{"name":"sam","title":"mr","salary":100.43}{"name":"laura","title":"ms","salary":429.06}{"name":"tom","title":null,"salary":395.44}""", ) @@ -183,13 +185,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -213,7 +215,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.json") should be( """{"name":"sam","title":"mr","salary":100.43}{"name":"laura","title":"ms","salary":429.06}""", ) @@ -233,16 +235,16 @@ abstract class CoreSinkTaskTestCases[ task.stop() listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet").size should be >= 941L - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").size should be >= 954L + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.parquet").value.size should be >= 941L + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.parquet").value.size should be >= 954L var genericRecords = - parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet")) + parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.parquet")) genericRecords.size should be(1) checkRecord(genericRecords.head, "sam", "mr", 100.43) genericRecords = - parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet")) + parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.parquet")) genericRecords.size should be(1) checkRecord(genericRecords.head, "laura", "ms", 429.06) @@ -276,13 +278,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -304,10 +306,10 @@ abstract class CoreSinkTaskTestCases[ val list = listBucketPath(BucketName, "streamReactorBackups/myTopic/1/") list.size should be(1) - list should contain("streamReactorBackups/myTopic/1/000000000001.parquet") + list should contain("streamReactorBackups/myTopic/1/000000000001_0_1.parquet") val modificationDate = - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").value.lastModified task = createTask(context, props) task.open(Seq(new TopicPartition(TopicName, 1)).asJava) @@ -317,7 +319,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) // file should not have been overwritten - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified should be( + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").value.lastModified should be( modificationDate, ) @@ -333,7 +335,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) // file should not have been overwritten - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified should be( + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").value.lastModified should be( modificationDate, ) @@ -354,7 +356,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) // file should not have been overwritten - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified should be( + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").value.lastModified should be( modificationDate, ) @@ -377,7 +379,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.parquet") val genericRecords = parquetFormatReader.read(bytes) genericRecords.size should be(1) @@ -401,7 +403,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -430,10 +432,10 @@ abstract class CoreSinkTaskTestCases[ unitUnderTest should "write to text format" in { val textRecords = List( - new SinkRecord(TopicName, 1, null, null, null, "Sausages", 0), - new SinkRecord(TopicName, 1, null, null, null, "Mash", 1), - new SinkRecord(TopicName, 1, null, null, null, "Peas", 2), - new SinkRecord(TopicName, 1, null, null, null, "Gravy", 3), + new SinkRecord(TopicName, 1, null, null, null, "Sausages", 0, 0, TimestampType.CREATE_TIME), + new SinkRecord(TopicName, 1, null, null, null, "Mash", 1, 1, TimestampType.CREATE_TIME), + new SinkRecord(TopicName, 1, null, null, null, "Peas", 2, 2, TimestampType.CREATE_TIME), + new SinkRecord(TopicName, 1, null, null, null, "Gravy", 3, 3, TimestampType.CREATE_TIME), ) val task = createSinkTask() @@ -448,10 +450,10 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.text") + val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.text") new String(file1Bytes) should be("Sausages\nMash\n") - val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003.text") + val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003_2_3.text") new String(file2Bytes) should be("Peas\nGravy\n") } @@ -476,7 +478,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.csv") + val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.csv") val file1Reader = new StringReader(new String(file1Bytes)) val file1CsvReader = new CSVReader(file1Reader) @@ -486,7 +488,7 @@ abstract class CoreSinkTaskTestCases[ file1CsvReader.readNext() should be(Array("laura", "ms", "429.06")) file1CsvReader.readNext() should be(null) - val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003.csv") + val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003_2_3.csv") val file2Reader = new StringReader(new String(file2Bytes)) val file2CsvReader = new CSVReader(file2Reader) @@ -517,7 +519,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.csv") + val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.csv") val file1Reader = new StringReader(new String(file1Bytes)) val file1CsvReader = new CSVReader(file1Reader) @@ -526,7 +528,7 @@ abstract class CoreSinkTaskTestCases[ file1CsvReader.readNext() should be(Array("laura", "ms", "429.06")) file1CsvReader.readNext() should be(null) - val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003.csv") + val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003_2_3.csv") val file2Reader = new StringReader(new String(file2Bytes)) val file2CsvReader = new CSVReader(file2Reader) @@ -701,7 +703,7 @@ abstract class CoreSinkTaskTestCases[ val bytes: Array[Byte] = IOUtils.toByteArray(stream) val textRecords = List( - new SinkRecord(TopicName, 1, null, null, null, bytes, 0), + new SinkRecord(TopicName, 1, null, null, null, bytes, 0, 6, TimestampType.CREATE_TIME), ) val task = createSinkTask() @@ -717,7 +719,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.bytes") should be(bytes) + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_6_6.bytes") should be(bytes) } @@ -749,8 +751,8 @@ abstract class CoreSinkTaskTestCases[ null, users(0), 0, - null, - null, + 0, + TimestampType.CREATE_TIME, createHeaders(("phonePrefix", "+44"), ("region", "8")), ), new SinkRecord(TopicName, @@ -760,8 +762,8 @@ abstract class CoreSinkTaskTestCases[ null, users(1), 1, - null, - null, + 1, + TimestampType.CREATE_TIME, createHeaders(("phonePrefix", "+49"), ("region", "5")), ), new SinkRecord(TopicName, @@ -771,8 +773,8 @@ abstract class CoreSinkTaskTestCases[ null, users(2), 2, - null, - null, + 2, + TimestampType.CREATE_TIME, createHeaders(("phonePrefix", "+49"), ("region", "5")), ), ) @@ -1127,7 +1129,7 @@ abstract class CoreSinkTaskTestCases[ unitUnderTest should "write multiple partitions independently" in { val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, schema, users(0), 0), + new SinkRecord(TopicName, 0, null, null, schema, users(0), 0, 0, TimestampType.CREATE_TIME), toSinkRecord(users(1), 0), toSinkRecord(users(2), 1), ) @@ -1154,7 +1156,7 @@ abstract class CoreSinkTaskTestCases[ fileList.size should be(1) fileList should contain( - "streamReactorBackups/myTopic/000000000001/000000000001.json", + "streamReactorBackups/myTopic/000000000001/000000000001_0_1.json", ) } @@ -1174,6 +1176,9 @@ abstract class CoreSinkTaskTestCases[ SchemaBuilder.map(Schema.STRING_SCHEMA, Schema.INT32_SCHEMA).build(), map, 0, + 0, + TimestampType.CREATE_TIME, + createHeaders(("phonePrefix", "+44"), ("region", "8")), ), ) @@ -1211,7 +1216,7 @@ abstract class CoreSinkTaskTestCases[ ) val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, null, array, 0), + new SinkRecord(TopicName, 0, null, null, null, array, 0, 1, TimestampType.CREATE_TIME), ) val topicPartitionsToManage = Seq( @@ -1234,7 +1239,7 @@ abstract class CoreSinkTaskTestCases[ val fileList = listBucketPath(BucketName, "streamReactorBackups/") fileList.size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_1_1.json") should be( """["jedi","klingons","cylons"]""", ) @@ -1249,7 +1254,16 @@ abstract class CoreSinkTaskTestCases[ ).asJava val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, SchemaBuilder.array(Schema.STRING_SCHEMA), array, 0), + new SinkRecord(TopicName, + 0, + null, + null, + SchemaBuilder.array(Schema.STRING_SCHEMA), + array, + 0, + 0, + TimestampType.CREATE_TIME, + ), ) val topicPartitionsToManage = Seq( @@ -1271,7 +1285,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_0_0.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1287,7 +1301,16 @@ abstract class CoreSinkTaskTestCases[ ).asJava val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, SchemaBuilder.map(Schema.STRING_SCHEMA, schema), map, 0), + new SinkRecord(TopicName, + 0, + null, + null, + SchemaBuilder.map(Schema.STRING_SCHEMA, schema), + map, + 0, + 0, + TimestampType.CREATE_TIME, + ), ) val topicPartitionsToManage = Seq( @@ -1309,7 +1332,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_0_0.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1337,7 +1360,16 @@ abstract class CoreSinkTaskTestCases[ .build() val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, SchemaBuilder.map(Schema.STRING_SCHEMA, optionalSchema).build(), map, 0), + new SinkRecord(TopicName, + 0, + null, + null, + SchemaBuilder.map(Schema.STRING_SCHEMA, optionalSchema).build(), + map, + 0, + 2, + TimestampType.CREATE_TIME, + ), ) val topicPartitionsToManage = Seq( @@ -1359,7 +1391,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_2_2.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1391,7 +1423,7 @@ abstract class CoreSinkTaskTestCases[ ).asJava val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, mapSchema, map, 0), + new SinkRecord(TopicName, 0, null, null, mapSchema, map, 0, 1, TimestampType.CREATE_TIME), ) val topicPartitionsToManage = Seq( @@ -1413,7 +1445,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_1_1.json") should be( """{"jedi":{"name":"sam","title":"mr","salary":100.43},"cylons":null}""", ) @@ -1435,6 +1467,8 @@ abstract class CoreSinkTaskTestCases[ SchemaBuilder.map(Schema.STRING_SCHEMA, SchemaBuilder.array(Schema.STRING_SCHEMA)), map, 0, + 0, + TimestampType.CREATE_TIME, ), ) @@ -1458,7 +1492,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_0_0.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1742,13 +1776,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -1783,13 +1817,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -1814,7 +1848,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000002.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000002_0_2.avro") val genericRecords1 = avroFormatReader.read(bytes) genericRecords1.size should be(3) @@ -1857,7 +1891,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_0_2.json") should be( """{"name":"sam","title":"mr","salary":100.43}{"name":"laura","title":"ms","salary":429.06}{"name":"tom","title":null,"salary":395.44}""", ) @@ -1898,7 +1932,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.avro") val genericRecords1 = avroFormatReader.read(bytes) genericRecords1.size should be(1) @@ -1947,11 +1981,11 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(2) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) // file 1 will not exist because it had been deleted before upload. We continue with the upload regardless. There will be a message in the log. - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -1984,7 +2018,7 @@ abstract class CoreSinkTaskTestCases[ Seq(TopicName, topic2Name).foreach { tName => listBucketPath(BucketName, s"streamReactorBackups/$tName/000000000001/").size should be(1) - remoteFileAsString(BucketName, s"streamReactorBackups/$tName/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, s"streamReactorBackups/$tName/000000000001/000000000002_0_2.json") should be( """ |{"name":"sam","title":"mr","salary":100.43} |{"name":"laura","title":"ms","salary":429.06} @@ -2048,7 +2082,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/2.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/2_0_2.json") should be( """ |{"name":"sam","title":"mr","salary":100.43} |{"name":"laura","title":"ms","salary":429.06} @@ -2059,7 +2093,17 @@ abstract class CoreSinkTaskTestCases[ } private def createSinkRecord(partition: Int, valueStruct: Struct, offset: Int, headers: lang.Iterable[Header]) = - new SinkRecord(TopicName, partition, null, null, null, valueStruct, offset.toLong, null, null, headers) + new SinkRecord(TopicName, + partition, + null, + null, + null, + valueStruct, + offset.toLong, + offset, + TimestampType.CREATE_TIME, + headers, + ) private def createKey(keySchema: Schema, keyValuePair: (String, Any)*): Struct = { val struct = new Struct(keySchema) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala index a2f9467b9..363d2fd2e 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala @@ -49,7 +49,11 @@ trait RemoteFileHelper[SI <: StorageInterface[_]] { def remoteFileAsStream(bucketName: String, fileName: String): InputStream = storageInterface.getBlob(bucketName, fileName) - .leftMap((f: FileLoadError) => fail(f.message(), f.exception)).merge + .leftMap { (f: FileLoadError) => + val availableFiles = storageInterface.listKeysRecursive(bucketName, None) + .map(_.map(_.files).getOrElse(List.empty)).getOrElse(List.empty) + fail(f.message() + s". Available files ${availableFiles.mkString(",")}", f.exception) + }.merge def remoteFileAsString(bucketName: String, fileName: String): String = streamToString(remoteFileAsStream(bucketName, fileName)) @@ -60,8 +64,7 @@ trait RemoteFileHelper[SI <: StorageInterface[_]] { private def streamToByteArray(inputStream: InputStream): Array[Byte] = ByteStreams.toByteArray(inputStream) - def getMetadata(bucket: String, path: String): ObjectMetadata = + def getMetadata(bucket: String, path: String): Either[FileLoadError, ObjectMetadata] = storageInterface.getMetadata(bucket, path) - .leftMap((f: FileLoadError) => fail(f.exception)).merge } diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/KeyNamerVersion.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/KeyNamerVersion.scala deleted file mode 100644 index 87714c6b2..000000000 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/KeyNamerVersion.scala +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.connect.cloud.common.config.kcqlprops - -import enumeratum.Enum -import enumeratum.EnumEntry -import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.KeyNameFormatVersion -import io.lenses.streamreactor.connect.config.kcqlprops.KcqlProperties - -sealed trait KeyNamerVersion extends EnumEntry - -object KeyNamerVersion extends Enum[KeyNamerVersion] { - - case object V0 extends KeyNamerVersion - - case object V1 extends KeyNamerVersion - - def apply( - props: KcqlProperties[PropsKeyEntry, PropsKeyEnum.type], - default: KeyNamerVersion, - ): KeyNamerVersion = fromProps(props).getOrElse(default) - - private def fromProps(props: KcqlProperties[PropsKeyEntry, PropsKeyEnum.type]): Option[KeyNamerVersion] = - props.getOptionalInt(KeyNameFormatVersion).collect { - case 0 => V0 - case 1 => V1 - } - - override def values: IndexedSeq[KeyNamerVersion] = findValues -} diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala index 25c0db682..9ae591877 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/kcqlprops/PropsKeyEnum.scala @@ -61,5 +61,4 @@ object PropsKeyEnum extends Enum[PropsKeyEntry] { case object FlushInterval extends PropsKeyEntry("flush.interval") - case object KeyNameFormatVersion extends PropsKeyEntry("key.name.format.version") } diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala index e2e187c81..e00b7fe66 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/CloudSinkBucketOptions.scala @@ -26,7 +26,6 @@ import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnu import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushSize import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.PartitionIncludeKeys -import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.KeyNamerVersion import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocationValidator import io.lenses.streamreactor.connect.cloud.common.sink.commit.CloudCommitPolicy @@ -75,8 +74,7 @@ object CloudSinkBucketOptions extends LazyLogging { partitionSelection = PartitionSelection(kcql, sinkProps) paddingService <- PaddingService.fromConfig(config, sinkProps) storageSettings <- DataStorageSettings.from(sinkProps) - keyNameVersion = KeyNamerVersion(sinkProps, KeyNamerVersion.V1) - fileNamer <- getFileNamer(keyNameVersion, storageSettings, fileExtension, partitionSelection, paddingService) + fileNamer <- getFileNamer(storageSettings, fileExtension, partitionSelection, paddingService) _ = println("File Namer: " + fileNamer) keyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) stagingArea <- config.getLocalStagingArea()(connectorTaskId) @@ -110,55 +108,22 @@ object CloudSinkBucketOptions extends LazyLogging { * @return */ private def getFileNamer( - keyNameVersion: KeyNamerVersion, storageSettings: DataStorageSettings, fileExtension: String, partitionSelection: PartitionSelection, paddingService: PaddingService, ): Either[Throwable, FileNamer] = - if (!storageSettings.envelope) { - if (partitionSelection.isCustom) { - new TopicPartitionOffsetFileNamerV0( - paddingService.padderFor("partition"), - paddingService.padderFor("offset"), - fileExtension, - ).asRight - } else { - new OffsetFileNamerV0( - paddingService.padderFor("offset"), - fileExtension, - ).asRight - } + if (partitionSelection.isCustom) { + new TopicPartitionOffsetFileNamer( + paddingService.padderFor("partition"), + paddingService.padderFor("offset"), + fileExtension, + ).asRight } else { - keyNameVersion match { - case KeyNamerVersion.V0 => - if (partitionSelection.isCustom) { - new TopicPartitionOffsetFileNamerV0( - paddingService.padderFor("partition"), - paddingService.padderFor("offset"), - fileExtension, - ).asRight - } else { - new OffsetFileNamerV0( - paddingService.padderFor("offset"), - fileExtension, - ).asRight - - } - case KeyNamerVersion.V1 => - if (partitionSelection.isCustom) { - new TopicPartitionOffsetFileNamerV1( - paddingService.padderFor("partition"), - paddingService.padderFor("offset"), - fileExtension, - ).asRight - } else { - new OffsetFileNamerV1( - paddingService.padderFor("offset"), - fileExtension, - ).asRight - } - } + new OffsetFileNamer( + paddingService.padderFor("offset"), + fileExtension, + ).asRight } private def validateWithFlush(kcql: Kcql): Either[Throwable, Unit] = { diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/kcqlprops/SinkPropsSchema.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/kcqlprops/SinkPropsSchema.scala index 5186415c8..91a93b1e8 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/kcqlprops/SinkPropsSchema.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/config/kcqlprops/SinkPropsSchema.scala @@ -39,7 +39,6 @@ object SinkPropsSchema { FlushCount -> LongPropsSchema, FlushSize -> LongPropsSchema, FlushInterval -> IntPropsSchema, - KeyNameFormatVersion -> IntPropsSchema, ) val schema: KcqlPropsSchema[PropsKeyEntry, PropsKeyEnum.type] = diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala index b20208599..6c0491f1f 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamer.scala @@ -25,19 +25,8 @@ trait FileNamer { latestRecordTimestamp: Long, ): String } -class OffsetFileNamerV0( - offsetPaddingStrategy: PaddingStrategy, - extension: String, -) extends FileNamer { - def fileName( - topicPartitionOffset: TopicPartitionOffset, - earliestRecordTimestamp: Long, - latestRecordTimestamp: Long, - ): String = - s"${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}.$extension" -} -class OffsetFileNamerV1( +class OffsetFileNamer( offsetPaddingStrategy: PaddingStrategy, extension: String, ) extends FileNamer { @@ -48,7 +37,7 @@ class OffsetFileNamerV1( ): String = s"${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}_${earliestRecordTimestamp}_$latestRecordTimestamp.$extension" } -class TopicPartitionOffsetFileNamerV0( +class TopicPartitionOffsetFileNamer( partitionPaddingStrategy: PaddingStrategy, offsetPaddingStrategy: PaddingStrategy, extension: String, @@ -63,19 +52,3 @@ class TopicPartitionOffsetFileNamerV0( )}_${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}).$extension" } - -class TopicPartitionOffsetFileNamerV1( - partitionPaddingStrategy: PaddingStrategy, - offsetPaddingStrategy: PaddingStrategy, - extension: String, -) extends FileNamer { - def fileName( - topicPartitionOffset: TopicPartitionOffset, - earliestRecordTimestamp: Long, - latestRecordTimestamp: Long, - ): String = - s"${topicPartitionOffset.topic.value}(${partitionPaddingStrategy.padString( - topicPartitionOffset.partition.toString, - )}_${offsetPaddingStrategy.padString(topicPartitionOffset.offset.value.toString)}_${earliestRecordTimestamp}_$latestRecordTimestamp).$extension" - -} diff --git a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala index ea7a723cb..33ce27422 100644 --- a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala +++ b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/CloudKeyNamerTest.scala @@ -70,7 +70,7 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with PartitionSelection(isCustom = false, List(HeaderPartitionField(PartitionNamePath("h"))), Values) val fileNamer: FileNamer = - new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + new OffsetFileNamer(paddingStrategy, JsonFormatSelection.extension) val keyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val either: Either[SinkError, Map[PartitionField, String]] = keyNamer.processPartitionValues( @@ -93,7 +93,7 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with val stagingDirectory = Files.createTempDirectory("myTempDir").toFile val fileNamer: FileNamer = - new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + new OffsetFileNamer(paddingStrategy, JsonFormatSelection.extension) val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val result = @@ -108,7 +108,7 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with test("should generate the correct staging file path") { val stagingDirectory = Files.createTempDirectory("myTempDir").toFile val fileNamer: FileNamer = - new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + new OffsetFileNamer(paddingStrategy, JsonFormatSelection.extension) val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val result = @@ -122,27 +122,17 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with test("should write to the root of the bucket with no prefix") { val fileNamer: FileNamer = - new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) + new OffsetFileNamer(paddingStrategy, JsonFormatSelection.extension) val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val result = s3KeyNamer.value(bucketNoPrefix, topicPartition, partitionValues, 0L, 10L) - result.value.path.value shouldEqual s"$TopicName/00$Partition/0$Offset.json" - } - - test("should generate the correct final S3 location for old format") { - val fileNamer: FileNamer = - new OffsetFileNamerV0(paddingStrategy, JsonFormatSelection.extension) - val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) - - val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 0L, 10L) - - result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/0$Offset.json" + result.value.path.value shouldEqual s"$TopicName/00$Partition/0${Offset}_0_10.json" } test("should generate the correct final S3 location for v1 OffsetFileNamerV1 format") { val fileNamer: FileNamer = - new OffsetFileNamerV1(paddingStrategy, JsonFormatSelection.extension) + new OffsetFileNamer(paddingStrategy, JsonFormatSelection.extension) val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 101L, 9999L) @@ -150,9 +140,9 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/0${Offset}_101_9999.json" } - test("should generate the correct final S3 location for TopicPartitionOffsetFileNamerV0 format") { + test("should generate the correct final S3 location for TopicPartitionOffsetFileNamer format") { val fileNamer: FileNamer = - new TopicPartitionOffsetFileNamerV0(paddingStrategy, paddingStrategy, JsonFormatSelection.extension) + new TopicPartitionOffsetFileNamer(paddingStrategy, paddingStrategy, JsonFormatSelection.extension) val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 101L, 1000L) @@ -160,14 +150,4 @@ class CloudKeyNamerTest extends AnyFunSuite with Matchers with OptionValues with result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/${topicPartition.topic.value}(009_0$Offset).json" } - test("should generate the correct final S3 location for TopicPartitionOffsetFileNamerV1 format") { - val fileNamer: FileNamer = - new TopicPartitionOffsetFileNamerV1(paddingStrategy, paddingStrategy, JsonFormatSelection.extension) - val s3KeyNamer = CloudKeyNamer(formatSelection, partitionSelection, fileNamer, paddingService) - - val result = s3KeyNamer.value(bucketAndPrefix, topicPartition, partitionValues, 101L, 1000L) - - result.value.path.value shouldEqual s"prefix/$TopicName/00$Partition/${topicPartition.topic.value}(009_0${Offset}_101_1000).json" - } - } diff --git a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala index 1c386610b..85abcf63d 100644 --- a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala +++ b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/naming/FileNamerTest.scala @@ -25,16 +25,9 @@ class FileNamerTest extends AnyFunSuite with Matchers { private val paddingStrategy = LeftPad.toPaddingStrategy(5, '0') private val topicPartitionOffset = Topic("topic").withPartition(9).atOffset(81) - test("OffsetFileNamerV0.fileName should generate the correct file name") { + test("OffsetFileNamer.fileName should generate the correct file name") { - val result = new OffsetFileNamerV0(paddingStrategy, extension).fileName(topicPartitionOffset, 0L, 0L) - - result shouldEqual "00081.avro" - } - - test("OffsetFileNamerV1.fileName should generate the correct file name") { - - val result = new OffsetFileNamerV1(paddingStrategy, extension).fileName(topicPartitionOffset, 1L, 9L) + val result = new OffsetFileNamer(paddingStrategy, extension).fileName(topicPartitionOffset, 1L, 9L) result shouldEqual "00081_1_9.avro" } @@ -42,9 +35,9 @@ class FileNamerTest extends AnyFunSuite with Matchers { test("TopicPartitionOffsetFileNamer.fileName should generate the correct file name") { val result = - new TopicPartitionOffsetFileNamerV0(paddingStrategy, paddingStrategy, extension).fileName(topicPartitionOffset, - 0L, - 0L, + new TopicPartitionOffsetFileNamer(paddingStrategy, paddingStrategy, extension).fileName(topicPartitionOffset, + 0L, + 0L, ) result shouldEqual "topic(00009_00081).avro" diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala index 117faf9e1..6e148ef64 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala @@ -8,7 +8,6 @@ import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushSize -import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.KeyNameFormatVersion import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.PartitionIncludeKeys import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.StoreEnvelope import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig @@ -116,8 +115,8 @@ abstract class CoreSinkTaskTestCases[ null, users(0), 0, - null, - null, + 0, + TimestampType.CREATE_TIME, ), new SinkRecord(TopicName, 1, @@ -126,8 +125,8 @@ abstract class CoreSinkTaskTestCases[ null, users(1), 1, - null, - null, + 1, + TimestampType.CREATE_TIME, ), new SinkRecord(TopicName, 1, @@ -136,8 +135,8 @@ abstract class CoreSinkTaskTestCases[ null, users(2), 2, - null, - null, + 2, + TimestampType.CREATE_TIME, ), ) @@ -167,7 +166,7 @@ abstract class CoreSinkTaskTestCases[ task.stop() listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_0_2.json") should be( """{"name":"sam","title":"mr","salary":100.43}{"name":"laura","title":"ms","salary":429.06}{"name":"tom","title":null,"salary":395.44}""", ) @@ -186,13 +185,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -216,7 +215,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.json") should be( """{"name":"sam","title":"mr","salary":100.43}{"name":"laura","title":"ms","salary":429.06}""", ) @@ -236,16 +235,16 @@ abstract class CoreSinkTaskTestCases[ task.stop() listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet").size should be >= 941L - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").size should be >= 954L + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.parquet").size should be >= 941L + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.parquet").size should be >= 954L var genericRecords = - parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet")) + parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.parquet")) genericRecords.size should be(1) checkRecord(genericRecords.head, "sam", "mr", 100.43) genericRecords = - parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet")) + parquetFormatReader.read(remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.parquet")) genericRecords.size should be(1) checkRecord(genericRecords.head, "laura", "ms", 429.06) @@ -279,13 +278,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -307,10 +306,10 @@ abstract class CoreSinkTaskTestCases[ val list = listBucketPath(BucketName, "streamReactorBackups/myTopic/1/") list.size should be(1) - list should contain("streamReactorBackups/myTopic/1/000000000001.parquet") + list should contain("streamReactorBackups/myTopic/1/000000000001_0_1.parquet") val modificationDate = - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").lastModified task = createTask(context, props) task.open(Seq(new TopicPartition(TopicName, 1)).asJava) @@ -320,7 +319,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) // file should not have been overwritten - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified should be( + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").lastModified should be( modificationDate, ) @@ -336,7 +335,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) // file should not have been overwritten - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified should be( + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").lastModified should be( modificationDate, ) @@ -357,7 +356,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) // file should not have been overwritten - getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001.parquet").lastModified should be( + getMetadata(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.parquet").lastModified should be( modificationDate, ) @@ -380,7 +379,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.parquet") val genericRecords = parquetFormatReader.read(bytes) genericRecords.size should be(1) @@ -409,28 +408,6 @@ abstract class CoreSinkTaskTestCases[ checkRecord(genericRecords.head.get("value").asInstanceOf[GenericRecord], "sam", "mr", 100.43) } - unitUnderTest should "write to parquet format using V0 key format" in { - - val props = (defaultProps + ( - s"$prefix.kcql" -> s"""insert into $BucketName:$PrefixName select * from $TopicName STOREAS PARQUET PROPERTIES('${FlushCount.entryName}'=1, '${KeyNameFormatVersion.entryName}'=0)""", - )).asJava - val task = createTask(context, props) - - task.open(Seq(new TopicPartition(TopicName, 1)).asJava) - task.put(records.asJava) - task.close(Seq(new TopicPartition(TopicName, 1)).asJava) - task.stop() - - listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.parquet") - - val genericRecords = parquetFormatReader.read(bytes) - genericRecords.size should be(1) - checkRecord(genericRecords.head, "sam", "mr", 100.43) - - } - unitUnderTest should "write to avro format" in { val task = createSinkTask() @@ -447,7 +424,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -479,29 +456,6 @@ abstract class CoreSinkTaskTestCases[ } - unitUnderTest should "write to avro format using V0 key format and envelope data storage" in { - - val task = createSinkTask() - - val props = ( - defaultProps + (s"$prefix.kcql" -> s"insert into $BucketName:$PrefixName select * from $TopicName STOREAS `AVRO` PROPERTIES('${FlushCount.entryName}'=1, '${KeyNameFormatVersion.entryName}'=0, '${StoreEnvelope.entryName}'= true)") - ).asJava - - task.start(props) - task.open(Seq(new TopicPartition(TopicName, 1)).asJava) - task.put(records.asJava) - task.close(Seq(new TopicPartition(TopicName, 1)).asJava) - task.stop() - - listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.avro") - - val genericRecords = avroFormatReader.read(bytes) - genericRecords.size should be(1) - checkRecord(genericRecords.head.get("value").asInstanceOf[GenericData.Record], "sam", "mr", 100.43) - - } unitUnderTest should "error when trying to write AVRO to text format" in { val task = createSinkTask() @@ -523,10 +477,10 @@ abstract class CoreSinkTaskTestCases[ unitUnderTest should "write to text format" in { val textRecords = List( - new SinkRecord(TopicName, 1, null, null, null, "Sausages", 0), - new SinkRecord(TopicName, 1, null, null, null, "Mash", 1), - new SinkRecord(TopicName, 1, null, null, null, "Peas", 2), - new SinkRecord(TopicName, 1, null, null, null, "Gravy", 3), + new SinkRecord(TopicName, 1, null, null, null, "Sausages", 0, 0, TimestampType.CREATE_TIME), + new SinkRecord(TopicName, 1, null, null, null, "Mash", 1, 1, TimestampType.CREATE_TIME), + new SinkRecord(TopicName, 1, null, null, null, "Peas", 2, 2, TimestampType.CREATE_TIME), + new SinkRecord(TopicName, 1, null, null, null, "Gravy", 3, 3, TimestampType.CREATE_TIME), ) val task = createSinkTask() @@ -541,10 +495,10 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.text") + val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.text") new String(file1Bytes) should be("Sausages\nMash\n") - val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003.text") + val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003_2_3.text") new String(file2Bytes) should be("Peas\nGravy\n") } @@ -569,7 +523,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.csv") + val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.csv") val file1Reader = new StringReader(new String(file1Bytes)) val file1CsvReader = new CSVReader(file1Reader) @@ -579,7 +533,7 @@ abstract class CoreSinkTaskTestCases[ file1CsvReader.readNext() should be(Array("laura", "ms", "429.06")) file1CsvReader.readNext() should be(null) - val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003.csv") + val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003_2_3.csv") val file2Reader = new StringReader(new String(file2Bytes)) val file2CsvReader = new CSVReader(file2Reader) @@ -610,7 +564,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(2) - val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001.csv") + val file1Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000001_0_1.csv") val file1Reader = new StringReader(new String(file1Bytes)) val file1CsvReader = new CSVReader(file1Reader) @@ -619,7 +573,7 @@ abstract class CoreSinkTaskTestCases[ file1CsvReader.readNext() should be(Array("laura", "ms", "429.06")) file1CsvReader.readNext() should be(null) - val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003.csv") + val file2Bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000003_2_3.csv") val file2Reader = new StringReader(new String(file2Bytes)) val file2CsvReader = new CSVReader(file2Reader) @@ -794,7 +748,7 @@ abstract class CoreSinkTaskTestCases[ val bytes: Array[Byte] = IOUtils.toByteArray(stream) val textRecords = List( - new SinkRecord(TopicName, 1, null, null, null, bytes, 0), + new SinkRecord(TopicName, 1, null, null, null, bytes, 0, 0, TimestampType.CREATE_TIME), ) val task = createSinkTask() @@ -810,7 +764,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.bytes") should be(bytes) + remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.bytes") should be(bytes) } @@ -1220,7 +1174,7 @@ abstract class CoreSinkTaskTestCases[ unitUnderTest should "write multiple partitions independently" in { val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, schema, users(0), 0), + new SinkRecord(TopicName, 0, null, null, schema, users(0), 0, 0, TimestampType.CREATE_TIME), toSinkRecord(users(1), 0), toSinkRecord(users(2), 1), ) @@ -1247,7 +1201,7 @@ abstract class CoreSinkTaskTestCases[ fileList.size should be(1) fileList should contain( - "streamReactorBackups/myTopic/000000000001/000000000001.json", + "streamReactorBackups/myTopic/000000000001/000000000001_0_1.json", ) } @@ -1304,7 +1258,7 @@ abstract class CoreSinkTaskTestCases[ ) val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, null, array, 0), + new SinkRecord(TopicName, 0, null, null, null, array, 0, 2, TimestampType.CREATE_TIME), ) val topicPartitionsToManage = Seq( @@ -1327,7 +1281,7 @@ abstract class CoreSinkTaskTestCases[ val fileList = listBucketPath(BucketName, "streamReactorBackups/") fileList.size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_2_2.json") should be( """["jedi","klingons","cylons"]""", ) @@ -1342,7 +1296,16 @@ abstract class CoreSinkTaskTestCases[ ).asJava val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, SchemaBuilder.array(Schema.STRING_SCHEMA), array, 0), + new SinkRecord(TopicName, + 0, + null, + null, + SchemaBuilder.array(Schema.STRING_SCHEMA), + array, + 0, + 2, + TimestampType.CREATE_TIME, + ), ) val topicPartitionsToManage = Seq( @@ -1364,7 +1327,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_2_2.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1380,7 +1343,16 @@ abstract class CoreSinkTaskTestCases[ ).asJava val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, SchemaBuilder.map(Schema.STRING_SCHEMA, schema), map, 0), + new SinkRecord(TopicName, + 0, + null, + null, + SchemaBuilder.map(Schema.STRING_SCHEMA, schema), + map, + 0, + 2, + TimestampType.CREATE_TIME, + ), ) val topicPartitionsToManage = Seq( @@ -1402,7 +1374,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_2_2.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1430,7 +1402,16 @@ abstract class CoreSinkTaskTestCases[ .build() val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, SchemaBuilder.map(Schema.STRING_SCHEMA, optionalSchema).build(), map, 0), + new SinkRecord(TopicName, + 0, + null, + null, + SchemaBuilder.map(Schema.STRING_SCHEMA, optionalSchema).build(), + map, + 0, + 2, + TimestampType.CREATE_TIME, + ), ) val topicPartitionsToManage = Seq( @@ -1452,7 +1433,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_2_2.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1484,7 +1465,7 @@ abstract class CoreSinkTaskTestCases[ ).asJava val kafkaPartitionedRecords = List( - new SinkRecord(TopicName, 0, null, null, mapSchema, map, 0), + new SinkRecord(TopicName, 0, null, null, mapSchema, map, 0, 1, TimestampType.CREATE_TIME), ) val topicPartitionsToManage = Seq( @@ -1506,7 +1487,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_1_1.json") should be( """{"jedi":{"name":"sam","title":"mr","salary":100.43},"cylons":null}""", ) @@ -1528,6 +1509,8 @@ abstract class CoreSinkTaskTestCases[ SchemaBuilder.map(Schema.STRING_SCHEMA, SchemaBuilder.array(Schema.STRING_SCHEMA)), map, 0, + 0, + TimestampType.CREATE_TIME, ), ) @@ -1551,7 +1534,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000000/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/000000000000/000000000000_0_0.avro") val genericRecords = avroFormatReader.read(bytes) genericRecords.size should be(1) @@ -1835,13 +1818,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -1876,13 +1859,13 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(3) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000001_1_1.json") should be( """{"name":"laura","title":"ms","salary":429.06}""", ) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -1907,7 +1890,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000002.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000002_0_2.avro") val genericRecords1 = avroFormatReader.read(bytes) genericRecords1.size should be(3) @@ -1950,7 +1933,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/000000000002_0_2.json") should be( """{"name":"sam","title":"mr","salary":100.43}{"name":"laura","title":"ms","salary":429.06}{"name":"tom","title":null,"salary":395.44}""", ) @@ -1991,7 +1974,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(3) - val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000.avro") + val bytes = remoteFileAsBytes(BucketName, "streamReactorBackups/myTopic/1/000000000000_0_0.avro") val genericRecords1 = avroFormatReader.read(bytes) genericRecords1.size should be(1) @@ -2040,11 +2023,11 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/000000000001/").size should be(2) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000000_0_0.json") should be( """{"name":"sam","title":"mr","salary":100.43}""", ) // file 1 will not exist because it had been deleted before upload. We continue with the upload regardless. There will be a message in the log. - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/000000000001/000000000002_2_2.json") should be( """{"name":"tom","title":null,"salary":395.44}""", ) @@ -2077,7 +2060,7 @@ abstract class CoreSinkTaskTestCases[ Seq(TopicName, topic2Name).foreach { tName => listBucketPath(BucketName, s"streamReactorBackups/$tName/000000000001/").size should be(1) - remoteFileAsString(BucketName, s"streamReactorBackups/$tName/000000000001/000000000002.json") should be( + remoteFileAsString(BucketName, s"streamReactorBackups/$tName/000000000001/000000000002_0_2.json") should be( """ |{"name":"sam","title":"mr","salary":100.43} |{"name":"laura","title":"ms","salary":429.06} @@ -2141,7 +2124,7 @@ abstract class CoreSinkTaskTestCases[ listBucketPath(BucketName, "streamReactorBackups/myTopic/1/").size should be(1) - remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/2.json") should be( + remoteFileAsString(BucketName, "streamReactorBackups/myTopic/1/2_0_2.json") should be( """ |{"name":"sam","title":"mr","salary":100.43} |{"name":"laura","title":"ms","salary":429.06} diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala index a2f9467b9..df97babc3 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/utils/RemoteFileHelper.scala @@ -49,7 +49,11 @@ trait RemoteFileHelper[SI <: StorageInterface[_]] { def remoteFileAsStream(bucketName: String, fileName: String): InputStream = storageInterface.getBlob(bucketName, fileName) - .leftMap((f: FileLoadError) => fail(f.message(), f.exception)).merge + .leftMap { (f: FileLoadError) => + val availableFiles = storageInterface.listKeysRecursive(bucketName, None) + .map(_.map(_.files).getOrElse(List.empty)).getOrElse(List.empty) + fail(f.message() + s". Available files ${availableFiles.mkString(",")}", f.exception) + }.merge def remoteFileAsString(bucketName: String, fileName: String): String = streamToString(remoteFileAsStream(bucketName, fileName)) From 26d8f2652e8197a7aa1cc5cc7c4311194414969b Mon Sep 17 00:00:00 2001 From: David Sloan <33483659+davidsloan@users.noreply.github.com> Date: Thu, 2 May 2024 11:33:44 +0100 Subject: [PATCH 12/30] GCP Commons Extraction- (#1188) * GCP Commons Extraction- * Extracting new java module (gcp-commons) for use in both the GCP Storage connectors, and future planned connectors. * Rewriting GCP authentication code in Java. * Including unit testing. * Currently just in SBT - no gradle config created yet. * Tweaks prompted by review comments --- build.sbt | 17 ++ java-connectors/.gitignore | 3 +- .../common/config/base/ConfigMap.java | 52 ++++++ .../common/config/base/RetryConfig.java | 48 ++++++ .../config/base/intf/ConnectionConfig.java | 24 +++ .../config/base/model/ConnectorPrefix.java | 29 ++++ .../common/config/base/ConfigMapTest.java | 72 ++++++++ .../gcp/common/auth/GCPConnectionConfig.java | 53 ++++++ .../auth/GCPServiceBuilderConfigurer.java | 95 +++++++++++ .../gcp/common/auth/HttpTimeoutConfig.java | 33 ++++ .../gcp/common/auth/mode/AuthMode.java | 36 ++++ .../common/auth/mode/CredentialsAuthMode.java | 41 +++++ .../gcp/common/auth/mode/DefaultAuthMode.java | 38 +++++ .../gcp/common/auth/mode/FileAuthMode.java | 38 +++++ .../gcp/common/auth/mode/NoAuthMode.java | 32 ++++ .../gcp/common/config/AuthModeSettings.java | 133 +++++++++++++++ .../auth/GCPServiceBuilderConfigurerTest.java | 137 +++++++++++++++ .../connect/gcp/common/auth/TestService.java | 56 ++++++ .../auth/mode/CredentialsAuthModeTest.java | 37 ++++ .../common/auth/mode/DefaultAuthModeTest.java | 41 +++++ .../common/auth/mode/FileAuthModeTest.java | 36 ++++ .../gcp/common/auth/mode/NoAuthModeTest.java | 33 ++++ .../gcp/common/auth/mode/TestFileUtil.java | 44 +++++ .../common/config/AuthModeSettingsTest.java | 134 +++++++++++++++ .../test/resources/test-gcp-credentials.json | 12 ++ .../aws/s3/sink/S3AvroWriterManagerTest.scala | 11 +- .../aws/s3/sink/S3JsonWriterManagerTest.scala | 20 ++- .../s3/sink/S3ParquetWriterManagerTest.scala | 7 +- .../connect/aws/s3/sink/S3SinkTaskTest.scala | 10 +- .../aws/s3/utils/S3ProxyContainerTest.scala | 9 +- .../sink/CloudPlatformEmulatorSuite.scala | 22 +-- .../common/sink/CoreSinkTaskTestCases.scala | 14 +- .../aws/s3/auth/AwsS3ClientCreator.scala | 4 +- .../aws/s3/config/CommonConfigDef.scala | 161 ------------------ .../aws/s3/config/S3CommonConfigDef.scala | 144 ++++++++++++++++ .../aws/s3/config/S3ConfigSettings.scala | 20 --- .../aws/s3/config/S3ConnectionConfig.scala | 25 +-- .../connect/aws/s3/sink/S3SinkTask.scala | 12 +- .../aws/s3/sink/config/S3SinkConfig.scala | 20 ++- .../aws/s3/sink/config/S3SinkConfigDef.scala | 2 +- .../sink/config/S3SinkConfigDefBuilder.scala | 4 +- .../aws/s3/source/config/S3SourceConfig.scala | 2 +- .../s3/source/config/S3SourceConfigDef.scala | 6 +- .../config/S3SourceConfigDefBuilder.scala | 2 +- ...Test.scala => S3CommonConfigDefTest.scala} | 4 +- .../connect/aws/s3/config/S3ConfigTest.scala | 44 +---- .../S3ConsumerGroupsSinkConfigTest.scala | 13 +- .../S3ConsumerGroupsSinkConfigTest.scala | 13 +- .../aws/s3/sink/config/S3SinkConfigTest.scala | 55 +++++- .../datalake/auth/DatalakeClientCreator.scala | 2 +- .../config/AzureConnectionConfig.scala | 24 +-- .../datalake/sink/DatalakeSinkTask.scala | 12 +- .../sink/config/DatalakeSinkConfig.scala | 18 +- .../config/DatalakeSinkConfigDefBuilder.scala | 4 +- .../cloud/common/auth/ClientCreator.scala | 8 +- .../common/config/traits/CloudConfig.scala | 19 +-- .../config/traits/CloudConnectionConfig.scala | 40 ----- .../traits/PropsToConfigConverter.scala | 2 +- .../cloud/common/sink/CloudSinkTask.scala | 24 ++- .../common/sink/WriterManagerCreator.scala | 4 +- .../config/CloudSourceConfigDefBuilder.scala | 2 +- .../source/config/CloudSourceSettings.scala | 3 +- .../sink/WriterManagerCreatorTest.scala | 35 ++-- .../config/base/traits/BaseConfig.scala | 2 +- .../config/base/traits/BaseSettings.scala | 7 +- .../base/traits/ErrorPolicySettings.scala | 48 +++++- .../base/traits/RetryConfigSettings.scala | 73 ++++++++ .../sink/CloudPlatformEmulatorSuite.scala | 22 +-- .../common/sink/CoreSinkTaskTestCases.scala | 20 ++- .../storage/sink/GCPStorageSinkTaskTest.scala | 2 + .../storage/utils/GCPProxyContainerTest.scala | 33 ++-- .../auth/GCPStorageClientCreator.scala | 58 +------ .../auth/ServiceConfigurationException.scala | 7 +- .../gcp/storage/config/AuthModeSettings.scala | 95 +---------- .../gcp/storage/config/CommonConfigDef.scala | 7 +- .../storage/config/GCPConnectionConfig.scala | 80 --------- .../config/GCPConnectionConfigBuilder.scala | 42 +++++ .../gcp/storage/sink/GCPStorageSinkTask.scala | 12 +- .../sink/config/GCPStorageSinkConfig.scala | 17 +- .../GCPStorageSinkConfigDefBuilder.scala | 4 +- .../config/GCPStorageSourceConfig.scala | 9 +- .../GCPStorageSourceConfigDefBuilder.scala | 2 +- .../auth/GCPStorageClientCreatorTest.scala | 46 ++--- .../storage/config/CommonConfigDefTest.scala | 18 +- .../gcp/storage/config/GCPConfigTest.scala | 59 ++----- .../storage/config/UploadSettingsTest.scala | 2 + .../config/GCPStorageSinkConfigTest.scala | 58 ++++++- .../config/GCPStorageSourceConfigTest.scala | 19 ++- .../config/KcqlWithFieldsSettingsTest.scala | 2 + project/Dependencies.scala | 21 ++- 90 files changed, 2070 insertions(+), 790 deletions(-) create mode 100644 java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigMap.java create mode 100644 java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java create mode 100644 java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java create mode 100644 java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java create mode 100644 java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/resources/test-gcp-credentials.json delete mode 100644 kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/CommonConfigDef.scala create mode 100644 kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3CommonConfigDef.scala rename kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/{CommonConfigDefTest.scala => S3CommonConfigDefTest.scala} (95%) delete mode 100644 kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConnectionConfig.scala create mode 100644 kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/RetryConfigSettings.scala rename kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/RetryConfig.scala => kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/ServiceConfigurationException.scala (74%) delete mode 100644 kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfig.scala create mode 100644 kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala diff --git a/build.sbt b/build.sbt index 78b3f65fe..d21815fba 100644 --- a/build.sbt +++ b/build.sbt @@ -13,6 +13,7 @@ ThisBuild / scalaVersion := Dependencies.scalaVersion lazy val subProjects: Seq[Project] = Seq( `query-language`, `java-common`, + `gcp-common`, common, `sql-common`, `cloud-common`, @@ -72,6 +73,21 @@ lazy val `java-common` = (project in file("java-connectors/kafka-connect-common" .configureAssembly(false) .configureTests(javaCommonTestDeps) +lazy val `gcp-common` = (project in file("java-connectors/kafka-connect-gcp-common")) + .dependsOn(`java-common`) + .settings( + settings ++ + Seq( + name := "kafka-connect-gcp-common", + description := "GCP Commons Module", + libraryDependencies ++= kafkaConnectGcpCommonDeps, + publish / skip := true, + ), + ) + .configureAssembly(true) + .configureTests(javaCommonTestDeps) + .configureAntlr() + lazy val `sql-common` = (project in file("kafka-connect-sql-common")) .dependsOn(`query-language`) .dependsOn(`common`) @@ -170,6 +186,7 @@ lazy val `azure-datalake` = (project in file("kafka-connect-azure-datalake")) lazy val `gcp-storage` = (project in file("kafka-connect-gcp-storage")) .dependsOn(common) + .dependsOn(`gcp-common`) .dependsOn(`cloud-common` % "compile->compile;test->test;it->it") .dependsOn(`test-common` % "test->compile") .settings( diff --git a/java-connectors/.gitignore b/java-connectors/.gitignore index 13047a0b9..16453db46 100644 --- a/java-connectors/.gitignore +++ b/java-connectors/.gitignore @@ -44,4 +44,5 @@ bin/ ### Lenses-specific ### release/ -gradle-modules.txt \ No newline at end of file +gradle-modules.txt +**/src/main/gen/* diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigMap.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigMap.java new file mode 100644 index 000000000..4154e5687 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigMap.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.base; + +import lombok.AllArgsConstructor; +import org.apache.kafka.common.config.types.Password; + +import java.util.Map; +import java.util.Optional; + +/** + * A wrapper for Kafka Connect properties that provides methods to retrieve property values. + */ +@AllArgsConstructor +public class ConfigMap { + + private final Map wrapped; + + /** + * Retrieves a String property value associated with the given key. + * + * @param key the property key + * @return an {@link Optional} containing the property value if present, otherwise empty + */ + public Optional getString(String key) { + return Optional.ofNullable((String) wrapped.get(key)); + } + + /** + * Retrieves a Password property value associated with the given key. + * + * @param key the property key + * @return an {@link Optional} containing the {@link Password} value if present, otherwise empty + */ + public Optional getPassword(String key) { + return Optional.ofNullable((Password) wrapped.get(key)); + } + +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java new file mode 100644 index 000000000..3058dc643 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java @@ -0,0 +1,48 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.base; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +/** + * Configuration class for defining retry behavior. + * This class encapsulates settings related to retrying operations, such as the maximum + * number of retry attempts and the interval (in milliseconds) between retries. + * + * Different connector implementations may interpret these settings differently based + * on their specific requirements and behavior. + */ +@Data +@Builder +@AllArgsConstructor +public class RetryConfig { + + /** + * Maximum number of retry attempts allowed. + * This value specifies the maximum number of times an operation will be retried + * before giving up. A value of 0 indicates no retries will be attempted. + */ + private int retryLimit; + + /** + * Interval (in milliseconds) between retry attempts. + * This value specifies the time delay between consecutive retry attempts, + * measured in milliseconds. + */ + private long retryIntervalMillis; +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java new file mode 100644 index 000000000..3fb23c5e1 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.base.intf; + +/** + * A marker interface for defining connection configurations across different implementations. + * Implementations of this interface signify various types of connection configurations, + * providing a unified categorization without requiring shared behavior or properties. + */ +public interface ConnectionConfig { +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java new file mode 100644 index 000000000..c87e60f43 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java @@ -0,0 +1,29 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.base.model; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class ConnectorPrefix { + + private final String prefix; + + public String prefixKey(String suffix) { + return String.format("%s.%s", prefix, suffix); + } + +} diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java new file mode 100644 index 000000000..5a8a67792 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.base; + +import org.apache.kafka.common.config.types.Password; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigMapTest { + + private ConfigMap configMap; + + @BeforeEach + void setUp() { + Map testMap = new HashMap<>(); + testMap.put("username", "user123"); + testMap.put("password", new Password("secret")); + + configMap = new ConfigMap(testMap); + } + + @Test + void testGetString_existingKey_shouldReturnValue() { + Optional value = configMap.getString("username"); + + assertTrue(value.isPresent()); + assertEquals("user123", value.get()); + } + + @Test + void testGetString_nonExistingKey_shouldReturnEmpty() { + Optional value = configMap.getString("invalidKey"); + + assertFalse(value.isPresent()); + } + + @Test + void testGetPassword_existingKey_shouldReturnPassword() { + Optional password = configMap.getPassword("password"); + + assertTrue(password.isPresent()); + assertEquals("secret", password.get().value()); + } + + @Test + void testGetPassword_nonExistingKey_shouldReturnEmpty() { + Optional password = configMap.getPassword("invalidKey"); + + assertFalse(password.isPresent()); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java new file mode 100644 index 000000000..d7f6c0f60 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java @@ -0,0 +1,53 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth; + +import io.lenses.streamreactor.common.config.base.RetryConfig; +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig; +import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +@Data +@Builder +@AllArgsConstructor +public class GCPConnectionConfig implements ConnectionConfig { + + // TODO: These values are duplicated with GCPConfigSettings. This will be fixed in the next PR. + private static final int HTTP_NUM_OF_RETRIES_DEFAULT = 5; + private static final long HTTP_ERROR_RETRY_INTERVAL_DEFAULT = 50L; + + @Nullable + private String projectId; + @Nullable + private String quotaProjectId; + @Nonnull + private AuthMode authMode; + @Nullable + private String host; + @Nonnull + @Builder.Default + private RetryConfig httpRetryConfig = RetryConfig.builder().retryLimit(HTTP_NUM_OF_RETRIES_DEFAULT).retryIntervalMillis(HTTP_ERROR_RETRY_INTERVAL_DEFAULT).build(); + @Nonnull + @Builder.Default + private HttpTimeoutConfig timeouts = HttpTimeoutConfig.builder().build(); + +} + diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java new file mode 100644 index 000000000..43bf2211b --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth; + +import com.google.api.gax.retrying.RetrySettings; +import com.google.cloud.Service; +import com.google.cloud.ServiceOptions; +import com.google.cloud.TransportOptions; +import com.google.cloud.http.HttpTransportOptions; +import io.lenses.streamreactor.common.config.base.RetryConfig; +import lombok.experimental.UtilityClass; +import lombok.val; +import org.threeten.bp.Duration; + +import java.io.IOException; +import java.util.Optional; +/** + * Utility class for configuring generic GCP service clients using a {@link GCPConnectionConfig}. + */ +@UtilityClass +public class GCPServiceBuilderConfigurer { + + /** + * Configures a GCP service client builder with the provided {@link GCPConnectionConfig}. + * + * @param Type representing the GCP service interface (e.g., Storage, BigQuery) + * @param Type representing the service options (e.g., StorageOptions, BigQueryOptions) + * @param Type representing the service options builder (e.g., StorageOptions.Builder, BigQueryOptions.Builder) + * @param config The GCP connection configuration containing settings such as host, project ID, and authentication details. + * @param builder The builder instance of the GCP service client options. + * @return The configured builder instance with updated settings. + * @throws IOException if an error occurs during configuration, such as credential retrieval. + */ + public static < + X extends Service, + Y extends ServiceOptions, + B extends ServiceOptions.Builder + > + B configure(GCPConnectionConfig config, B builder) throws IOException { + + Optional.ofNullable(config.getHost()).ifPresent(builder::setHost); + + Optional.ofNullable(config.getProjectId()).ifPresent(builder :: setProjectId); + + Optional.ofNullable(config.getQuotaProjectId()).ifPresent(builder :: setQuotaProjectId); + + val authMode = config.getAuthMode(); + + builder.setCredentials(authMode.getCredentials()); + + builder.setRetrySettings(createRetrySettings(config.getHttpRetryConfig())); + + createTransportOptions(config.getTimeouts()).ifPresent(builder::setTransportOptions); + + return builder; + } + + private static Optional createTransportOptions(HttpTimeoutConfig timeoutConfig) { + val connectionTimeout = Optional.ofNullable(timeoutConfig.getConnectionTimeoutMillis()); + val socketTimeout = Optional.ofNullable(timeoutConfig.getSocketTimeoutMillis()); + if (connectionTimeout.isPresent() || socketTimeout.isPresent()) { + HttpTransportOptions.Builder httpTransportOptionsBuilder = HttpTransportOptions.newBuilder(); + socketTimeout.ifPresent(sock -> + httpTransportOptionsBuilder.setReadTimeout(sock.intValue()) + ); + connectionTimeout.ifPresent(conn -> + httpTransportOptionsBuilder.setConnectTimeout(conn.intValue()) + ); + return Optional.of(httpTransportOptionsBuilder.build()); + } + return Optional.empty(); + } + + private static RetrySettings createRetrySettings(RetryConfig httpRetryConfig) { + + return RetrySettings.newBuilder() + .setInitialRetryDelay(Duration.ofMillis(httpRetryConfig.getRetryIntervalMillis())) + .setMaxRetryDelay(Duration.ofMillis(httpRetryConfig.getRetryIntervalMillis() * 5)) + .setMaxAttempts(httpRetryConfig.getRetryLimit()) + .build(); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java new file mode 100644 index 000000000..44514eb15 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import javax.annotation.Nullable; +import java.util.Optional; + +@Data +@Builder +@AllArgsConstructor +public class HttpTimeoutConfig { + @Nullable + private Long socketTimeoutMillis; + @Nullable + private Long connectionTimeoutMillis; +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java new file mode 100644 index 000000000..9e1fda8e5 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java @@ -0,0 +1,36 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.Credentials; + +import java.io.IOException; + +/** + * Interface representing different authentication modes for GCP connectors. + * Implementations of this interface provide methods to obtain Google Cloud Platform (GCP) {@link Credentials}. + */ +public interface AuthMode { + + /** + * Retrieves the GCP credentials required for authentication. + * + * @return The GCP {@link Credentials}. + * @throws IOException If an I/O error occurs while obtaining credentials. + */ + Credentials getCredentials() throws IOException; +} + diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java new file mode 100644 index 000000000..b9326ee2a --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java @@ -0,0 +1,41 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.Credentials; +import com.google.auth.oauth2.GoogleCredentials; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import org.apache.kafka.common.config.types.Password; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/** + * Authentication mode using credentials from a string in configuration. + */ +@AllArgsConstructor +@EqualsAndHashCode +public class CredentialsAuthMode implements AuthMode { + private final Password passwordCredentials; + + @Override + public Credentials getCredentials() throws IOException { + return GoogleCredentials.fromStream( + new ByteArrayInputStream(passwordCredentials.value().getBytes()) + ); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java new file mode 100644 index 000000000..d3542e2a7 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.Credentials; +import com.google.auth.oauth2.GoogleCredentials; +import lombok.EqualsAndHashCode; + +import java.io.IOException; + +/** + * Default authentication mode without explicit credentials. + * This mode utilizes the Application Default Credentials (ADC) chain. + * ADC is a strategy used by the Google authentication libraries to automatically find credentials based on the application environment. + * The credentials are made available to Cloud Client Libraries and Google API Client Libraries, allowing the code to run seamlessly + * in both development and production environments without altering the authentication process for Google Cloud services and APIs. + */ +@EqualsAndHashCode +public class DefaultAuthMode implements AuthMode { + + @Override + public Credentials getCredentials() throws IOException { + return GoogleCredentials.getApplicationDefault(); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java new file mode 100644 index 000000000..1c4689579 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.Credentials; +import com.google.auth.oauth2.GoogleCredentials; +import lombok.AllArgsConstructor; + +import java.io.FileInputStream; +import java.io.IOException; + +/** + * Authentication mode using a json file for credentials. + */ +@AllArgsConstructor +public class FileAuthMode implements AuthMode { + + private final String filePath; + + @Override + public Credentials getCredentials() throws IOException { + FileInputStream fileInputStream = new FileInputStream(filePath); + return GoogleCredentials.fromStream(fileInputStream); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java new file mode 100644 index 000000000..860155f4b --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java @@ -0,0 +1,32 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.Credentials; +import com.google.cloud.NoCredentials; +import lombok.EqualsAndHashCode; + +/** + * Authentication mode indicating no authentication is required. + */ +@EqualsAndHashCode +public class NoAuthMode implements AuthMode { + + @Override + public Credentials getCredentials() { + return NoCredentials.getInstance(); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java new file mode 100644 index 000000000..289bc7fee --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java @@ -0,0 +1,133 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.config; + +import io.lenses.streamreactor.common.config.base.ConfigMap; +import io.lenses.streamreactor.common.config.base.model.ConnectorPrefix; +import io.lenses.streamreactor.connect.gcp.common.auth.mode.*; +import lombok.Getter; +import org.apache.kafka.common.config.ConfigDef; +import org.apache.kafka.common.config.ConfigDef.Importance; +import org.apache.kafka.common.config.ConfigDef.Type; +import org.apache.kafka.common.config.ConfigException; + +/** + * Configuration settings for specifying authentication mode and related credentials for GCP connectors. + * This class provides methods to define and parse authentication mode settings based on Kafka Connect's {@link ConfigDef}. + * + * Authentication modes supported: + * - 'credentials': Use GCP credentials for authentication. + * - 'file': Authenticate using credentials stored in a file. + * - 'default': Default authentication mode. + * - 'none': No authentication required. + * + * Keys used in configuration: + * - {@code gcp.auth.mode}: Key to specify the authentication mode. + * - {@code gcp.credentials}: Key for providing GCP credentials (used with 'credentials' auth mode). + * - {@code gcp.file}: Key for specifying the file path containing GCP credentials (used with 'file' auth mode). + */ +@Getter +public class AuthModeSettings { + + public static final String EMPTY_STRING = ""; + + // Auth Mode values + public static final String PROP_KEY_CREDENTIALS = "CREDENTIALS"; + public static final String PROP_KEY_FILE = "FILE"; + public static final String PROP_KEY_NONE = "NONE"; + public static final String PROP_KEY_DEFAULT = "DEFAULT"; + + private final String authModeKey; + private final String credentialsKey; + private final String fileKey; + + /** + * Constructs an instance of AuthModeSettings. + * + * @param connectorPrefix The prefix used to generate keys for configuration settings. + */ + public AuthModeSettings(ConnectorPrefix connectorPrefix) { + this.authModeKey = connectorPrefix.prefixKey("gcp.auth.mode"); + this.credentialsKey = connectorPrefix.prefixKey("gcp.credentials"); + this.fileKey = connectorPrefix.prefixKey("gcp.file"); + } + + + /** + * Configures the provided ConfigDef with authentication mode settings. + * + * @param configDef The ConfigDef instance to be updated with authentication mode definitions. + * @return The updated ConfigDef with authentication mode settings defined. + */ + public ConfigDef withAuthModeSettings(ConfigDef configDef) { + return configDef.define( + authModeKey, + Type.STRING, + PROP_KEY_DEFAULT, + Importance.HIGH, + "Authenticate mode, 'credentials', 'file', 'default' or 'none'" + ) + .define( + credentialsKey, + Type.PASSWORD, + EMPTY_STRING, + Importance.HIGH, + "GCP Credentials if using 'credentials' auth mode." + ) + .define( + fileKey, + Type.STRING, + EMPTY_STRING, + Importance.HIGH, + "File containing GCP Credentials if using 'file' auth mode. This can be relative from the current working directory of the java process or from the root. Remember your path format is operating system dependent. (eg for unix-based /home/my/path/file)" + ); + } + + /** + * Parses authentication mode from the provided ConfigMap and returns the corresponding AuthMode instance. + * + * @param configMap The ConfigMap containing configuration settings. + * @return The parsed AuthMode based on the configuration settings. + * @throws ConfigException If an invalid or unsupported authentication mode is specified. + */ + public AuthMode parseFromConfig(ConfigMap configMap) { + return configMap.getString(getAuthModeKey()) + .map(authModeString -> { + switch (authModeString.toUpperCase()) { + case PROP_KEY_CREDENTIALS: + return createCredentialsAuthMode(configMap); + case PROP_KEY_FILE: + return createFileAuthMode(configMap); + case PROP_KEY_NONE: + return new NoAuthMode(); + case PROP_KEY_DEFAULT: + return new DefaultAuthMode(); + case EMPTY_STRING: + default: + throw new ConfigException(String.format("Unsupported auth mode `%s`", authModeString)); + } + }) + .orElse(new DefaultAuthMode()); + } + + private FileAuthMode createFileAuthMode(ConfigMap configMap) { + return configMap.getString(getFileKey()).map(FileAuthMode::new).orElseThrow(() -> new ConfigException(String.format("No `%s` specified in configuration", getFileKey()))); + } + + private CredentialsAuthMode createCredentialsAuthMode(ConfigMap configMap) { + return configMap.getPassword(getCredentialsKey()).map(CredentialsAuthMode::new).orElseThrow(() -> new ConfigException(String.format("No `%s` specified in configuration", getCredentialsKey()))); + } +} \ No newline at end of file diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java new file mode 100644 index 000000000..79c869479 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth; + + +import com.google.api.gax.retrying.RetrySettings; +import com.google.cloud.NoCredentials; +import com.google.cloud.ServiceOptions; +import com.google.cloud.http.HttpTransportOptions; +import io.lenses.streamreactor.common.config.base.RetryConfig; +import io.lenses.streamreactor.connect.gcp.common.auth.mode.NoAuthMode; +import lombok.val; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +class GCPServiceBuilderConfigurerTest { + + private GCPConnectionConfig.GCPConnectionConfigBuilder configBuilder; + + @BeforeEach + public void setUp() { + configBuilder = GCPConnectionConfig.builder() + .authMode(new NoAuthMode()); + } + + @Test + void testConfigure_withHostAndProjectIdConfigured() throws IOException { + val config = configBuilder + .host("example.com") + .projectId("test-project") + .build(); + + val builder = createMockBuilder(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + assertHostAndProjectIdConfigured(builder, "example.com", "test-project"); + } + + @Test + void testConfigure_withRetrySettingsConfigured() throws IOException { + RetryConfig retryConfig = RetryConfig.builder().retryIntervalMillis(1000).retryLimit(3).build(); + + val config = configBuilder.httpRetryConfig(retryConfig) + .build(); + + val builder = createMockBuilder(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + assertRetrySettingsConfigured(builder, 1000, 5000, 3); + } + + @Test + void testConfigure_withTransportOptionsConfigured() throws IOException { + val timeoutConfig = HttpTimeoutConfig.builder() + .socketTimeoutMillis(5000L) + .connectionTimeoutMillis(3000L) + .build(); + + val config = configBuilder.timeouts(timeoutConfig).build(); + + val builder = createMockBuilder(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + assertTransportOptionsConfigured(builder, 5000, 3000); + } + + @Test + void testConfigure_withEmptyConfig() throws IOException { + val builder = createMockBuilder(); + + val config = configBuilder.build(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + // Ensure that no properties are set if configuration is empty + verify(builder, never()).setHost(anyString()); + verify(builder, never()).setProjectId(anyString()); + verify(builder, times(1)).setRetrySettings(RetrySettings.newBuilder().build()); + verify(builder, times(1)).setCredentials(NoCredentials.getInstance()); + verify(builder, never()).setTransportOptions(any()); + } + + private TestSvcServiceOptionsBuilder createMockBuilder() { + return mock(TestSvcServiceOptionsBuilder.class); + } + + private void assertHostAndProjectIdConfigured(ServiceOptions.Builder builder, String expectedHost, String expectedProjectId) { + verify(builder, times(1)).setHost(expectedHost); + verify(builder, times(1)).setProjectId(expectedProjectId); + } + + private void assertRetrySettingsConfigured(ServiceOptions.Builder builder, long expectedInitialRetryDelay, long expectedMaxRetryDelay, int expectedMaxAttempts) { + ArgumentCaptor retrySettingsCaptor = ArgumentCaptor.forClass(RetrySettings.class); + verify(builder).setRetrySettings(retrySettingsCaptor.capture()); + + RetrySettings capturedRetrySettings = retrySettingsCaptor.getValue(); + assertNotNull(capturedRetrySettings); + assertEquals(1000, capturedRetrySettings.getInitialRetryDelay().toMillis()); + assertEquals(5000, capturedRetrySettings.getMaxRetryDelay().toMillis()); + assertEquals(3, capturedRetrySettings.getMaxAttempts()); + } + + private void assertTransportOptionsConfigured(ServiceOptions.Builder builder, int expectedReadTimeout, int expectedConnectTimeout) { + ArgumentCaptor transportOptionsCaptor = ArgumentCaptor.forClass(HttpTransportOptions.class); + verify(builder).setTransportOptions(transportOptionsCaptor.capture()); + + HttpTransportOptions capturedTransportOptions = transportOptionsCaptor.getValue(); + assertNotNull(capturedTransportOptions); + assertEquals(5000, capturedTransportOptions.getReadTimeout()); + assertEquals(3000, capturedTransportOptions.getConnectTimeout()); + } + +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java new file mode 100644 index 000000000..9e1d814e2 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth; + + +import com.google.cloud.BaseService; +import com.google.cloud.ServiceDefaults; +import com.google.cloud.ServiceFactory; +import com.google.cloud.ServiceOptions; +import com.google.cloud.spi.ServiceRpcFactory; + +import java.util.Set; + +class TestService extends BaseService { + protected TestService(TestSvcServiceOptions options) { + super(options); + } +} + +class TestSvcServiceOptions extends ServiceOptions { + + protected TestSvcServiceOptions(Class> serviceFactoryClass, Class> rpcFactoryClass, Builder builder, ServiceDefaults serviceDefaults) { + super(serviceFactoryClass, rpcFactoryClass, builder, serviceDefaults); + } + + @Override + protected Set getScopes() { + return null; + } + + @Override + public > B toBuilder() { + return null; + } +} + +class TestSvcServiceOptionsBuilder extends ServiceOptions.Builder { + + @Override + protected ServiceOptions build() { + return null; + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java new file mode 100644 index 000000000..edd5c636f --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.oauth2.ServiceAccountCredentials; +import lombok.val; +import org.apache.kafka.common.config.types.Password; +import org.junit.jupiter.api.Test; + +import static io.lenses.streamreactor.connect.gcp.common.auth.mode.TestFileUtil.resourceAsString; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CredentialsAuthModeTest { + + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val password = new Password(resourceAsString("/test-gcp-credentials.json")); + val authMode = new CredentialsAuthMode(password); + + val googleCredentials = (ServiceAccountCredentials) authMode.getCredentials(); + assertEquals("your-client-id", googleCredentials.getClientId()); + } + +} \ No newline at end of file diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java new file mode 100644 index 000000000..fd110041b --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.oauth2.GoogleCredentials; +import lombok.val; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class DefaultAuthModeTest { + + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val mockCreds = mock(GoogleCredentials.class); + + try (MockedStatic mockedStatic = mockStatic(GoogleCredentials.class)) { + when(GoogleCredentials.getApplicationDefault()).thenReturn(mockCreds); + + val credentials = new DefaultAuthMode().getCredentials(); + assertEquals(mockCreds, credentials); + + mockedStatic.verify(GoogleCredentials::getApplicationDefault); + } + } +} \ No newline at end of file diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java new file mode 100644 index 000000000..ed5b7d3dd --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.auth.oauth2.ServiceAccountCredentials; +import lombok.val; +import org.junit.jupiter.api.Test; + +import static io.lenses.streamreactor.connect.gcp.common.auth.mode.TestFileUtil.absolutePathForResource; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FileAuthModeTest { + + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val filePath = absolutePathForResource("/test-gcp-credentials.json"); + val authMode = new FileAuthMode(filePath); + + val googleCredentials = (ServiceAccountCredentials) authMode.getCredentials(); + assertEquals("your-client-id", googleCredentials.getClientId()); + } + +} \ No newline at end of file diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java new file mode 100644 index 000000000..f8708536b --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.cloud.NoCredentials; +import lombok.val; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NoAuthModeTest { + + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val authMode = new NoAuthMode(); + + assertEquals(NoCredentials.getInstance(), authMode.getCredentials()); + } + +} \ No newline at end of file diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java new file mode 100644 index 000000000..ddbd1fca2 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.auth.mode; + +import com.google.common.io.ByteStreams; +import lombok.experimental.UtilityClass; + +import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import static com.google.common.base.Preconditions.checkNotNull; + +@UtilityClass +public class TestFileUtil { + + static String streamToString(InputStream inputStream) throws Exception { + byte[] bytes = ByteStreams.toByteArray(inputStream); + return new String(bytes, StandardCharsets.UTF_8); + } + + static String resourceAsString(String resourceFile) throws Exception { + return streamToString(TestFileUtil.class.getResourceAsStream(resourceFile)); + } + static String absolutePathForResource(String resourceName) { + URL resourceUrl = TestFileUtil.class.getResource(resourceName); + checkNotNull(resourceUrl, String.format("Resource not found: %s", resourceName)); + return new File(resourceUrl.getFile()).getAbsolutePath(); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java new file mode 100644 index 000000000..93a882465 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java @@ -0,0 +1,134 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.config; + +import io.lenses.streamreactor.common.config.base.ConfigMap; +import io.lenses.streamreactor.common.config.base.model.ConnectorPrefix; +import io.lenses.streamreactor.connect.gcp.common.auth.mode.CredentialsAuthMode; +import io.lenses.streamreactor.connect.gcp.common.auth.mode.DefaultAuthMode; +import io.lenses.streamreactor.connect.gcp.common.auth.mode.FileAuthMode; +import io.lenses.streamreactor.connect.gcp.common.auth.mode.NoAuthMode; +import lombok.val; +import org.apache.kafka.common.config.ConfigDef; +import org.apache.kafka.common.config.ConfigException; +import org.apache.kafka.common.config.types.Password; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class AuthModeSettingsTest { + + private AuthModeSettings authModeSettings; + private final String CONNECTOR_PREFIX = "test.connector"; + + @BeforeEach + public void setUp() { + val connectorPrefix = new ConnectorPrefix(CONNECTOR_PREFIX); + authModeSettings = new AuthModeSettings(connectorPrefix); + } + + @Test + void testGenerateKey() { + assertEquals("test.connector.gcp.auth.mode", authModeSettings.getAuthModeKey()); + assertEquals("test.connector.gcp.credentials", authModeSettings.getCredentialsKey()); + assertEquals("test.connector.gcp.file", authModeSettings.getFileKey()); + } + + @Test + void testWithAuthModeSettings() { + val configDef = new ConfigDef(); + val result = authModeSettings.withAuthModeSettings(configDef); + + assertNotNull(result); + assertTrue(result.configKeys().containsKey(authModeSettings.getAuthModeKey())); + assertTrue(result.configKeys().containsKey(authModeSettings.getCredentialsKey())); + assertTrue(result.configKeys().containsKey(authModeSettings.getFileKey())); + } + + @Test + void testParseFromConfig_CredentialsAuthMode() { + val configMap = new ConfigMap( + Map.of( + authModeSettings.getAuthModeKey(), "credentials", + authModeSettings.getCredentialsKey(), new Password("password") + ) + ); + + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof CredentialsAuthMode); + } + + @Test + void testParseFromConfig_FileAuthMode() { + + val configMap = new ConfigMap( + Map.of( + authModeSettings.getAuthModeKey(), "file", + authModeSettings.getFileKey(), "\"path/to/file\"" + ) + ); + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof FileAuthMode); + } + + @Test + void testParseFromConfig_NoneAuthMode() { + + val configMap = new ConfigMap( + Map.of( + authModeSettings.getAuthModeKey(), "none" + ) + ); + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof NoAuthMode); + } + + @Test + void testParseFromConfig_DefaultAuthMode() { + + val configMap = new ConfigMap( + Map.of( + authModeSettings.getAuthModeKey(), "default" + ) + ); + + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof DefaultAuthMode); + } + + @Test + void testParseFromConfig_UnsupportedAuthMode() { + + val configMap = new ConfigMap( + Map.of( + authModeSettings.getAuthModeKey(), "invalid" + ) + ); + + assertThrows(ConfigException.class, () -> authModeSettings.parseFromConfig(configMap)); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/resources/test-gcp-credentials.json b/java-connectors/kafka-connect-gcp-common/src/test/resources/test-gcp-credentials.json new file mode 100644 index 000000000..58c82241a --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/resources/test-gcp-credentials.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "your-project-id", + "private_key_id": "your-private-key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC8N9leow0Hgn0x\now2wCuUpp2vMiAPZ+AOGrkhsJjWCqzbU+VoOdIg3LmgR6NKjLkEWaSgONFmSSNBL\nIU97Z/LB2+VoBwg14kI4f+tAus7JAwp0PvDMtri7oURwh4z9zTmk7KFpyBzNK31N\nmoOD/Zb9cw+4sQGhdZ/1CwSwrryy5rUhojdeK2EUDwI7cIqefM8s7QK5YRqldB7t\ns0LTxr3f4M0Q8pH78ZJcxS5wdlqSCd4xly2hRK+E6Ogt+0nc8N5XJ0AwV0+adM9E\nShyFiWP62Lvp3rWl+2c4tfDQNWqSb0Xxilp0K1Y0jdn3UClA6cOqleTPgjNrs+ve\nPmIERDmlAgMBAAECggEBAINiqQX647l+SU5A9+kPcfClwgZAXA/npO568mssMOZK\nBjic51R33C5I4rS9xWvLefve4+smd/A5i80mL4mBgl/41CbN4dlbW8Z85QeGX5rJ\n2J5U4VrFoia36EJ1HOJ+Q+Lwm6xv2EsQNKPbXDri5md9zleql+zRYxt22YtMLsTi\nOyORN24w3P+8K9+QL7o+Z8Wuj7o550bCUQrXOw21C3sXinJBn0BeoCgyxNLRwygH\n7P1ibQKozfUTFDn75TWPC5VDWrpN/165fWN5nwrIh6Zt5aSMgSE15E9sV8i1uLpk\n+XLA3Qtqupe0SuC72c+DHR/crhrGcR6J0ffBmUXvfgECgYEA87SkBHXPlAfBOkTV\n2pe3+Q1KjBenkB3rodKOoS6G10FjqP1WOcCqJ6dJsE8nnEX8YWD/3rFtDlzXBAeC\nXWhjaJBa+tn/05jaIgVlwLGsdIrcJoaucUcv4wzvHyeNRQ3kuvXTdsp0bSL/ON1V\nmFkGU7eJo00E8T4I+pqnmZf9EWECgYEAxbacO182mqjTNhdwxG00yZDhzQFWghZO\n/JLTew0WCX3naL01xmoShY2lUlyKz0D0otwCiMUOqsVaVtwXYYNHjM60i12t8UTz\nid/Vumr/ruli1LgTvblXeMU79MuFmcyg/pK5D8d8Xcjtz9UcpFDcoRCET1iEZkFN\n0wPdmTgCGsUCgYEAmjLzEKtmYzig53iEg6I50si5IXkaGdMEs8hhXNTulqaWI2fg\nNfyU7TApLPh4jKWvsgHJBCPpaAwQNEl4EBgrxg6Ism5bM4xkgOA/aLRC9R6je3D+\nUUiEoToe1uyUs1u+HRnL6j7heeiJ6nYJYbL6kN/xo7To6qeg0MgoQcPsaSECgYAt\nEXo9gm+1A4TZ0LAQ5n/g7pi6HXL1xlYM9v8kDpCWa0/DrVXDu8wrC2XDB3tQ0RKy\nyQn+2USFouT75cGipcU6kKfRGPKci8YkCJT0oI1V9rdjm+5MEiKhUfxfycDTlTSh\nsxpiQWvVCQdEl+SmhBQ21sgCOkA7+ujdkAUEdyLrrQKBgQCqxaZXPa/liHlq5og1\n1AvTkNDFEv8BHfc83u0kjcjkY5cg42rmnA+AL292Am1AVQhvRIkejIAU3QruaXpW\n/lufkeVokwe+8CvQrLFUfm+jG8/pw+NOqj0UlAjDVW26HS9ii7T8plXCsSkU+NFY\nDryzCRcEMVSpY1IKtTHqzLP5yA==\n-----END PRIVATE KEY-----", + "client_email": "your-service-account-email@your-project.iam.gserviceaccount.com", + "client_id": "your-client-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/your-service-account-email%40your-project.iam.gserviceaccount.com" +} \ No newline at end of file diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala index 24c5aae00..e95a562b8 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3AvroWriterManagerTest.scala @@ -17,6 +17,9 @@ package io.lenses.streamreactor.connect.aws.s3.sink import cats.implicits.catsSyntaxOptionId +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.ErrorPolicy +import io.lenses.streamreactor.common.errors.ErrorPolicyEnum import io.lenses.streamreactor.connect.aws.s3.config._ import io.lenses.streamreactor.connect.aws.s3.model.location.S3LocationValidator import io.lenses.streamreactor.connect.aws.s3.sink.config.S3SinkConfig @@ -101,9 +104,11 @@ class S3AvroWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont dataStorage = DataStorageSettings.disabled, ), ), - offsetSeekerOptions = OffsetSeekerOptions(5), - compressionCodec = compressionCodec, - batchDelete = true, + offsetSeekerOptions = OffsetSeekerOptions(5), + compressionCodec = compressionCodec, + batchDelete = true, + errorPolicy = ErrorPolicy(ErrorPolicyEnum.THROW), + connectorRetryConfig = new RetryConfig(1, 1L), ) "avro sink" should "write 2 records to avro format in s3" in { diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala index 08e1ced45..b66a38c2e 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3JsonWriterManagerTest.scala @@ -17,6 +17,9 @@ package io.lenses.streamreactor.connect.aws.s3.sink import cats.implicits.catsSyntaxOptionId +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.ErrorPolicy +import io.lenses.streamreactor.common.errors.ErrorPolicyEnum import io.lenses.streamreactor.connect.aws.s3.config._ import io.lenses.streamreactor.connect.aws.s3.model.location.S3LocationValidator import io.lenses.streamreactor.connect.aws.s3.sink.config.S3SinkConfig @@ -57,6 +60,7 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import java.time.Instant + import scala.jdk.CollectionConverters._ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyContainerTest { @@ -103,7 +107,9 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ), offsetSeekerOptions = OffsetSeekerOptions(5), compressionCodec, - batchDelete = true, + batchDelete = true, + errorPolicy = ErrorPolicy(ErrorPolicyEnum.THROW), + connectorRetryConfig = new RetryConfig(1, 1L), ) val sink = writerManagerCreator.from(config) @@ -164,7 +170,9 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ), offsetSeekerOptions = OffsetSeekerOptions(5), compressionCodec, - batchDelete = true, + batchDelete = true, + errorPolicy = ErrorPolicy(ErrorPolicyEnum.THROW), + connectorRetryConfig = new RetryConfig(1, 1L), ) val sink = writerManagerCreator.from(config) @@ -229,7 +237,9 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ), offsetSeekerOptions = OffsetSeekerOptions(5), compressionCodec, - batchDelete = true, + batchDelete = true, + errorPolicy = ErrorPolicy(ErrorPolicyEnum.THROW), + connectorRetryConfig = new RetryConfig(1, 1L), ) val sink = writerManagerCreator.from(config) @@ -300,7 +310,9 @@ class S3JsonWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyCont ), offsetSeekerOptions = OffsetSeekerOptions(5), compressionCodec, - batchDelete = true, + batchDelete = true, + errorPolicy = ErrorPolicy(ErrorPolicyEnum.THROW), + connectorRetryConfig = new RetryConfig(1, 1L), ) val sink = writerManagerCreator.from(config) diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala index 0a9545322..36fa1820a 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3ParquetWriterManagerTest.scala @@ -17,6 +17,9 @@ package io.lenses.streamreactor.connect.aws.s3.sink import cats.implicits.catsSyntaxOptionId +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.ErrorPolicy +import io.lenses.streamreactor.common.errors.ErrorPolicyEnum import io.lenses.streamreactor.connect.aws.s3.config._ import io.lenses.streamreactor.connect.aws.s3.model.location.S3LocationValidator import io.lenses.streamreactor.connect.aws.s3.sink.config.S3SinkConfig @@ -101,7 +104,9 @@ class S3ParquetWriterManagerTest extends AnyFlatSpec with Matchers with S3ProxyC ), offsetSeekerOptions = OffsetSeekerOptions(5), compressionCodec, - batchDelete = true, + batchDelete = true, + errorPolicy = ErrorPolicy(ErrorPolicyEnum.THROW), + connectorRetryConfig = new RetryConfig(1, 1L), ) "parquet sink" should "write 2 records to parquet format in s3" in { diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskTest.scala index bb299f512..d3b09edc6 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTaskTest.scala @@ -18,6 +18,7 @@ package io.lenses.streamreactor.connect.aws.s3.sink import cats.effect.IO import cats.effect.kernel.Resource import cats.effect.unsafe.implicits.global +import io.lenses.streamreactor.connect.aws.s3.config.S3ConnectionConfig import io.lenses.streamreactor.connect.aws.s3.sink.config.S3SinkConfig import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface import io.lenses.streamreactor.connect.aws.s3.storage.S3FileMetadata @@ -29,7 +30,14 @@ import software.amazon.awssdk.services.s3.S3Client import scala.jdk.CollectionConverters.MapHasAsJava class S3SinkTaskTest - extends CoreSinkTaskTestCases[S3FileMetadata, AwsS3StorageInterface, S3SinkConfig, S3Client, S3SinkTask]( + extends CoreSinkTaskTestCases[ + S3FileMetadata, + AwsS3StorageInterface, + S3SinkConfig, + S3ConnectionConfig, + S3Client, + S3SinkTask, + ]( "S3SinkTask", ) with S3ProxyContainerTest { diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala index 3a1041211..d85998041 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/utils/S3ProxyContainerTest.scala @@ -25,7 +25,14 @@ import java.nio.file.Files import scala.util.Try trait S3ProxyContainerTest - extends CloudPlatformEmulatorSuite[S3FileMetadata, AwsS3StorageInterface, S3SinkConfig, S3Client, S3SinkTask] + extends CloudPlatformEmulatorSuite[ + S3FileMetadata, + AwsS3StorageInterface, + S3SinkConfig, + S3ConnectionConfig, + S3Client, + S3SinkTask, + ] with TaskIndexKey with LazyLogging { diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala index 0ec9e4cc5..912964d59 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala @@ -3,6 +3,7 @@ package io.lenses.streamreactor.connect.cloud.common.sink import cats.implicits.catsSyntaxEitherId import cats.implicits.catsSyntaxOptionId import cats.implicits.toBifunctorOps +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig import io.lenses.streamreactor.connect.cloud.common.storage.FileMetadata import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface @@ -15,11 +16,12 @@ import org.scalatest.flatspec.AnyFlatSpec import scala.util.Try trait CloudPlatformEmulatorSuite[ - SM <: FileMetadata, - SI <: StorageInterface[SM], - CSC <: CloudSinkConfig, - C, - T <: CloudSinkTask[SM, CSC, C], + MD <: FileMetadata, + SI <: StorageInterface[MD], + C <: CloudSinkConfig[CC], + CC <: ConnectionConfig, + CT, + T <: CloudSinkTask[MD, C, CC, CT], ] extends AnyFlatSpec with BeforeAndAfter with BeforeAndAfterAll @@ -34,17 +36,17 @@ trait CloudPlatformEmulatorSuite[ var maybeStorageInterface: Option[SI] = None - var maybeClient: Option[C] = None + var maybeClient: Option[CT] = None implicit def storageInterface: SI = maybeStorageInterface.getOrElse(fail("Unset SI")) - def client: C = maybeClient.getOrElse(fail("Unset client")) + def client: CT = maybeClient.getOrElse(fail("Unset client")) - def createClient(): Either[Throwable, C] - def createStorageInterface(client: C): Either[Throwable, SI] + def createClient(): Either[Throwable, CT] + def createStorageInterface(client: CT): Either[Throwable, SI] val defaultProps: Map[String, String] - def createBucket(client: C): Either[Throwable, Unit] + def createBucket(client: CT): Either[Throwable, Unit] override protected def beforeAll(): Unit = { diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala index fc325da31..721459a30 100644 --- a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala @@ -5,6 +5,7 @@ import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.MAX_RET import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.RETRY_INTERVAL_PROP_SUFFIX import com.opencsv.CSVReader import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushSize @@ -50,13 +51,14 @@ import scala.util.Failure import scala.util.Success import scala.util.Try abstract class CoreSinkTaskTestCases[ - SM <: FileMetadata, - SI <: StorageInterface[SM], - CSC <: CloudSinkConfig, - C, - T <: CloudSinkTask[SM, CSC, C], + MD <: FileMetadata, + SI <: StorageInterface[MD], + C <: CloudSinkConfig[CC], + CC <: ConnectionConfig, + CT, + T <: CloudSinkTask[MD, C, CC, CT], ](unitUnderTest: String, -) extends CloudPlatformEmulatorSuite[SM, SI, CSC, C, T] +) extends CloudPlatformEmulatorSuite[MD, SI, C, CC, CT, T] with Matchers with MockitoSugar with LazyLogging diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/auth/AwsS3ClientCreator.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/auth/AwsS3ClientCreator.scala index f66741538..633a10387 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/auth/AwsS3ClientCreator.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/auth/AwsS3ClientCreator.scala @@ -48,9 +48,9 @@ object AwsS3ClientCreator extends ClientCreator[S3ConnectionConfig, S3Client] { retryPolicy <- Try { RetryPolicy .builder() - .numRetries(config.httpRetryConfig.numberOfRetries) + .numRetries(config.httpRetryConfig.getRetryLimit) .backoffStrategy( - FixedDelayBackoffStrategy.create(Duration.ofMillis(config.httpRetryConfig.errorRetryInterval)), + FixedDelayBackoffStrategy.create(Duration.ofMillis(config.httpRetryConfig.getRetryIntervalMillis)), ) .build() }.toEither diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/CommonConfigDef.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/CommonConfigDef.scala deleted file mode 100644 index b9002c2bc..000000000 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/CommonConfigDef.scala +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.connect.aws.s3.config - -import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ -import io.lenses.streamreactor.connect.aws.s3.source.config.S3SourceConfigDef -import io.lenses.streamreactor.connect.cloud.common.config.CompressionCodecConfigKeys -import org.apache.kafka.common.config.ConfigDef -import org.apache.kafka.common.config.ConfigDef.Importance -import org.apache.kafka.common.config.ConfigDef.Type - -trait CommonConfigDef extends CompressionCodecConfigKeys with DeleteModeConfigKeys { - - def config: ConfigDef = new S3SourceConfigDef() - .define( - AWS_REGION, - Type.STRING, - "", - Importance.HIGH, - "AWS region", - ) - .define( - AWS_ACCESS_KEY, - Type.PASSWORD, - "", - Importance.HIGH, - "AWS access key", - ) - .define( - AWS_SECRET_KEY, - Type.PASSWORD, - "", - Importance.HIGH, - "AWS password key", - ) - .define( - AUTH_MODE, - Type.STRING, - AuthMode.Default.toString, - Importance.HIGH, - "Authenticate mode, 'credentials' or 'default'", - ) - .define( - CUSTOM_ENDPOINT, - Type.STRING, - "", - Importance.LOW, - "Custom S3-compatible endpoint (usually for testing)", - ) - .define( - ENABLE_VIRTUAL_HOST_BUCKETS, - Type.BOOLEAN, - false, - Importance.LOW, - "Enable virtual host buckets", - ) - .define(KCQL_CONFIG, Type.STRING, Importance.HIGH, KCQL_DOC) - .define( - ERROR_POLICY, - Type.STRING, - ERROR_POLICY_DEFAULT, - Importance.HIGH, - ERROR_POLICY_DOC, - "Error", - 1, - ConfigDef.Width.LONG, - ERROR_POLICY, - ) - .define( - NBR_OF_RETRIES, - Type.INT, - NBR_OF_RETIRES_DEFAULT, - Importance.MEDIUM, - NBR_OF_RETRIES_DOC, - "Error", - 2, - ConfigDef.Width.LONG, - NBR_OF_RETRIES, - ) - .define( - ERROR_RETRY_INTERVAL, - Type.LONG, - ERROR_RETRY_INTERVAL_DEFAULT, - Importance.MEDIUM, - ERROR_RETRY_INTERVAL_DOC, - "Error", - 3, - ConfigDef.Width.LONG, - ERROR_RETRY_INTERVAL, - ) - .define( - HTTP_NBR_OF_RETRIES, - Type.INT, - HTTP_NBR_OF_RETIRES_DEFAULT, - Importance.MEDIUM, - HTTP_NBR_OF_RETRIES_DOC, - "Error", - 2, - ConfigDef.Width.LONG, - HTTP_NBR_OF_RETRIES, - ) - .define( - HTTP_ERROR_RETRY_INTERVAL, - Type.LONG, - HTTP_ERROR_RETRY_INTERVAL_DEFAULT, - Importance.MEDIUM, - HTTP_ERROR_RETRY_INTERVAL_DOC, - "Error", - 3, - ConfigDef.Width.LONG, - HTTP_ERROR_RETRY_INTERVAL, - ) - .define(HTTP_SOCKET_TIMEOUT, Type.LONG, HTTP_SOCKET_TIMEOUT_DEFAULT, Importance.LOW, HTTP_SOCKET_TIMEOUT_DOC) - .define(HTTP_CONNECTION_TIMEOUT, - Type.INT, - HTTP_CONNECTION_TIMEOUT_DEFAULT, - Importance.LOW, - HTTP_CONNECTION_TIMEOUT_DOC, - ) - .define( - POOL_MAX_CONNECTIONS, - Type.INT, - POOL_MAX_CONNECTIONS_DEFAULT, - Importance.LOW, - POOL_MAX_CONNECTIONS_DOC, - ) - .define( - COMPRESSION_CODEC, - Type.STRING, - COMPRESSION_CODEC_DEFAULT, - Importance.LOW, - COMPRESSION_CODEC_DOC, - ) - .define( - COMPRESSION_LEVEL, - Type.INT, - COMPRESSION_LEVEL_DEFAULT, - Importance.LOW, - COMPRESSION_LEVEL_DOC, - ) - .define( - DELETE_MODE, - Type.STRING, - DELETE_MODE_DEFAULT, - Importance.LOW, - DELETE_MODE_DOC, - ) -} diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3CommonConfigDef.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3CommonConfigDef.scala new file mode 100644 index 000000000..a249898f4 --- /dev/null +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3CommonConfigDef.scala @@ -0,0 +1,144 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.aws.s3.config + +import io.lenses.streamreactor.common.config.base.traits.ConnectorRetryConfigKeys +import io.lenses.streamreactor.common.config.base.traits.ErrorPolicyConfigKey +import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ +import io.lenses.streamreactor.connect.aws.s3.config.processors.kcql.DeprecationConfigDefProcessor +import io.lenses.streamreactor.connect.cloud.common.config.CloudConfigDef +import io.lenses.streamreactor.connect.cloud.common.config.CompressionCodecConfigKeys +import org.apache.kafka.common.config.ConfigDef +import org.apache.kafka.common.config.ConfigDef.Importance +import org.apache.kafka.common.config.ConfigDef.Type + +class S3ConfigDef() extends CloudConfigDef(CONNECTOR_PREFIX, new DeprecationConfigDefProcessor()) {} + +/** + * ConfigDef elements shared between both sink and source. + */ +trait S3CommonConfigDef + extends CompressionCodecConfigKeys + with ConnectorRetryConfigKeys + with ErrorPolicyConfigKey + with DeleteModeConfigKeys { + + def config: ConfigDef = { + val config = new S3ConfigDef() + .define( + AWS_REGION, + Type.STRING, + "", + Importance.HIGH, + "AWS region", + ) + .define( + AWS_ACCESS_KEY, + Type.PASSWORD, + "", + Importance.HIGH, + "AWS access key", + ) + .define( + AWS_SECRET_KEY, + Type.PASSWORD, + "", + Importance.HIGH, + "AWS password key", + ) + .define( + AUTH_MODE, + Type.STRING, + AuthMode.Default.toString, + Importance.HIGH, + "Authenticate mode, 'credentials' or 'default'", + ) + .define( + CUSTOM_ENDPOINT, + Type.STRING, + "", + Importance.LOW, + "Custom S3-compatible endpoint (usually for testing)", + ) + .define( + ENABLE_VIRTUAL_HOST_BUCKETS, + Type.BOOLEAN, + false, + Importance.LOW, + "Enable virtual host buckets", + ) + .define(KCQL_CONFIG, Type.STRING, Importance.HIGH, KCQL_DOC) + .define( + HTTP_NBR_OF_RETRIES, + Type.INT, + HTTP_NBR_OF_RETIRES_DEFAULT, + Importance.MEDIUM, + HTTP_NBR_OF_RETRIES_DOC, + "Error", + 2, + ConfigDef.Width.LONG, + HTTP_NBR_OF_RETRIES, + ) + .define( + HTTP_ERROR_RETRY_INTERVAL, + Type.LONG, + HTTP_ERROR_RETRY_INTERVAL_DEFAULT, + Importance.MEDIUM, + HTTP_ERROR_RETRY_INTERVAL_DOC, + "Error", + 3, + ConfigDef.Width.LONG, + HTTP_ERROR_RETRY_INTERVAL, + ) + .define(HTTP_SOCKET_TIMEOUT, Type.LONG, HTTP_SOCKET_TIMEOUT_DEFAULT, Importance.LOW, HTTP_SOCKET_TIMEOUT_DOC) + .define(HTTP_CONNECTION_TIMEOUT, + Type.INT, + HTTP_CONNECTION_TIMEOUT_DEFAULT, + Importance.LOW, + HTTP_CONNECTION_TIMEOUT_DOC, + ) + .define( + POOL_MAX_CONNECTIONS, + Type.INT, + POOL_MAX_CONNECTIONS_DEFAULT, + Importance.LOW, + POOL_MAX_CONNECTIONS_DOC, + ) + .define( + COMPRESSION_CODEC, + Type.STRING, + COMPRESSION_CODEC_DEFAULT, + Importance.LOW, + COMPRESSION_CODEC_DOC, + ) + .define( + COMPRESSION_LEVEL, + Type.INT, + COMPRESSION_LEVEL_DEFAULT, + Importance.LOW, + COMPRESSION_LEVEL_DOC, + ) + .define( + DELETE_MODE, + Type.STRING, + DELETE_MODE_DEFAULT, + Importance.LOW, + DELETE_MODE_DOC, + ) + withConnectorRetryConfig(config) + withErrorPolicyConfig(config) + } +} diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigSettings.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigSettings.scala index c51928ea5..7861d0b77 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigSettings.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigSettings.scala @@ -32,26 +32,6 @@ object S3ConfigSettings { val KCQL_DOC = "Contains the Kafka Connect Query Language describing the flow from Apache Kafka topics to Apache Hive tables." - val ERROR_POLICY = s"$CONNECTOR_PREFIX.$ERROR_POLICY_PROP_SUFFIX" - val ERROR_POLICY_DOC: String = - """ - |Specifies the action to be taken if an error occurs while inserting the data. - | There are three available options: - | NOOP - the error is swallowed - | THROW - the error is allowed to propagate. - | RETRY - The exception causes the Connect framework to retry the message. The number of retries is set by connect.s3.max.retries. - |All errors will be logged automatically, even if the code swallows them. - """.stripMargin - val ERROR_POLICY_DEFAULT = "THROW" - - val ERROR_RETRY_INTERVAL = s"$CONNECTOR_PREFIX.$RETRY_INTERVAL_PROP_SUFFIX" - val ERROR_RETRY_INTERVAL_DOC = "The time in milliseconds between retries." - val ERROR_RETRY_INTERVAL_DEFAULT: Long = 60000L - - val NBR_OF_RETRIES = s"$CONNECTOR_PREFIX.$MAX_RETRIES_PROP_SUFFIX" - val NBR_OF_RETRIES_DOC = "The maximum number of times to try the write again." - val NBR_OF_RETIRES_DEFAULT: Int = 20 - val HTTP_ERROR_RETRY_INTERVAL = s"$CONNECTOR_PREFIX.http.$RETRY_INTERVAL_PROP_SUFFIX" val HTTP_ERROR_RETRY_INTERVAL_DOC = "If greater than zero, used to determine the delay after which to retry the http request in milliseconds. Based on an exponential backoff algorithm." diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConnectionConfig.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConnectionConfig.scala index c496736e5..4c784ec35 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConnectionConfig.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConnectionConfig.scala @@ -15,19 +15,16 @@ */ package io.lenses.streamreactor.connect.aws.s3.config -import io.lenses.streamreactor.common.errors.ErrorPolicy -import io.lenses.streamreactor.common.errors.ErrorPolicyEnum -import io.lenses.streamreactor.common.errors.ThrowErrorPolicy import enumeratum.Enum import enumeratum.EnumEntry +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getBoolean import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getInt import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getLong import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getPassword import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getString -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig -import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudConnectionConfig import scala.collection.immutable @@ -54,12 +51,7 @@ object S3ConnectionConfig { ), getString(props, CUSTOM_ENDPOINT), getBoolean(props, ENABLE_VIRTUAL_HOST_BUCKETS).getOrElse(false), - getErrorPolicy(props), - RetryConfig( - getInt(props, NBR_OF_RETRIES).getOrElse(NBR_OF_RETIRES_DEFAULT), - getLong(props, ERROR_RETRY_INTERVAL).getOrElse(ERROR_RETRY_INTERVAL_DEFAULT), - ), - RetryConfig( + new RetryConfig( getInt(props, HTTP_NBR_OF_RETRIES).getOrElse(HTTP_NBR_OF_RETIRES_DEFAULT), getLong(props, HTTP_ERROR_RETRY_INTERVAL).getOrElse(HTTP_ERROR_RETRY_INTERVAL_DEFAULT), ), @@ -71,11 +63,6 @@ object S3ConnectionConfig { getInt(props, POOL_MAX_CONNECTIONS), ), ) - - private def getErrorPolicy(props: Map[String, _]) = - ErrorPolicy( - ErrorPolicyEnum.withName(getString(props, ERROR_POLICY).map(_.toUpperCase()).getOrElse(ERROR_POLICY_DEFAULT)), - ) } case class HttpTimeoutConfig(socketTimeout: Option[Int], connectionTimeout: Option[Long]) @@ -94,9 +81,7 @@ case class S3ConnectionConfig( authMode: AuthMode, customEndpoint: Option[String] = None, enableVirtualHostBuckets: Boolean = false, - errorPolicy: ErrorPolicy = ThrowErrorPolicy(), - connectorRetryConfig: RetryConfig = RetryConfig(NBR_OF_RETIRES_DEFAULT, ERROR_RETRY_INTERVAL_DEFAULT), - httpRetryConfig: RetryConfig = RetryConfig(HTTP_NBR_OF_RETIRES_DEFAULT, HTTP_ERROR_RETRY_INTERVAL_DEFAULT), + httpRetryConfig: RetryConfig = new RetryConfig(HTTP_NBR_OF_RETIRES_DEFAULT, HTTP_ERROR_RETRY_INTERVAL_DEFAULT), timeouts: HttpTimeoutConfig = HttpTimeoutConfig(None, None), connectionPoolConfig: Option[ConnectionPoolConfig] = Option.empty, -) extends CloudConnectionConfig +) extends ConnectionConfig diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala index 315f4dd99..76b9cda00 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/S3SinkTask.scala @@ -18,6 +18,7 @@ package io.lenses.streamreactor.connect.aws.s3.sink import io.lenses.streamreactor.common.util.JarManifest import io.lenses.streamreactor.connect.aws.s3.auth.AwsS3ClientCreator import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings +import io.lenses.streamreactor.connect.aws.s3.config.S3ConnectionConfig import io.lenses.streamreactor.connect.aws.s3.model.location.S3LocationValidator import io.lenses.streamreactor.connect.aws.s3.sink.config.S3SinkConfig import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface @@ -30,7 +31,12 @@ import software.amazon.awssdk.services.s3.S3Client object S3SinkTask {} class S3SinkTask - extends CloudSinkTask[S3FileMetadata, S3SinkConfig, S3Client]( + extends CloudSinkTask[ + S3FileMetadata, + S3SinkConfig, + S3ConnectionConfig, + S3Client, + ]( S3ConfigSettings.CONNECTOR_PREFIX, "/aws-s3-sink-ascii.txt", new JarManifest(S3SinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), @@ -45,8 +51,8 @@ class S3SinkTask ): AwsS3StorageInterface = new AwsS3StorageInterface(connectorTaskId, cloudClient, config.batchDelete) - override def createClient(config: S3SinkConfig): Either[Throwable, S3Client] = - AwsS3ClientCreator.make(config.connectionConfig) + override def createClient(config: S3ConnectionConfig): Either[Throwable, S3Client] = + AwsS3ClientCreator.make(config) override def convertPropsToConfig( connectorTaskId: ConnectorTaskId, diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfig.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfig.scala index 0ca138d48..e3bb32113 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfig.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfig.scala @@ -15,6 +15,8 @@ */ package io.lenses.streamreactor.connect.aws.s3.sink.config +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.ErrorPolicy import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings.SEEK_MAX_INDEX_FILES import io.lenses.streamreactor.connect.aws.s3.config.S3ConnectionConfig import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId @@ -31,7 +33,7 @@ object S3SinkConfig extends PropsToConfigConverter[S3SinkConfig] { override def fromProps( connectorTaskId: ConnectorTaskId, - props: Map[String, String], + props: Map[String, AnyRef], )( implicit cloudLocationValidator: CloudLocationValidator, @@ -59,14 +61,18 @@ object S3SinkConfig extends PropsToConfigConverter[S3SinkConfig] { offsetSeekerOptions, s3ConfigDefBuilder.getCompressionCodec(), s3ConfigDefBuilder.batchDelete(), + errorPolicy = s3ConfigDefBuilder.getErrorPolicyOrDefault, + connectorRetryConfig = s3ConfigDefBuilder.getRetryConfig, ) } case class S3SinkConfig( - connectionConfig: S3ConnectionConfig, - bucketOptions: Seq[CloudSinkBucketOptions] = Seq.empty, - offsetSeekerOptions: OffsetSeekerOptions, - compressionCodec: CompressionCodec, - batchDelete: Boolean, -) extends CloudSinkConfig + connectionConfig: S3ConnectionConfig, + bucketOptions: Seq[CloudSinkBucketOptions] = Seq.empty, + offsetSeekerOptions: OffsetSeekerOptions, + compressionCodec: CompressionCodec, + batchDelete: Boolean, + errorPolicy: ErrorPolicy, + connectorRetryConfig: RetryConfig, +) extends CloudSinkConfig[S3ConnectionConfig] diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDef.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDef.scala index ae99b96cb..d523394bf 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDef.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDef.scala @@ -27,7 +27,7 @@ import org.apache.kafka.common.config.ConfigDef.Importance import org.apache.kafka.common.config.ConfigDef.Type object S3SinkConfigDef - extends CommonConfigDef + extends S3CommonConfigDef with FlushConfigKeys with LocalStagingAreaConfigKeys with PaddingStrategyConfigKeys { diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDefBuilder.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDefBuilder.scala index 18b80d55d..b1adc19c7 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDefBuilder.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigDefBuilder.scala @@ -22,11 +22,11 @@ import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkConfigD import scala.jdk.CollectionConverters.MapHasAsScala -case class S3SinkConfigDefBuilder(props: Map[String, String]) +case class S3SinkConfigDefBuilder(props: Map[String, AnyRef]) extends BaseConfig(S3ConfigSettings.CONNECTOR_PREFIX, S3SinkConfigDef.config, props) with CloudSinkConfigDefBuilder with ErrorPolicySettings - with NumberRetriesSettings + with RetryConfigSettings with DeleteModeSettings { def getParsedValues: Map[String, _] = values().asScala.toMap diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfig.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfig.scala index 488ca8f8a..5f64167e9 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfig.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfig.scala @@ -32,7 +32,7 @@ object S3SourceConfig extends PropsToConfigConverter[S3SourceConfig] { override def fromProps( connectorTaskId: ConnectorTaskId, - props: Map[String, String], + props: Map[String, AnyRef], )( implicit cloudLocationValidator: CloudLocationValidator, diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDef.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDef.scala index d0eedcec6..22c2c9aca 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDef.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDef.scala @@ -17,12 +17,10 @@ package io.lenses.streamreactor.connect.aws.s3.source.config import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ import io.lenses.streamreactor.connect.aws.s3.config._ -import io.lenses.streamreactor.connect.aws.s3.config.processors.kcql.DeprecationConfigDefProcessor -import io.lenses.streamreactor.connect.cloud.common.config.CloudConfigDef import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceSettingsKeys import org.apache.kafka.common.config.ConfigDef -object S3SourceConfigDef extends CommonConfigDef with CloudSourceSettingsKeys { +object S3SourceConfigDef extends S3CommonConfigDef with CloudSourceSettingsKeys { override def connectorPrefix: String = CONNECTOR_PREFIX @@ -34,5 +32,3 @@ object S3SourceConfigDef extends CommonConfigDef with CloudSourceSettingsKeys { addSourcePartitionExtractorSettings(settings) } } - -class S3SourceConfigDef() extends CloudConfigDef(CONNECTOR_PREFIX, new DeprecationConfigDefProcessor()) {} diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDefBuilder.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDefBuilder.scala index f5588cbb8..a3cb76c65 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDefBuilder.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/source/config/S3SourceConfigDefBuilder.scala @@ -21,7 +21,7 @@ import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceCon import scala.jdk.CollectionConverters.MapHasAsScala -case class S3SourceConfigDefBuilder(props: Map[String, String]) +case class S3SourceConfigDefBuilder(props: Map[String, AnyRef]) extends CloudSourceConfigDefBuilder(S3ConfigSettings.CONNECTOR_PREFIX, S3SourceConfigDef.config, props) with DeleteModeSettings { diff --git a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/CommonConfigDefTest.scala b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3CommonConfigDefTest.scala similarity index 95% rename from kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/CommonConfigDefTest.scala rename to kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3CommonConfigDefTest.scala index c9ce6e7fb..60d43fecc 100644 --- a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/CommonConfigDefTest.scala +++ b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3CommonConfigDefTest.scala @@ -25,7 +25,7 @@ import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala import scala.util.Try -class CommonConfigDefTest extends AnyFlatSpec with Matchers with EitherValues { +class S3CommonConfigDefTest extends AnyFlatSpec with Matchers with EitherValues { private val DeprecatedProps: Map[String, String] = Map( DEP_AWS_ACCESS_KEY -> "DepAccessKey", @@ -45,7 +45,7 @@ class CommonConfigDefTest extends AnyFlatSpec with Matchers with EitherValues { KCQL_CONFIG -> "SELECT * FROM DEFAULT", AWS_REGION -> "eu-west-1", ) - val commonConfigDef = new CommonConfigDef { + val commonConfigDef = new S3CommonConfigDef { override def connectorPrefix: String = CONNECTOR_PREFIX } "CommonConfigDef" should "parse original properties" in { diff --git a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigTest.scala b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigTest.scala index 259826dc7..f91f9186a 100644 --- a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigTest.scala +++ b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConfigTest.scala @@ -15,56 +15,22 @@ */ package io.lenses.streamreactor.connect.aws.s3.config -import io.lenses.streamreactor.common.errors.NoopErrorPolicy -import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.errors.ThrowErrorPolicy import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig +import io.lenses.streamreactor.common.config.base.RetryConfig import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.scalatest.prop.TableDrivenPropertyChecks._ class S3ConfigTest extends AnyFlatSpec with Matchers with LazyLogging { - "S3Config" should "set error policies in a case insensitive way" in { - - val errorPolicyValuesMap = Table( - ("testName", "value", "errorPolicyClass"), - ("lcvalue-noop", "noop", NoopErrorPolicy()), - ("lcvalue-throw", "throw", ThrowErrorPolicy()), - ("lcvalue-retry", "retry", RetryErrorPolicy()), - ("ucvalue-noop", "NOOP", NoopErrorPolicy()), - ("ucvalue-throw", "THROW", ThrowErrorPolicy()), - ("ucvalue-retry", "RETRY", RetryErrorPolicy()), - ("value-unspecified", "", ThrowErrorPolicy()), - ) - - forAll(errorPolicyValuesMap) { - (name, value, clazz) => - logger.debug("Executing {}", name) - S3ConnectionConfig(Map("connect.s3.error.policy" -> value)).errorPolicy should be(clazz) - } - } - val retryValuesMap = Table[String, Any, Any, RetryConfig]( ("testName", "retries", "interval", "result"), - ("noret-noint", 0, 0, RetryConfig(0, 0)), - ("ret-and-int", 1, 2, RetryConfig(1, 2)), - ("noret-noint-strings", "0", "0", RetryConfig(0, 0)), - ("ret-and-int-strings", "1", "2", RetryConfig(1, 2)), + ("noret-noint", 0, 0, new RetryConfig(0, 0)), + ("ret-and-int", 1, 2, new RetryConfig(1, 2)), + ("noret-noint-strings", "0", "0", new RetryConfig(0, 0)), + ("ret-and-int-strings", "1", "2", new RetryConfig(1, 2)), ) - "S3Config" should "set retry config" in { - forAll(retryValuesMap) { - (name: String, ret: Any, interval: Any, result: RetryConfig) => - logger.debug("Executing {}", name) - S3ConnectionConfig(Map( - "connect.s3.max.retries" -> ret, - "connect.s3.retry.interval" -> interval, - )).connectorRetryConfig should be(result) - } - } - "S3Config" should "set http retry config" in { forAll(retryValuesMap) { (name: String, ret: Any, interval: Any, result: RetryConfig) => diff --git a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConsumerGroupsSinkConfigTest.scala b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConsumerGroupsSinkConfigTest.scala index dcc86d83b..c0a6bb67e 100644 --- a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConsumerGroupsSinkConfigTest.scala +++ b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/config/S3ConsumerGroupsSinkConfigTest.scala @@ -16,10 +16,9 @@ package io.lenses.streamreactor.connect.aws.s3.config import cats.implicits.catsSyntaxOptionId -import io.lenses.streamreactor.common.errors.ThrowErrorPolicy +import io.lenses.streamreactor.common.config.base.RetryConfig import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ import io.lenses.streamreactor.connect.aws.s3.sink.config.S3ConsumerGroupsSinkConfig -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig import io.lenses.streamreactor.connect.cloud.common.consumers.CloudObjectKey import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -41,16 +40,14 @@ class S3ConsumerGroupsSinkConfigTest extends AnyFunSuite with Matchers { value should be( S3ConsumerGroupsSinkConfig( CloudObjectKey("bucket", "a/b/c".some), - S3ConnectionConfig( + new S3ConnectionConfig( Some("eu-west-1"), Some("access"), Some("secret"), AuthMode.Credentials, Some("endpoint"), false, - ThrowErrorPolicy(), - RetryConfig(20, 60000), - RetryConfig(5, 50), + new RetryConfig(5, 50), HttpTimeoutConfig(Some(60000), Some(60000)), None, ), @@ -82,9 +79,7 @@ class S3ConsumerGroupsSinkConfigTest extends AnyFunSuite with Matchers { AuthMode.Credentials, Some("endpoint"), false, - ThrowErrorPolicy(), - RetryConfig(20, 60000), - RetryConfig(5, 50), + new RetryConfig(5, 50), HttpTimeoutConfig(Some(60000), Some(60000)), None, ), diff --git a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3ConsumerGroupsSinkConfigTest.scala b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3ConsumerGroupsSinkConfigTest.scala index cafeb1049..ec6e8e613 100644 --- a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3ConsumerGroupsSinkConfigTest.scala +++ b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3ConsumerGroupsSinkConfigTest.scala @@ -16,12 +16,11 @@ package io.lenses.streamreactor.connect.aws.s3.sink.config import cats.implicits.catsSyntaxOptionId -import io.lenses.streamreactor.common.errors.ThrowErrorPolicy +import io.lenses.streamreactor.common.config.base.RetryConfig import io.lenses.streamreactor.connect.aws.s3.config.AuthMode import io.lenses.streamreactor.connect.aws.s3.config.HttpTimeoutConfig import io.lenses.streamreactor.connect.aws.s3.config.S3ConnectionConfig import io.lenses.streamreactor.connect.aws.s3.config.S3ConfigSettings._ -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig import io.lenses.streamreactor.connect.cloud.common.consumers.CloudObjectKey import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -50,9 +49,7 @@ class S3ConsumerGroupsSinkConfigTest extends AnyFunSuite with Matchers { AuthMode.Credentials, Some("endpoint"), false, - ThrowErrorPolicy(), - RetryConfig(20, 60000), - RetryConfig(5, 50), + new RetryConfig(5, 50), HttpTimeoutConfig(Some(60000), Some(60000)), None, ), @@ -77,16 +74,14 @@ class S3ConsumerGroupsSinkConfigTest extends AnyFunSuite with Matchers { value should be( S3ConsumerGroupsSinkConfig( CloudObjectKey("bucket", "a/b/c".some), - S3ConnectionConfig( + new S3ConnectionConfig( Some("eu-west-1"), Some("access"), Some("secret"), AuthMode.Credentials, Some("endpoint"), false, - ThrowErrorPolicy(), - RetryConfig(20, 60000), - RetryConfig(5, 50), + new RetryConfig(5, 50), HttpTimeoutConfig(Some(60000), Some(60000)), None, ), diff --git a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigTest.scala b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigTest.scala index 5f35eb607..b7e3c8df6 100644 --- a/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigTest.scala +++ b/kafka-connect-aws-s3/src/test/scala/io/lenses/streamreactor/connect/aws/s3/sink/config/S3SinkConfigTest.scala @@ -14,6 +14,11 @@ * limitations under the License. */ package io.lenses.streamreactor.connect.aws.s3.sink.config +import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.NoopErrorPolicy +import io.lenses.streamreactor.common.errors.RetryErrorPolicy +import io.lenses.streamreactor.common.errors.ThrowErrorPolicy import io.lenses.streamreactor.connect.aws.s3.model.location.S3LocationValidator import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.DataStorageSettings @@ -28,7 +33,7 @@ import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import org.scalatest.prop.TableDrivenPropertyChecks._ -class S3SinkConfigTest extends AnyFunSuite with Matchers { +class S3SinkConfigTest extends AnyFunSuite with Matchers with LazyLogging { private implicit val connectorTaskId: ConnectorTaskId = ConnectorTaskId("connector", 1, 0) private implicit val cloudLocationValidator: CloudLocationValidator = S3LocationValidator test("envelope and CSV storage is not allowed") { @@ -122,4 +127,52 @@ class S3SinkConfigTest extends AnyFunSuite with Matchers { } } + test("set error policies in a case insensitive way") { + + val errorPolicyValuesMap = Table( + ("testName", "value", "errorPolicyClass"), + ("lcvalue-noop", "noop", NoopErrorPolicy()), + ("lcvalue-throw", "throw", ThrowErrorPolicy()), + ("lcvalue-retry", "retry", RetryErrorPolicy()), + ("ucvalue-noop", "NOOP", NoopErrorPolicy()), + ("ucvalue-throw", "THROW", ThrowErrorPolicy()), + ("ucvalue-retry", "RETRY", RetryErrorPolicy()), + ("value-unspecified", "", ThrowErrorPolicy()), + ) + + forAll(errorPolicyValuesMap) { + (name, value, clazz) => + logger.debug("Executing {}", name) + S3SinkConfigDefBuilder( + Map( + "connect.s3.kcql" -> "select * from `blah` where `blah`", + "connect.s3.error.policy" -> value, + ), + ).getErrorPolicyOrDefault should be(clazz) + } + } + + val retryValuesMap = Table[String, Any, Any, RetryConfig]( + ("testName", "retries", "interval", "result"), + ("noret-noint", 0, 0L, new RetryConfig(0, 0L)), + ("ret-and-int", 1, 2L, new RetryConfig(1, 2L)), + ("noret-noint-strings", "0", "0", new RetryConfig(0, 0L)), + ("ret-and-int-strings", "1", "2", new RetryConfig(1, 2L)), + ) + + test("should set retry config") { + forAll(retryValuesMap) { + (name: String, ret: Any, interval: Any, result: RetryConfig) => + logger.debug("Executing {}", name) + + val props = Map( + "connect.s3.kcql" -> s"insert into mybucket:myprefix select * from TopicName PARTITIONBY _key STOREAS `Bytes` PROPERTIES('${FlushCount.entryName}'=1,'${DataStorageSettings.StoreEnvelopeKey}'=true)", + "connect.s3.max.retries" -> s"$ret", + "connect.s3.retry.interval" -> s"$interval", + ) + + S3SinkConfigDefBuilder(props).getRetryConfig should be(result) + } + } + } diff --git a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/auth/DatalakeClientCreator.scala b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/auth/DatalakeClientCreator.scala index d81f67512..0fac864c9 100644 --- a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/auth/DatalakeClientCreator.scala +++ b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/auth/DatalakeClientCreator.scala @@ -59,7 +59,7 @@ object DatalakeClientCreator extends ClientCreator[AzureConnectionConfig, DataLa httpClientOptions.setMaximumConnectionPoolSize(cpc.maxConnections) } val configuration = new ConfigurationBuilder().putProperty(Configuration.PROPERTY_AZURE_REQUEST_RETRY_COUNT, - config.httpRetryConfig.numberOfRetries.toString, + config.httpRetryConfig.getRetryLimit.toString, ).build() httpClientOptions.setConfiguration(configuration) HttpClient.createDefault(httpClientOptions) diff --git a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/config/AzureConnectionConfig.scala b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/config/AzureConnectionConfig.scala index 6e89e608d..ffe79fa83 100644 --- a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/config/AzureConnectionConfig.scala +++ b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/config/AzureConnectionConfig.scala @@ -15,14 +15,11 @@ */ package io.lenses.streamreactor.connect.datalake.config -import io.lenses.streamreactor.common.errors.ErrorPolicy -import io.lenses.streamreactor.common.errors.ErrorPolicyEnum -import io.lenses.streamreactor.common.errors.ThrowErrorPolicy +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getInt import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getLong import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getString -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig -import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudConnectionConfig import io.lenses.streamreactor.connect.datalake.config.AzureConfigSettings._ object AzureConnectionConfig { @@ -30,12 +27,7 @@ object AzureConnectionConfig { def apply(props: Map[String, _], authMode: AuthMode): AzureConnectionConfig = AzureConnectionConfig( authMode, getString(props, ENDPOINT), - getErrorPolicy(props), - RetryConfig( - getInt(props, NBR_OF_RETRIES).getOrElse(NBR_OF_RETIRES_DEFAULT), - getLong(props, ERROR_RETRY_INTERVAL).getOrElse(ERROR_RETRY_INTERVAL_DEFAULT), - ), - RetryConfig( + new RetryConfig( getInt(props, HTTP_NBR_OF_RETRIES).getOrElse(HTTP_NBR_OF_RETIRES_DEFAULT), getLong(props, HTTP_ERROR_RETRY_INTERVAL).getOrElse(HTTP_ERROR_RETRY_INTERVAL_DEFAULT), ), @@ -48,10 +40,6 @@ object AzureConnectionConfig { ), ) - private def getErrorPolicy(props: Map[String, _]) = - ErrorPolicy( - ErrorPolicyEnum.withName(getString(props, ERROR_POLICY).map(_.toUpperCase()).getOrElse(ERROR_POLICY_DEFAULT)), - ) } case class HttpTimeoutConfig(socketTimeout: Option[Long], connectionTimeout: Option[Long]) @@ -66,9 +54,7 @@ object ConnectionPoolConfig { case class AzureConnectionConfig( authMode: AuthMode, endpoint: Option[String] = None, - errorPolicy: ErrorPolicy = ThrowErrorPolicy(), - connectorRetryConfig: RetryConfig = RetryConfig(NBR_OF_RETIRES_DEFAULT, ERROR_RETRY_INTERVAL_DEFAULT), - httpRetryConfig: RetryConfig = RetryConfig(HTTP_NBR_OF_RETIRES_DEFAULT, HTTP_ERROR_RETRY_INTERVAL_DEFAULT), + httpRetryConfig: RetryConfig = new RetryConfig(HTTP_NBR_OF_RETIRES_DEFAULT, HTTP_ERROR_RETRY_INTERVAL_DEFAULT), timeouts: HttpTimeoutConfig = HttpTimeoutConfig(None, None), connectionPoolConfig: Option[ConnectionPoolConfig] = Option.empty, -) extends CloudConnectionConfig +) extends ConnectionConfig diff --git a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala index ca67bf841..ca0a58e1a 100644 --- a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala +++ b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/DatalakeSinkTask.scala @@ -22,20 +22,26 @@ import io.lenses.streamreactor.connect.cloud.common.sink.CloudSinkTask import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface import io.lenses.streamreactor.connect.datalake.auth.DatalakeClientCreator import io.lenses.streamreactor.connect.datalake.config.AzureConfigSettings +import io.lenses.streamreactor.connect.datalake.config.AzureConnectionConfig import io.lenses.streamreactor.connect.datalake.model.location.DatalakeLocationValidator import io.lenses.streamreactor.connect.datalake.sink.config.DatalakeSinkConfig import io.lenses.streamreactor.connect.datalake.storage.DatalakeFileMetadata import io.lenses.streamreactor.connect.datalake.storage.DatalakeStorageInterface object DatalakeSinkTask {} class DatalakeSinkTask - extends CloudSinkTask[DatalakeFileMetadata, DatalakeSinkConfig, DataLakeServiceClient]( + extends CloudSinkTask[ + DatalakeFileMetadata, + DatalakeSinkConfig, + AzureConnectionConfig, + DataLakeServiceClient, + ]( AzureConfigSettings.CONNECTOR_PREFIX, "/datalake-sink-ascii.txt", new JarManifest(DatalakeSinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), ) { - override def createClient(config: DatalakeSinkConfig): Either[Throwable, DataLakeServiceClient] = - DatalakeClientCreator.make(config.connectionConfig) + override def createClient(config: AzureConnectionConfig): Either[Throwable, DataLakeServiceClient] = + DatalakeClientCreator.make(config) override def createStorageInterface( connectorTaskId: ConnectorTaskId, diff --git a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfig.scala b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfig.scala index 24ed7a027..2aec2e7a3 100644 --- a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfig.scala +++ b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfig.scala @@ -15,6 +15,8 @@ */ package io.lenses.streamreactor.connect.datalake.sink.config +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.ErrorPolicy import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig import io.lenses.streamreactor.connect.cloud.common.config.traits.PropsToConfigConverter @@ -29,7 +31,7 @@ object DatalakeSinkConfig extends PropsToConfigConverter[DatalakeSinkConfig] { def fromProps( connectorTaskId: ConnectorTaskId, - props: Map[String, String], + props: Map[String, AnyRef], )( implicit cloudLocationValidator: CloudLocationValidator, @@ -54,13 +56,17 @@ object DatalakeSinkConfig extends PropsToConfigConverter[DatalakeSinkConfig] { sinkBucketOptions, offsetSeekerOptions, s3ConfigDefBuilder.getCompressionCodec(), + s3ConfigDefBuilder.getErrorPolicyOrDefault, + s3ConfigDefBuilder.getRetryConfig, ) } case class DatalakeSinkConfig( - connectionConfig: AzureConnectionConfig, - bucketOptions: Seq[CloudSinkBucketOptions] = Seq.empty, - offsetSeekerOptions: OffsetSeekerOptions, - compressionCodec: CompressionCodec, -) extends CloudSinkConfig + connectionConfig: AzureConnectionConfig, + bucketOptions: Seq[CloudSinkBucketOptions] = Seq.empty, + offsetSeekerOptions: OffsetSeekerOptions, + compressionCodec: CompressionCodec, + errorPolicy: ErrorPolicy, + connectorRetryConfig: RetryConfig, +) extends CloudSinkConfig[AzureConnectionConfig] diff --git a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfigDefBuilder.scala b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfigDefBuilder.scala index 6025b567c..98a4e358d 100644 --- a/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfigDefBuilder.scala +++ b/kafka-connect-azure-datalake/src/main/scala/io/lenses/streamreactor/connect/datalake/sink/config/DatalakeSinkConfigDefBuilder.scala @@ -22,11 +22,11 @@ import io.lenses.streamreactor.connect.datalake.config.AzureConfigSettings import scala.jdk.CollectionConverters.MapHasAsScala -case class DatalakeSinkConfigDefBuilder(props: Map[String, String]) +case class DatalakeSinkConfigDefBuilder(props: Map[String, AnyRef]) extends BaseConfig(AzureConfigSettings.CONNECTOR_PREFIX, DatalakeSinkConfigDef.config, props) with CloudSinkConfigDefBuilder with ErrorPolicySettings - with NumberRetriesSettings + with RetryConfigSettings with AuthModeSettings { def getParsedValues: Map[String, _] = values().asScala.toMap diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/auth/ClientCreator.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/auth/ClientCreator.scala index 72c47ea90..e77484594 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/auth/ClientCreator.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/auth/ClientCreator.scala @@ -15,8 +15,8 @@ */ package io.lenses.streamreactor.connect.cloud.common.auth -import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudConnectionConfig - -trait ClientCreator[CT <: CloudConnectionConfig, X] { - def make(config: CT): Either[Throwable, X] +trait ClientCreator[CC, X] { + def make( + config: CC, + ): Either[Throwable, X] } diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConfig.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConfig.scala index 57c205961..e850da1d9 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConfig.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConfig.scala @@ -15,6 +15,8 @@ */ package io.lenses.streamreactor.connect.cloud.common.config.traits +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.ErrorPolicy import io.lenses.streamreactor.connect.cloud.common.model.CompressionCodec import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkBucketOptions import io.lenses.streamreactor.connect.cloud.common.sink.config.OffsetSeekerOptions @@ -26,20 +28,20 @@ import io.lenses.streamreactor.connect.cloud.common.storage.FileMetadata * Trait representing a generic cloud configuration. * This trait serves as a marker trait for cloud-specific configuration implementations. */ -trait CloudConfig +sealed trait CloudConfig /** * Trait representing configuration for a cloud sink. * Extends [[CloudConfig]]. */ -trait CloudSinkConfig extends CloudConfig { +trait CloudSinkConfig[CC] extends CloudConfig { /** * Retrieves the connection configuration for the cloud sink. * * @return The connection configuration for the cloud sink. */ - def connectionConfig: CloudConnectionConfig + def connectionConfig: CC /** * Retrieves the bucket options for the cloud sink. @@ -61,6 +63,10 @@ trait CloudSinkConfig extends CloudConfig { * @return The compression codec for the cloud sink. */ def compressionCodec: CompressionCodec + + def connectorRetryConfig: RetryConfig + + def errorPolicy: ErrorPolicy } /** @@ -71,13 +77,6 @@ trait CloudSinkConfig extends CloudConfig { */ trait CloudSourceConfig[MD <: FileMetadata] extends CloudConfig { - /** - * Retrieves the connection configuration for the cloud source. - * - * @return The connection configuration for the cloud source. - */ - def connectionConfig: CloudConnectionConfig - /** * Retrieves the bucket options for the cloud source. * diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConnectionConfig.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConnectionConfig.scala deleted file mode 100644 index b6c49e562..000000000 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/CloudConnectionConfig.scala +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.connect.cloud.common.config.traits - -import io.lenses.streamreactor.common.errors.ErrorPolicy -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig - -/** - * Trait representing configuration for a cloud connection. - * This trait defines methods for retrieving error policy and connector retry configuration. - */ -trait CloudConnectionConfig { - - /** - * Retrieves the error policy for the cloud connection. - * - * @return The error policy for the cloud connection. - */ - def errorPolicy: ErrorPolicy - - /** - * Retrieves the retry configuration for the cloud connection. - * - * @return The retry configuration for the cloud connection. - */ - def connectorRetryConfig: RetryConfig -} diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/PropsToConfigConverter.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/PropsToConfigConverter.scala index b4dd9b6db..9503493c0 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/PropsToConfigConverter.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/traits/PropsToConfigConverter.scala @@ -42,7 +42,7 @@ trait PropsToConfigConverter[C <: CloudConfig] { */ def fromProps( connectorTaskId: ConnectorTaskId, - props: Map[String, String], + props: Map[String, AnyRef], )( implicit cloudLocationValidator: CloudLocationValidator, diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala index 5bd5e9209..d0378c602 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudSinkTask.scala @@ -17,6 +17,7 @@ package io.lenses.streamreactor.connect.cloud.common.sink import cats.implicits.toBifunctorOps import cats.implicits.toShow +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.common.errors.ErrorHandler import io.lenses.streamreactor.common.errors.RetryErrorPolicy import io.lenses.streamreactor.common.util.AsciiArtPrinter.printAsciiHeader @@ -48,7 +49,16 @@ import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala import scala.util.Try -abstract class CloudSinkTask[MD <: FileMetadata, C <: CloudSinkConfig, CT]( +/** + * @param connectorPrefix + * @param sinkAsciiArtResource + * @param manifest + * @tparam MD file metadata type + * @tparam C cloud sink config subtype + * @tparam CC connection configuration type + * @tparam CT client type + */ +abstract class CloudSinkTask[MD <: FileMetadata, C <: CloudSinkConfig[CC], CC <: ConnectionConfig, CT]( connectorPrefix: String, sinkAsciiArtResource: String, manifest: JarManifest, @@ -247,7 +257,7 @@ abstract class CloudSinkTask[MD <: FileMetadata, C <: CloudSinkConfig, CT]( writerManager = null } - def createClient(config: C): Either[Throwable, CT] + def createClient(config: CC): Either[Throwable, CT] def createStorageInterface(connectorTaskId: ConnectorTaskId, config: C, cloudClient: CT): StorageInterface[MD] @@ -256,7 +266,7 @@ abstract class CloudSinkTask[MD <: FileMetadata, C <: CloudSinkConfig, CT]( private def createWriterMan(props: Map[String, String]): Either[Throwable, WriterManager[MD]] = for { config <- convertPropsToConfig(connectorTaskId, props) - s3Client <- createClient(config) + s3Client <- createClient(config.connectionConfig) storageInterface = createStorageInterface(connectorTaskId, config, s3Client) _ <- setRetryInterval(config) writerManager <- Try(writerManagerCreator.from(config)(connectorTaskId, storageInterface)).toEither @@ -265,15 +275,15 @@ abstract class CloudSinkTask[MD <: FileMetadata, C <: CloudSinkConfig, CT]( private def initializeFromConfig(config: C): Either[Throwable, Unit] = Try(initialize( - config.connectionConfig.connectorRetryConfig.numberOfRetries, - config.connectionConfig.errorPolicy, + config.connectorRetryConfig.getRetryLimit, + config.errorPolicy, )).toEither private def setRetryInterval(config: C): Either[Throwable, Unit] = Try { //if error policy is retry set retry interval - config.connectionConfig.errorPolicy match { - case RetryErrorPolicy() => context.timeout(config.connectionConfig.connectorRetryConfig.errorRetryInterval) + config.errorPolicy match { + case RetryErrorPolicy() => context.timeout(config.connectorRetryConfig.getRetryIntervalMillis) case _ => } }.toEither diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala index 4bd69b6a7..9e951b5d9 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreator.scala @@ -39,7 +39,7 @@ import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface import java.io.File import scala.collection.immutable -class WriterManagerCreator[MD <: FileMetadata, SC <: CloudSinkConfig] extends LazyLogging { +class WriterManagerCreator[MD <: FileMetadata, SC <: CloudSinkConfig[_]] extends LazyLogging { def from( config: SC, @@ -134,7 +134,7 @@ class WriterManagerCreator[MD <: FileMetadata, SC <: CloudSinkConfig] extends La ) } - private def bucketOptsForTopic(config: CloudSinkConfig, topic: Topic): Option[CloudSinkBucketOptions] = + private def bucketOptsForTopic(config: CloudSinkConfig[_], topic: Topic): Option[CloudSinkBucketOptions] = config.bucketOptions.find(bo => bo.sourceTopic.isEmpty || bo.sourceTopic.contains(topic.value)) private def fatalErrorTopicNotConfigured(topicPartition: TopicPartition): SinkError = diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceConfigDefBuilder.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceConfigDefBuilder.scala index 8ba9b7b71..a44053a30 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceConfigDefBuilder.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceConfigDefBuilder.scala @@ -25,7 +25,7 @@ import org.apache.kafka.common.config.ConfigDef abstract class CloudSourceConfigDefBuilder( connectorPrefix: String, configDef: ConfigDef, - props: Map[String, String], + props: Map[String, AnyRef], ) extends BaseConfig(connectorPrefix, configDef, props) with CloudSourceSettings with KcqlSettings diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceSettings.scala b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceSettings.scala index 556f35154..e3edc6980 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceSettings.scala +++ b/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/source/config/CloudSourceSettings.scala @@ -17,7 +17,6 @@ package io.lenses.streamreactor.connect.cloud.common.source.config import io.lenses.streamreactor.common.config.base.traits.BaseSettings import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse -import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getLong import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEntry import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum import io.lenses.streamreactor.connect.cloud.common.source.config.PartitionSearcherOptions.ExcludeIndexes @@ -48,7 +47,7 @@ trait CloudSourceSettings extends BaseSettings with CloudSourceSettingsKeys { PartitionSearcherOptions( recurseLevels = getInt(SOURCE_PARTITION_SEARCH_RECURSE_LEVELS), continuous = getBoolean(SOURCE_PARTITION_SEARCH_MODE), - interval = getLong(props, SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS).getOrElse( + interval = ConfigParse.getLong(props, SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS).getOrElse( SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS_DEFAULT, ).millis, wildcardExcludes = ExcludeIndexes, diff --git a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreatorTest.scala b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreatorTest.scala index 38d92077b..dd5ae9a8b 100644 --- a/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreatorTest.scala +++ b/kafka-connect-cloud-common/src/test/scala/io/lenses/streamreactor/connect/cloud/common/sink/WriterManagerCreatorTest.scala @@ -15,12 +15,11 @@ */ package io.lenses.streamreactor.connect.cloud.common.sink -import io.lenses.streamreactor.common.errors.ErrorPolicy +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.common.errors.NoopErrorPolicy -import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudConnectionConfig -import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig +import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig import io.lenses.streamreactor.connect.cloud.common.model.CompressionCodec import io.lenses.streamreactor.connect.cloud.common.model.CompressionCodecName import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkBucketOptions @@ -36,17 +35,15 @@ import java.time.Instant class WriterManagerCreatorTest extends AnyFunSuite with Matchers with MockitoSugar { - case class FakeConnectionConfig( - errorPolicy: ErrorPolicy, - connectorRetryConfig: RetryConfig, - ) extends CloudConnectionConfig - + case class FakeConnectionConfig() extends ConnectionConfig case class FakeCloudSinkConfig( - connectionConfig: FakeConnectionConfig, - bucketOptions: Seq[CloudSinkBucketOptions], - offsetSeekerOptions: OffsetSeekerOptions, - compressionCodec: CompressionCodec, - ) extends CloudSinkConfig + connectionConfig: FakeConnectionConfig, + bucketOptions: Seq[CloudSinkBucketOptions], + offsetSeekerOptions: OffsetSeekerOptions, + compressionCodec: CompressionCodec, + connectorRetryConfig: RetryConfig, + errorPolicy: NoopErrorPolicy, + ) extends CloudSinkConfig[FakeConnectionConfig] case class FakeFileMetadata(file: String, lastModified: Instant) extends FileMetadata @@ -57,10 +54,12 @@ class WriterManagerCreatorTest extends AnyFunSuite with Matchers with MockitoSug test("create WriterManager from GCPStorageSinkConfig") { val config = FakeCloudSinkConfig( - connectionConfig = FakeConnectionConfig(NoopErrorPolicy(), RetryConfig(1, 1L)), - bucketOptions = Seq.empty, - offsetSeekerOptions = OffsetSeekerOptions(maxIndexFiles = 10), - compressionCodec = CompressionCodecName.ZSTD.toCodec(), + connectionConfig = FakeConnectionConfig(), + bucketOptions = Seq.empty, + offsetSeekerOptions = OffsetSeekerOptions(maxIndexFiles = 10), + compressionCodec = CompressionCodecName.ZSTD.toCodec(), + errorPolicy = NoopErrorPolicy(), + connectorRetryConfig = new RetryConfig(1, 1L), ) val writerManagerCreator = new WriterManagerCreator[FakeFileMetadata, FakeCloudSinkConfig]() diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseConfig.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseConfig.scala index aa76ef62c..bdc686d4e 100644 --- a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseConfig.scala +++ b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseConfig.scala @@ -20,7 +20,7 @@ import org.apache.kafka.common.config.ConfigDef import scala.jdk.CollectionConverters.MapHasAsJava -abstract class BaseConfig(connectorPrefixStr: String, confDef: ConfigDef, props: Map[String, String]) +abstract class BaseConfig(connectorPrefixStr: String, confDef: ConfigDef, props: Map[String, AnyRef]) extends AbstractConfig(confDef, props.asJava) { val connectorPrefix: String = connectorPrefixStr } diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseSettings.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseSettings.scala index 50f9b7dcf..330c8dd8c 100644 --- a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseSettings.scala +++ b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/BaseSettings.scala @@ -15,13 +15,16 @@ */ package io.lenses.streamreactor.common.config.base.traits -import java.util +import io.lenses.streamreactor.common.config.base.model.ConnectorPrefix +import java.util import org.apache.kafka.common.config.types.Password trait WithConnectorPrefix { def connectorPrefix: String + val javaConnectorPrefix = new ConnectorPrefix(connectorPrefix) + } trait BaseSettings extends WithConnectorPrefix { @@ -29,6 +32,8 @@ trait BaseSettings extends WithConnectorPrefix { def getInt(key: String): Integer + def getLong(key: String): java.lang.Long + def getBoolean(key: String): java.lang.Boolean def getPassword(key: String): Password diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/ErrorPolicySettings.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/ErrorPolicySettings.scala index 5a907195c..618b41986 100644 --- a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/ErrorPolicySettings.scala +++ b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/ErrorPolicySettings.scala @@ -15,14 +15,54 @@ */ package io.lenses.streamreactor.common.config.base.traits +import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.ERROR_POLICY_PROP_SUFFIX import io.lenses.streamreactor.common.errors import io.lenses.streamreactor.common.errors.ErrorPolicy import io.lenses.streamreactor.common.errors.ErrorPolicyEnum -import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.ERROR_POLICY_PROP_SUFFIX +import io.lenses.streamreactor.common.errors.ThrowErrorPolicy +import org.apache.kafka.common.config.ConfigDef +import org.apache.kafka.common.config.ConfigDef.Importance +import org.apache.kafka.common.config.ConfigDef.Type + +import scala.util.Try + +trait ErrorPolicyConfigKey extends WithConnectorPrefix { + + val ERROR_POLICY = s"$connectorPrefix.$ERROR_POLICY_PROP_SUFFIX" + val ERROR_POLICY_DOC: String = + """ + |Specifies the action to be taken if an error occurs while inserting the data. + | There are three available options: + | NOOP - the error is swallowed + | THROW - the error is allowed to propagate. + | RETRY - The exception causes the Connect framework to retry the message. The number of retries is set by connect.s3.max.retries. + |All errors will be logged automatically, even if the code swallows them. + """.stripMargin + val ERROR_POLICY_DEFAULT = "THROW" + val ERROR_POLICY_DEFAULT_POLICY: ErrorPolicy = ThrowErrorPolicy() -trait ErrorPolicySettings extends BaseSettings { - def errorPolicyConst = s"$connectorPrefix.$ERROR_POLICY_PROP_SUFFIX" + def withErrorPolicyConfig(configDef: ConfigDef): ConfigDef = + configDef.define( + ERROR_POLICY, + Type.STRING, + ERROR_POLICY_DEFAULT, + Importance.HIGH, + ERROR_POLICY_DOC, + "Error", + 1, + ConfigDef.Width.LONG, + ERROR_POLICY, + ) + +} + +trait ErrorPolicySettings extends BaseSettings with ErrorPolicyConfigKey { def getErrorPolicy: ErrorPolicy = - errors.ErrorPolicy(ErrorPolicyEnum.withName(getString(errorPolicyConst).toUpperCase)) + errors.ErrorPolicy( + ErrorPolicyEnum.withName(getString(ERROR_POLICY).toUpperCase), + ) + + def getErrorPolicyOrDefault: ErrorPolicy = Try(getErrorPolicy).toOption + .getOrElse(ERROR_POLICY_DEFAULT_POLICY) } diff --git a/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/RetryConfigSettings.scala b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/RetryConfigSettings.scala new file mode 100644 index 000000000..dc4c8686f --- /dev/null +++ b/kafka-connect-common/src/main/scala/io/lenses/streamreactor/common/config/base/traits/RetryConfigSettings.scala @@ -0,0 +1,73 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.base.traits + +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.MAX_RETRIES_PROP_SUFFIX +import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.RETRY_INTERVAL_PROP_SUFFIX +import org.apache.kafka.common.config.ConfigDef +import org.apache.kafka.common.config.ConfigDef.Importance +import org.apache.kafka.common.config.ConfigDef.Type + +trait ConnectorRetryConfigKeys extends WithConnectorPrefix { + + val NumRetries: String = s"$connectorPrefix.$MAX_RETRIES_PROP_SUFFIX" + val RetryInterval: String = s"$connectorPrefix.$RETRY_INTERVAL_PROP_SUFFIX" + + val ERROR_RETRY_INTERVAL = s"$connectorPrefix.$RETRY_INTERVAL_PROP_SUFFIX" + val ERROR_RETRY_INTERVAL_DOC = "The time in milliseconds between retries." + val ERROR_RETRY_INTERVAL_DEFAULT: Long = 60000L + + val NBR_OF_RETRIES = s"$connectorPrefix.$MAX_RETRIES_PROP_SUFFIX" + val NBR_OF_RETRIES_DOC = "The maximum number of times to try the write again." + val NBR_OF_RETIRES_DEFAULT: Int = 20 + + def withConnectorRetryConfig(configDef: ConfigDef): ConfigDef = + configDef + .define( + NBR_OF_RETRIES, + Type.INT, + NBR_OF_RETIRES_DEFAULT, + Importance.MEDIUM, + NBR_OF_RETRIES_DOC, + "Error", + 2, + ConfigDef.Width.LONG, + NBR_OF_RETRIES, + ) + .define( + ERROR_RETRY_INTERVAL, + Type.LONG, + ERROR_RETRY_INTERVAL_DEFAULT, + Importance.MEDIUM, + ERROR_RETRY_INTERVAL_DOC, + "Error", + 3, + ConfigDef.Width.LONG, + ERROR_RETRY_INTERVAL, + ) +} + +trait RetryConfigSettings extends BaseSettings with ConnectorRetryConfigKeys { + + private def getNumberRetries: Int = getInt(NumRetries) + + private def getRetryInterval: Long = getLong(RetryInterval) + + def getRetryConfig: RetryConfig = + RetryConfig.builder().retryLimit(getNumberRetries).retryIntervalMillis(getRetryInterval).build() + +} diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala index 0ec9e4cc5..912964d59 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CloudPlatformEmulatorSuite.scala @@ -3,6 +3,7 @@ package io.lenses.streamreactor.connect.cloud.common.sink import cats.implicits.catsSyntaxEitherId import cats.implicits.catsSyntaxOptionId import cats.implicits.toBifunctorOps +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig import io.lenses.streamreactor.connect.cloud.common.storage.FileMetadata import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface @@ -15,11 +16,12 @@ import org.scalatest.flatspec.AnyFlatSpec import scala.util.Try trait CloudPlatformEmulatorSuite[ - SM <: FileMetadata, - SI <: StorageInterface[SM], - CSC <: CloudSinkConfig, - C, - T <: CloudSinkTask[SM, CSC, C], + MD <: FileMetadata, + SI <: StorageInterface[MD], + C <: CloudSinkConfig[CC], + CC <: ConnectionConfig, + CT, + T <: CloudSinkTask[MD, C, CC, CT], ] extends AnyFlatSpec with BeforeAndAfter with BeforeAndAfterAll @@ -34,17 +36,17 @@ trait CloudPlatformEmulatorSuite[ var maybeStorageInterface: Option[SI] = None - var maybeClient: Option[C] = None + var maybeClient: Option[CT] = None implicit def storageInterface: SI = maybeStorageInterface.getOrElse(fail("Unset SI")) - def client: C = maybeClient.getOrElse(fail("Unset client")) + def client: CT = maybeClient.getOrElse(fail("Unset client")) - def createClient(): Either[Throwable, C] - def createStorageInterface(client: C): Either[Throwable, SI] + def createClient(): Either[Throwable, CT] + def createStorageInterface(client: CT): Either[Throwable, SI] val defaultProps: Map[String, String] - def createBucket(client: C): Either[Throwable, Unit] + def createBucket(client: CT): Either[Throwable, Unit] override protected def beforeAll(): Unit = { diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala index 6e148ef64..a3f1bced2 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/cloud/common/sink/CoreSinkTaskTestCases.scala @@ -1,10 +1,11 @@ package io.lenses.streamreactor.connect.cloud.common.sink +import com.opencsv.CSVReader +import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.ERROR_POLICY_PROP_SUFFIX import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.MAX_RETRIES_PROP_SUFFIX import io.lenses.streamreactor.common.config.base.const.TraitConfigConst.RETRY_INTERVAL_PROP_SUFFIX -import com.opencsv.CSVReader -import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushInterval import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushSize @@ -38,11 +39,11 @@ import org.mockito.MockitoSugar import org.scalatest.matchers.should.Matchers import java.io.StringReader -import java.lang import java.nio.file.Files import java.time.LocalDate import java.time.ZoneOffset import java.time.format.DateTimeFormatter +import java.lang import java.util import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala @@ -51,13 +52,14 @@ import scala.util.Failure import scala.util.Success import scala.util.Try abstract class CoreSinkTaskTestCases[ - SM <: FileMetadata, - SI <: StorageInterface[SM], - CSC <: CloudSinkConfig, - C, - T <: CloudSinkTask[SM, CSC, C], + MD <: FileMetadata, + SI <: StorageInterface[MD], + C <: CloudSinkConfig[CC], + CC <: ConnectionConfig, + CT, + T <: CloudSinkTask[MD, C, CC, CT], ](unitUnderTest: String, -) extends CloudPlatformEmulatorSuite[SM, SI, CSC, C, T] +) extends CloudPlatformEmulatorSuite[MD, SI, C, CC, CT, T] with Matchers with MockitoSugar with LazyLogging { diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTaskTest.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTaskTest.scala index b7da4700b..d8c7cb91f 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTaskTest.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTaskTest.scala @@ -18,6 +18,7 @@ package io.lenses.streamreactor.connect.gcp.storage.sink import com.google.cloud.storage.Storage import io.lenses.streamreactor.connect.cloud.common.sink.CoreSinkTaskTestCases +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig import io.lenses.streamreactor.connect.gcp.storage.sink.config.GCPStorageSinkConfig import io.lenses.streamreactor.connect.gcp.storage.storage.GCPStorageFileMetadata import io.lenses.streamreactor.connect.gcp.storage.storage.GCPStorageStorageInterface @@ -28,6 +29,7 @@ class GCPStorageSinkTaskTest GCPStorageFileMetadata, GCPStorageStorageInterface, GCPStorageSinkConfig, + GCPConnectionConfig, Storage, GCPStorageSinkTask, ]( diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala index 80daa010d..61f4a378b 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala @@ -7,11 +7,11 @@ import com.typesafe.scalalogging.LazyLogging import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.TaskIndexKey import io.lenses.streamreactor.connect.cloud.common.sink.CloudPlatformEmulatorSuite +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.common.auth.mode.NoAuthMode +import io.lenses.streamreactor.connect.gcp.common.config.AuthModeSettings import io.lenses.streamreactor.connect.gcp.storage.auth.GCPStorageClientCreator import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings._ -import io.lenses.streamreactor.connect.gcp.storage.config.AuthMode -import io.lenses.streamreactor.connect.gcp.storage.config.AuthModeSettingsConfigKeys -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfig import io.lenses.streamreactor.connect.gcp.storage.config.UploadConfigKeys import io.lenses.streamreactor.connect.gcp.storage.sink.GCPStorageSinkTask import io.lenses.streamreactor.connect.gcp.storage.sink.config.GCPStorageSinkConfig @@ -30,14 +30,16 @@ trait GCPProxyContainerTest GCPStorageFileMetadata, GCPStorageStorageInterface, GCPStorageSinkConfig, + GCPConnectionConfig, Storage, GCPStorageSinkTask, ] with TaskIndexKey - with AuthModeSettingsConfigKeys with UploadConfigKeys with LazyLogging { + private val authModeConfig = new AuthModeSettings(javaConnectorPrefix) + implicit val connectorTaskId: ConnectorTaskId = ConnectorTaskId("unit-tests", 1, 1) override val container: PausableContainer = new TestContainersPausableContainer(GCPStorageContainer()) @@ -49,12 +51,11 @@ trait GCPProxyContainerTest override def createClient(): Either[Throwable, Storage] = { - val gcpConfig: GCPConnectionConfig = GCPConnectionConfig( - projectId = Some("test"), - quotaProjectId = Option.empty, - authMode = AuthMode.None, - host = Some(container.getEndpointUrl()), - ) + val gcpConfig: GCPConnectionConfig = GCPConnectionConfig.builder() + .projectId("test") + .authMode(new NoAuthMode()) + .host(container.getEndpointUrl()) + .build() GCPStorageClientCreator.make(gcpConfig) } @@ -63,12 +64,12 @@ trait GCPProxyContainerTest lazy val defaultProps: Map[String, String] = Map( - GCP_PROJECT_ID -> "projectId", - AUTH_MODE -> "none", - HOST -> container.getEndpointUrl(), - "name" -> "gcpSinkTaskTest", - TASK_INDEX -> "1:1", - AVOID_RESUMABLE_UPLOAD -> "true", + GCP_PROJECT_ID -> "projectId", + authModeConfig.getAuthModeKey -> "none", + HOST -> container.getEndpointUrl(), + "name" -> "gcpSinkTaskTest", + TASK_INDEX -> "1:1", + AVOID_RESUMABLE_UPLOAD -> "true", ) val localRoot: File = Files.createTempDirectory("blah").toFile diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreator.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreator.scala index efbfad096..a47ba5938 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreator.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreator.scala @@ -15,67 +15,25 @@ */ package io.lenses.streamreactor.connect.gcp.storage.auth -import com.google.api.gax.retrying.RetrySettings -import com.google.auth.oauth2.GoogleCredentials -import com.google.cloud.http.HttpTransportOptions import com.google.cloud.storage.Storage import com.google.cloud.storage.StorageOptions -import com.google.cloud.NoCredentials -import com.google.cloud.TransportOptions import io.lenses.streamreactor.connect.cloud.common.auth.ClientCreator -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig -import io.lenses.streamreactor.connect.gcp.storage.config.AuthMode.None -import io.lenses.streamreactor.connect.gcp.storage.config.AuthMode -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfig -import io.lenses.streamreactor.connect.gcp.storage.config.HttpTimeoutConfig -import org.threeten.bp.Duration +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.common.auth.GCPServiceBuilderConfigurer -import java.io.ByteArrayInputStream -import java.io.FileInputStream import scala.util.Try object GCPStorageClientCreator extends ClientCreator[GCPConnectionConfig, Storage] { def make(config: GCPConnectionConfig): Either[Throwable, Storage] = Try { - val builder = StorageOptions - .newBuilder() + val builder: StorageOptions.Builder = StorageOptions.newBuilder() - config.host.foreach(builder.setHost) - config.projectId.foreach(builder.setProjectId) - config.quotaProjectId.foreach(builder.setQuotaProjectId) - - builder.setCredentials( - config.authMode match { - case None => NoCredentials.getInstance() - case AuthMode.Credentials(credentials) => - GoogleCredentials.fromStream(new ByteArrayInputStream(credentials.value().getBytes)) - case AuthMode.File(filePath) => GoogleCredentials.fromStream(new FileInputStream(filePath)) - case _ => GoogleCredentials.getApplicationDefault - }, - ) - .setRetrySettings(createRetrySettings(config.httpRetryConfig)) - - createTransportOptions(config.timeouts).foreach(builder.setTransportOptions) - - builder.build() - .getService + GCPServiceBuilderConfigurer.configure[ + Storage, + StorageOptions, + StorageOptions.Builder, + ](config, builder).build().getService }.toEither - private def createTransportOptions(timeoutConfig: HttpTimeoutConfig): Option[TransportOptions] = - Option.when(timeoutConfig.connectionTimeout.nonEmpty || timeoutConfig.socketTimeout.nonEmpty) { - val httpTransportOptionsBuilder = HttpTransportOptions.newBuilder() - timeoutConfig.socketTimeout.foreach(sock => httpTransportOptionsBuilder.setReadTimeout(sock.toInt)) - timeoutConfig.connectionTimeout.foreach(conn => httpTransportOptionsBuilder.setConnectTimeout(conn.toInt)) - httpTransportOptionsBuilder.build() - } - - private def createRetrySettings(httpRetryConfig: RetryConfig): RetrySettings = - RetrySettings - .newBuilder() - .setInitialRetryDelay(Duration.ofMillis(httpRetryConfig.errorRetryInterval)) - .setMaxRetryDelay(Duration.ofMillis(httpRetryConfig.errorRetryInterval * 5)) - .setMaxAttempts(httpRetryConfig.numberOfRetries) - .build() - } diff --git a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/RetryConfig.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/ServiceConfigurationException.scala similarity index 74% rename from kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/RetryConfig.scala rename to kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/ServiceConfigurationException.scala index fd2314905..1790e4b5b 100644 --- a/kafka-connect-cloud-common/src/main/scala/io/lenses/streamreactor/connect/cloud/common/config/RetryConfig.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/auth/ServiceConfigurationException.scala @@ -13,5 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.lenses.streamreactor.connect.cloud.common.config -case class RetryConfig(numberOfRetries: Int, errorRetryInterval: Long) +package io.lenses.streamreactor.connect.gcp.storage.auth + +import org.apache.kafka.common.config.ConfigException + +class ServiceConfigurationException(message: String) extends ConfigException(message) {} diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/AuthModeSettings.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/AuthModeSettings.scala index 8623040e8..551a88a9e 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/AuthModeSettings.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/AuthModeSettings.scala @@ -15,98 +15,21 @@ */ package io.lenses.streamreactor.connect.gcp.storage.config -import cats.syntax.all._ +import io.lenses.streamreactor.common.config.base.ConfigMap import io.lenses.streamreactor.common.config.base.traits.BaseSettings -import io.lenses.streamreactor.common.config.base.traits.WithConnectorPrefix -import org.apache.kafka.common.config.ConfigDef -import org.apache.kafka.common.config.ConfigDef.Importance -import org.apache.kafka.common.config.ConfigDef.Type -import org.apache.kafka.common.config.ConfigException -import org.apache.kafka.common.config.types.Password +import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode +import io.lenses.streamreactor.connect.gcp.common.config.{ AuthModeSettings => JavaAuthModeSettings } +import scala.jdk.CollectionConverters.MapHasAsJava import scala.util.Try -sealed trait AuthMode +trait AuthModeSettings extends BaseSettings { -object AuthMode { + private val javaAuthModeSettings = new JavaAuthModeSettings(javaConnectorPrefix) - /** - * Authentication mode using credentials from a string in configuration. - * - * @param credentials The credentials used for authentication. - */ - case class Credentials(credentials: Password) extends AuthMode - - /** - * Authentication mode using a json file for credentials. - * - * @param filePath The path to the file containing json credentials. - */ - case class File(filePath: String) extends AuthMode - - /** - * Default authentication mode without explicit credentials. This mode utilizes the Application Default Credentials (ADC) chain. - * ADC is a strategy used by the Google authentication libraries to automatically find credentials based on the application environment. - * The credentials are made available to Cloud Client Libraries and Google API Client Libraries, allowing the code to run seamlessly - * in both development and production environments without altering the authentication process for Google Cloud services and APIs. - */ - case object Default extends AuthMode - - /** - * Authentication mode indicating no authentication is required. - */ - case object None extends AuthMode - -} - -trait AuthModeSettingsConfigKeys extends WithConnectorPrefix { - protected val AUTH_MODE: String = s"$connectorPrefix.gcp.auth.mode" - protected val CREDENTIALS: String = s"$connectorPrefix.gcp.credentials" - protected val FILE: String = s"$connectorPrefix.gcp.file" - - def withAuthModeSettings(configDef: ConfigDef): ConfigDef = - configDef.define( - AUTH_MODE, - Type.STRING, - AuthMode.Default.toString, - Importance.HIGH, - "Authenticate mode, 'credentials', 'file' or 'default'", - ) - .define( - CREDENTIALS, - Type.PASSWORD, - "", - Importance.HIGH, - "GCP Credentials if using 'credentials' auth mode.", - ) - .define( - FILE, - Type.STRING, - "", - Importance.HIGH, - "File containing GCP Credentials if using 'file' auth mode", - ) -} - -trait AuthModeSettings extends BaseSettings with AuthModeSettingsConfigKeys { - - def getAuthMode: Either[Throwable, AuthMode] = { - - val authMode = Option(getString(AUTH_MODE)).map(_.trim.toLowerCase).filter(_.nonEmpty) - authMode match { - case Some("credentials") => - for { - creds <- Try(getPassword(CREDENTIALS)).toEither - } yield AuthMode.Credentials(creds) - case Some("file") => - for { - filePath <- Try(getString(FILE)).toEither - } yield AuthMode.File(filePath) - case Some("default") => AuthMode.Default.asRight - case Some("none") => AuthMode.None.asRight - case Some(invalidAuthMode) => new ConfigException(s"Unsupported auth mode `$invalidAuthMode`").asLeft - case None => AuthMode.Default.asRight - } + def getAuthMode(props: Map[String, AnyRef]): Either[Throwable, AuthMode] = { + val configMap = new ConfigMap(props.asJava) + Try(javaAuthModeSettings.parseFromConfig(configMap)).toEither } } diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala index 9aaf178af..fae0e8196 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala @@ -39,11 +39,16 @@ import GCPConfigSettings.KCQL_DOC import GCPConfigSettings.NBR_OF_RETIRES_DEFAULT import GCPConfigSettings.NBR_OF_RETRIES import GCPConfigSettings.NBR_OF_RETRIES_DOC +import io.lenses.streamreactor.connect.gcp.common.config.AuthModeSettings import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.common.config.ConfigDef.Importance import org.apache.kafka.common.config.ConfigDef.Type -trait CommonConfigDef extends CompressionCodecConfigKeys with AuthModeSettingsConfigKeys { +trait CommonConfigDef extends CompressionCodecConfigKeys { + + private val authModeSettingsConfigKeys = new AuthModeSettings(javaConnectorPrefix) + + import authModeSettingsConfigKeys._ def config: ConfigDef = { val conf = new ConfigDef() diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfig.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfig.scala deleted file mode 100644 index 75a4024d0..000000000 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfig.scala +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.connect.gcp.storage.config - -import io.lenses.streamreactor.common.errors.ErrorPolicy -import io.lenses.streamreactor.common.errors.ErrorPolicyEnum -import io.lenses.streamreactor.common.errors.ThrowErrorPolicy -import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse._ -import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse.getString -import GCPConfigSettings.ERROR_POLICY -import GCPConfigSettings.ERROR_POLICY_DEFAULT -import GCPConfigSettings.ERROR_RETRY_INTERVAL -import GCPConfigSettings.ERROR_RETRY_INTERVAL_DEFAULT -import GCPConfigSettings.GCP_PROJECT_ID -import GCPConfigSettings.GCP_QUOTA_PROJECT_ID -import GCPConfigSettings.HOST -import GCPConfigSettings.HTTP_CONNECTION_TIMEOUT -import GCPConfigSettings.HTTP_ERROR_RETRY_INTERVAL -import GCPConfigSettings.HTTP_ERROR_RETRY_INTERVAL_DEFAULT -import GCPConfigSettings.HTTP_NBR_OF_RETIRES_DEFAULT -import GCPConfigSettings.HTTP_NBR_OF_RETRIES -import GCPConfigSettings.HTTP_SOCKET_TIMEOUT -import GCPConfigSettings.NBR_OF_RETIRES_DEFAULT -import GCPConfigSettings.NBR_OF_RETRIES -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig -import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudConnectionConfig - -object GCPConnectionConfig { - - def apply(props: Map[String, _], authMode: AuthMode): GCPConnectionConfig = GCPConnectionConfig( - getString(props, GCP_PROJECT_ID), - getString(props, GCP_QUOTA_PROJECT_ID), - authMode, - getString(props, HOST), - getErrorPolicy(props), - RetryConfig( - getInt(props, NBR_OF_RETRIES).getOrElse(NBR_OF_RETIRES_DEFAULT), - getLong(props, ERROR_RETRY_INTERVAL).getOrElse(ERROR_RETRY_INTERVAL_DEFAULT), - ), - RetryConfig( - getInt(props, HTTP_NBR_OF_RETRIES).getOrElse(HTTP_NBR_OF_RETIRES_DEFAULT), - getLong(props, HTTP_ERROR_RETRY_INTERVAL).getOrElse(HTTP_ERROR_RETRY_INTERVAL_DEFAULT), - ), - HttpTimeoutConfig( - getLong(props, HTTP_SOCKET_TIMEOUT), - getLong(props, HTTP_CONNECTION_TIMEOUT), - ), - ) - - private def getErrorPolicy(props: Map[String, _]) = - ErrorPolicy( - ErrorPolicyEnum.withName(getString(props, ERROR_POLICY).map(_.toUpperCase()).getOrElse(ERROR_POLICY_DEFAULT)), - ) -} - -case class HttpTimeoutConfig(socketTimeout: Option[Long], connectionTimeout: Option[Long]) - -case class GCPConnectionConfig( - projectId: Option[String], - quotaProjectId: Option[String], - authMode: AuthMode, - host: Option[String] = None, - errorPolicy: ErrorPolicy = ThrowErrorPolicy(), - connectorRetryConfig: RetryConfig = RetryConfig(NBR_OF_RETIRES_DEFAULT, ERROR_RETRY_INTERVAL_DEFAULT), - httpRetryConfig: RetryConfig = RetryConfig(HTTP_NBR_OF_RETIRES_DEFAULT, HTTP_ERROR_RETRY_INTERVAL_DEFAULT), - timeouts: HttpTimeoutConfig = HttpTimeoutConfig(None, None), -) extends CloudConnectionConfig diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala new file mode 100644 index 000000000..6203820bc --- /dev/null +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.storage.config + +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse._ +import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.common.auth.HttpTimeoutConfig +import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings._ + +object GCPConnectionConfigBuilder { + + def apply(props: Map[String, _], authMode: AuthMode): GCPConnectionConfig = new GCPConnectionConfig( + getString(props, GCP_PROJECT_ID).orNull, + getString(props, GCP_QUOTA_PROJECT_ID).orNull, + authMode, + getString(props, HOST).orNull, + new RetryConfig( + getInt(props, HTTP_NBR_OF_RETRIES).getOrElse(HTTP_NBR_OF_RETIRES_DEFAULT), + getLong(props, HTTP_ERROR_RETRY_INTERVAL).getOrElse(HTTP_ERROR_RETRY_INTERVAL_DEFAULT), + ), + new HttpTimeoutConfig( + getLong(props, HTTP_SOCKET_TIMEOUT).map(Long.box).orNull, + getLong(props, HTTP_CONNECTION_TIMEOUT).map(Long.box).orNull, + ), + ) + +} diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala index d22c43349..4a40ff009 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/GCPStorageSinkTask.scala @@ -20,6 +20,7 @@ import io.lenses.streamreactor.common.util.JarManifest import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.sink.CloudSinkTask import io.lenses.streamreactor.connect.cloud.common.storage.StorageInterface +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig import io.lenses.streamreactor.connect.gcp.storage.auth.GCPStorageClientCreator import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings import io.lenses.streamreactor.connect.gcp.storage.model.location.GCPStorageLocationValidator @@ -29,14 +30,19 @@ import io.lenses.streamreactor.connect.gcp.storage.storage.GCPStorageStorageInte object GCPStorageSinkTask {} class GCPStorageSinkTask - extends CloudSinkTask[GCPStorageFileMetadata, GCPStorageSinkConfig, Storage]( + extends CloudSinkTask[ + GCPStorageFileMetadata, + GCPStorageSinkConfig, + GCPConnectionConfig, + Storage, + ]( GCPConfigSettings.CONNECTOR_PREFIX, "/gcpstorage-sink-ascii.txt", new JarManifest(GCPStorageSinkTask.getClass.getProtectionDomain.getCodeSource.getLocation), ) { - override def createClient(config: GCPStorageSinkConfig): Either[Throwable, Storage] = - GCPStorageClientCreator.make(config.connectionConfig) + override def createClient(config: GCPConnectionConfig): Either[Throwable, Storage] = + GCPStorageClientCreator.make(config) override def createStorageInterface( connectorTaskId: ConnectorTaskId, diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala index 1dc2ecad4..604f4c800 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala @@ -15,6 +15,8 @@ */ package io.lenses.streamreactor.connect.gcp.storage.sink.config +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.ErrorPolicy import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig import io.lenses.streamreactor.connect.cloud.common.config.traits.PropsToConfigConverter @@ -22,14 +24,15 @@ import io.lenses.streamreactor.connect.cloud.common.model.CompressionCodec import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocationValidator import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkBucketOptions import io.lenses.streamreactor.connect.cloud.common.sink.config.OffsetSeekerOptions -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfigBuilder import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.SEEK_MAX_INDEX_FILES object GCPStorageSinkConfig extends PropsToConfigConverter[GCPStorageSinkConfig] { def fromProps( connectorTaskId: ConnectorTaskId, - props: Map[String, String], + props: Map[String, AnyRef], )( implicit cloudLocationValidator: CloudLocationValidator, @@ -44,17 +47,19 @@ object GCPStorageSinkConfig extends PropsToConfigConverter[GCPStorageSinkConfig] cloudLocationValidator: CloudLocationValidator, ): Either[Throwable, GCPStorageSinkConfig] = for { - authMode <- gcpConfigDefBuilder.getAuthMode + authMode <- gcpConfigDefBuilder.getAuthMode(gcpConfigDefBuilder.props) sinkBucketOptions <- CloudSinkBucketOptions(connectorTaskId, gcpConfigDefBuilder) offsetSeekerOptions = OffsetSeekerOptions( gcpConfigDefBuilder.getInt(SEEK_MAX_INDEX_FILES), ) } yield GCPStorageSinkConfig( - GCPConnectionConfig(gcpConfigDefBuilder.getParsedValues, authMode), + GCPConnectionConfigBuilder(gcpConfigDefBuilder.getParsedValues, authMode), sinkBucketOptions, offsetSeekerOptions, gcpConfigDefBuilder.getCompressionCodec(), avoidResumableUpload = gcpConfigDefBuilder.isAvoidResumableUpload, + errorPolicy = gcpConfigDefBuilder.getErrorPolicyOrDefault, + connectorRetryConfig = gcpConfigDefBuilder.getRetryConfig, ) } @@ -65,4 +70,6 @@ case class GCPStorageSinkConfig( offsetSeekerOptions: OffsetSeekerOptions, compressionCodec: CompressionCodec, avoidResumableUpload: Boolean, -) extends CloudSinkConfig + connectorRetryConfig: RetryConfig, + errorPolicy: ErrorPolicy, +) extends CloudSinkConfig[GCPConnectionConfig] diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala index 8f15ad819..cd4eeeac9 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala @@ -23,11 +23,11 @@ import io.lenses.streamreactor.connect.gcp.storage.config.UploadSettings import scala.jdk.CollectionConverters.MapHasAsScala -case class GCPStorageSinkConfigDefBuilder(props: Map[String, String]) +case class GCPStorageSinkConfigDefBuilder(props: Map[String, AnyRef]) extends BaseConfig(GCPConfigSettings.CONNECTOR_PREFIX, GCPStorageSinkConfigDef.config, props) with CloudSinkConfigDefBuilder with ErrorPolicySettings - with NumberRetriesSettings + with RetryConfigSettings with AuthModeSettings with UploadSettings { diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala index 3bee76121..2a5883ed5 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala @@ -22,7 +22,8 @@ import io.lenses.streamreactor.connect.cloud.common.model.CompressionCodec import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocationValidator import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceBucketOptions import io.lenses.streamreactor.connect.cloud.common.source.config.PartitionSearcherOptions -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfigBuilder import io.lenses.streamreactor.connect.gcp.storage.model.location.GCPStorageLocationValidator import io.lenses.streamreactor.connect.gcp.storage.storage.GCPStorageFileMetadata @@ -34,7 +35,7 @@ object GCPStorageSourceConfig extends PropsToConfigConverter[GCPStorageSourceCon override def fromProps( connectorTaskId: ConnectorTaskId, - props: Map[String, String], + props: Map[String, AnyRef], )( implicit cloudLocationValidator: CloudLocationValidator, @@ -44,13 +45,13 @@ object GCPStorageSourceConfig extends PropsToConfigConverter[GCPStorageSourceCon def apply(gcpConfigDefBuilder: GCPStorageSourceConfigDefBuilder): Either[Throwable, GCPStorageSourceConfig] = { val parsedValues = gcpConfigDefBuilder.getParsedValues for { - authMode <- gcpConfigDefBuilder.getAuthMode + authMode <- gcpConfigDefBuilder.getAuthMode(gcpConfigDefBuilder.props) sbo <- CloudSourceBucketOptions[GCPStorageFileMetadata]( gcpConfigDefBuilder, gcpConfigDefBuilder.getPartitionExtractor(parsedValues), ) } yield GCPStorageSourceConfig( - GCPConnectionConfig(parsedValues, authMode), + GCPConnectionConfigBuilder(parsedValues, authMode), sbo, gcpConfigDefBuilder.getCompressionCodec(), gcpConfigDefBuilder.getPartitionSearcherOptions(parsedValues), diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala index 905053ded..bef4e2453 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala @@ -21,7 +21,7 @@ import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings import scala.jdk.CollectionConverters.MapHasAsScala -case class GCPStorageSourceConfigDefBuilder(props: Map[String, String]) +case class GCPStorageSourceConfigDefBuilder(props: Map[String, AnyRef]) extends CloudSourceConfigDefBuilder(GCPConfigSettings.CONNECTOR_PREFIX, GCPStorageSourceConfigDef.config, props) with AuthModeSettings { diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreatorTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreatorTest.scala index e6388ece1..a9d443463 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreatorTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/auth/GCPStorageClientCreatorTest.scala @@ -15,13 +15,15 @@ */ package io.lenses.streamreactor.connect.gcp.storage.auth -import cats.implicits.catsSyntaxOptionId import com.google.cloud.TransportOptions import com.google.cloud.http.HttpTransportOptions -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig -import io.lenses.streamreactor.connect.gcp.storage.config.AuthMode -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfig -import io.lenses.streamreactor.connect.gcp.storage.config.HttpTimeoutConfig +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.connect.gcp.common.auth.mode.CredentialsAuthMode +import io.lenses.streamreactor.connect.gcp.common.auth.mode.DefaultAuthMode +import io.lenses.streamreactor.connect.gcp.common.auth.mode.FileAuthMode +import io.lenses.streamreactor.connect.gcp.common.auth.mode.NoAuthMode +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.common.auth.HttpTimeoutConfig import org.apache.commons.io.IOUtils import org.apache.kafka.common.config.types.Password import org.scalatest.EitherValues @@ -36,15 +38,14 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV private val jsonCredsUrl: URL = getClass.getResource("/test-gcp-credentials.json") - private val defaultConfig = GCPConnectionConfig( - host = Some("custom-host"), - projectId = Some("project-id"), - quotaProjectId = Some("quota-project-id"), - authMode = AuthMode.None, - ) + private val defaultConfigBuilder = GCPConnectionConfig.builder() + .host("custom-host") + .projectId("project-id") + .quotaProjectId("quota-project-id") + .authMode(new NoAuthMode()); test("should provide specified base options") { - val config = defaultConfig.copy(authMode = AuthMode.None) + val config = defaultConfigBuilder.build() val storageEither = GCPStorageClientCreator.make(config) @@ -55,7 +56,7 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV } test("should handle AuthMode.None") { - val config = defaultConfig.copy(authMode = AuthMode.None) + val config = defaultConfigBuilder.build() GCPStorageClientCreator.make(config).value.getOptions.getCredentials.getClass.getSimpleName should be( "NoCredentials", @@ -63,7 +64,7 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV } test("should handle AuthMode.Default") { - val config = defaultConfig.copy(authMode = AuthMode.Default) + val config = defaultConfigBuilder.authMode(new DefaultAuthMode()).build() // we probably don't have GCP credentials configured so we would expect this to fail. GCPStorageClientCreator.make(config).swap.value.getMessage should startWith( @@ -73,7 +74,7 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV test("should handle AuthMode.Credentials") { val testCreds = IOUtils.toString(jsonCredsUrl, Charset.defaultCharset()) - val config = defaultConfig.copy(authMode = AuthMode.Credentials(new Password(testCreds))) + val config = defaultConfigBuilder.authMode(new CredentialsAuthMode(new Password(testCreds))).build() val storageEither = GCPStorageClientCreator.make(config) @@ -83,7 +84,7 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV test("should handle AuthMode.File") { val filePath = jsonCredsUrl.getPath - val config = defaultConfig.copy(authMode = AuthMode.File(filePath)) + val config = defaultConfigBuilder.authMode(new FileAuthMode(filePath)).build() val storageEither = GCPStorageClientCreator.make(config) @@ -93,9 +94,8 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV test("should handle http retry config") { - val config = defaultConfig.copy( - httpRetryConfig = RetryConfig(100, 500), - ) + val config = defaultConfigBuilder + .httpRetryConfig(new RetryConfig(100, 500)).build() val retrySettings = GCPStorageClientCreator.make(config).value.getOptions.getRetrySettings retrySettings.getMaxAttempts should be(100) @@ -105,7 +105,7 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV test("should use http by default") { - val config = defaultConfig + val config = defaultConfigBuilder.build() val transportOpts: TransportOptions = GCPStorageClientCreator.make(config).value.getOptions.getTransportOptions transportOpts match { @@ -116,9 +116,9 @@ class GCPStorageClientCreatorTest extends AnyFunSuite with Matchers with EitherV test("should handle http timeout config") { - val config = defaultConfig.copy( - timeouts = HttpTimeoutConfig(250L.some, 800L.some), - ) + val config = defaultConfigBuilder.timeouts( + new HttpTimeoutConfig(250L, 800L), + ).build() val transportOpts: TransportOptions = GCPStorageClientCreator.make(config).value.getOptions.getTransportOptions transportOpts match { diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala index 59c8709cd..aaec37c90 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala @@ -16,6 +16,7 @@ package io.lenses.streamreactor.connect.gcp.storage.config import cats.implicits.catsSyntaxOptionId +import io.lenses.streamreactor.connect.gcp.common.config.AuthModeSettings import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.CONNECTOR_PREFIX import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.GCP_PROJECT_ID import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.HOST @@ -27,12 +28,9 @@ import org.scalatest.matchers.should.Matchers import scala.jdk.CollectionConverters.MapHasAsJava import scala.jdk.CollectionConverters.MapHasAsScala -class CommonConfigDefTest - extends AnyFlatSpec - with Matchers - with EitherValues - with AuthModeSettingsConfigKeys - with UploadConfigKeys { +class CommonConfigDefTest extends AnyFlatSpec with Matchers with EitherValues with UploadConfigKeys { + + private val authModeConfig = new AuthModeSettings(javaConnectorPrefix) private val commonConfigDef = new CommonConfigDef { override def connectorPrefix: String = CONNECTOR_PREFIX @@ -40,10 +38,10 @@ class CommonConfigDefTest private val DefaultProps: Map[String, String] = Map( - GCP_PROJECT_ID -> "projectId", - AUTH_MODE -> "none", - HOST -> "localhost:9090", - KCQL_CONFIG -> "SELECT * FROM DEFAULT", + GCP_PROJECT_ID -> "projectId", + authModeConfig.getAuthModeKey -> "none", + HOST -> "localhost:9090", + KCQL_CONFIG -> "SELECT * FROM DEFAULT", ) "CommonConfigDef" should "retain original properties after parsing" in { diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala index 8477c6fd9..e7bd741fe 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala @@ -30,11 +30,9 @@ package io.lenses.streamreactor.connect.gcp.storage.config * See the License for the specific language governing permissions and * limitations under the License. */ -import io.lenses.streamreactor.common.errors.NoopErrorPolicy -import io.lenses.streamreactor.common.errors.RetryErrorPolicy -import io.lenses.streamreactor.common.errors.ThrowErrorPolicy import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.connect.cloud.common.config.RetryConfig +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode import org.mockito.MockitoSugar import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers @@ -44,57 +42,24 @@ class GCPConfigTest extends AnyFlatSpec with Matchers with LazyLogging with Mock private val authMode = mock[AuthMode] - "GCPConfig" should "set error policies in a case insensitive way" in { - - val errorPolicyValuesMap = Table( - ("testName", "value", "errorPolicyClass"), - ("lcvalue-noop", "noop", NoopErrorPolicy()), - ("lcvalue-throw", "throw", ThrowErrorPolicy()), - ("lcvalue-retry", "retry", RetryErrorPolicy()), - ("ucvalue-noop", "NOOP", NoopErrorPolicy()), - ("ucvalue-throw", "THROW", ThrowErrorPolicy()), - ("ucvalue-retry", "RETRY", RetryErrorPolicy()), - ("value-unspecified", "", ThrowErrorPolicy()), - ) - - forAll(errorPolicyValuesMap) { - (name, value, clazz) => - logger.debug("Executing {}", name) - GCPConnectionConfig(Map("connect.gcpstorage.error.policy" -> value), authMode).errorPolicy should be(clazz) - } - } - val retryValuesMap = Table[String, Any, Any, RetryConfig]( ("testName", "retries", "interval", "result"), - ("noret-noint", 0, 0, RetryConfig(0, 0)), - ("ret-and-int", 1, 2, RetryConfig(1, 2)), - ("noret-noint-strings", "0", "0", RetryConfig(0, 0)), - ("ret-and-int-strings", "1", "2", RetryConfig(1, 2)), + ("noret-noint", 0, 0, new RetryConfig(0, 0)), + ("ret-and-int", 1, 2, new RetryConfig(1, 2)), + ("noret-noint-strings", "0", "0", new RetryConfig(0, 0)), + ("ret-and-int-strings", "1", "2", new RetryConfig(1, 2)), ) - "GCPConfig" should "set retry config" in { - forAll(retryValuesMap) { - (name: String, ret: Any, interval: Any, result: RetryConfig) => - logger.debug("Executing {}", name) - GCPConnectionConfig(Map( - "connect.gcpstorage.max.retries" -> ret, - "connect.gcpstorage.retry.interval" -> interval, - ), - authMode, - ).connectorRetryConfig should be(result) - } - } - "GCPConfig" should "set http retry config" in { forAll(retryValuesMap) { (name: String, ret: Any, interval: Any, result: RetryConfig) => logger.debug("Executing {}", name) - GCPConnectionConfig(Map( - "connect.gcpstorage.http.max.retries" -> ret, - "connect.gcpstorage.http.retry.interval" -> interval, - ), - authMode, - ).httpRetryConfig should be(result) + GCPConnectionConfigBuilder(Map( + "connect.gcpstorage.http.max.retries" -> ret, + "connect.gcpstorage.http.retry.interval" -> interval, + ), + authMode, + ).getHttpRetryConfig should be(result) } } diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/UploadSettingsTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/UploadSettingsTest.scala index 9e4b81e06..84e313d33 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/UploadSettingsTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/UploadSettingsTest.scala @@ -38,6 +38,8 @@ class UploadSettingsTest extends AnyFunSuite with Matchers with UploadConfigKeys override def getPassword(key: String): Password = ??? override def getList(key: String): util.List[String] = ??? + + override def getLong(key: String): lang.Long = ??? } test("isAvoidResumableUpload should default to false") { diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigTest.scala index db06b7015..c7803ddfb 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigTest.scala @@ -15,18 +15,26 @@ */ package io.lenses.streamreactor.connect.gcp.storage.sink.config +import com.typesafe.scalalogging.LazyLogging +import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.errors.NoopErrorPolicy +import io.lenses.streamreactor.common.errors.RetryErrorPolicy +import io.lenses.streamreactor.common.errors.ThrowErrorPolicy +import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.DataStorageSettings -import io.lenses.streamreactor.connect.cloud.common.config.kcqlprops.PropsKeyEnum.FlushCount import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocationValidator import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkBucketOptions import io.lenses.streamreactor.connect.gcp.storage.model.location.GCPStorageLocationValidator +import org.scalatest.EitherValues import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks._ -class GCPStorageSinkConfigTest extends AnyFunSuite with Matchers { +class GCPStorageSinkConfigTest extends AnyFunSuite with Matchers with LazyLogging with EitherValues { private implicit val connectorTaskId: ConnectorTaskId = ConnectorTaskId("connector", 1, 0) private implicit val cloudLocationValidator: CloudLocationValidator = GCPStorageLocationValidator + test("envelope and CSV storage is not allowed") { val props = Map( "connect.gcpstorage.kcql" -> s"insert into mybucket:myprefix select * from TopicName PARTITIONBY _key STOREAS `CSV` PROPERTIES('${FlushCount.entryName}'=1,'${DataStorageSettings.StoreEnvelopeKey}'=true)", @@ -88,4 +96,50 @@ class GCPStorageSinkConfigTest extends AnyFunSuite with Matchers { case Right(_) => fail("Should fail since envelope and bytes storage is not allowed") } } + + test("set error policies in a case insensitive way") { + + val errorPolicyValuesMap = Table( + ("testName", "value", "errorPolicyClass"), + ("lcvalue-noop", "noop", NoopErrorPolicy()), + ("lcvalue-throw", "throw", ThrowErrorPolicy()), + ("lcvalue-retry", "retry", RetryErrorPolicy()), + ("ucvalue-noop", "NOOP", NoopErrorPolicy()), + ("ucvalue-throw", "THROW", ThrowErrorPolicy()), + ("ucvalue-retry", "RETRY", RetryErrorPolicy()), + ("value-unspecified", "", ThrowErrorPolicy()), + ) + + forAll(errorPolicyValuesMap) { + (name, value, clazz) => + logger.debug("Executing {}", name) + GCPStorageSinkConfigDefBuilder(Map("connect.gcpstorage.kcql" -> "select * from blah", + "connect.gcpstorage.error.policy" -> value, + )).getErrorPolicyOrDefault should be(clazz) + } + } + + val retryValuesMap = Table[String, Any, Any, RetryConfig]( + ("testName", "retries", "interval", "result"), + ("noret-noint", 0, 0, new RetryConfig(0, 0)), + ("ret-and-int", 1, 2, new RetryConfig(1, 2)), + ("noret-noint-strings", "0", "0", new RetryConfig(0, 0)), + ("ret-and-int-strings", "1", "2", new RetryConfig(1, 2)), + ) + + test("should set retry config") { + forAll(retryValuesMap) { + (name: String, ret: Any, interval: Any, result: RetryConfig) => + logger.debug("Executing {}", name) + + val props = Map( + "connect.gcpstorage.kcql" -> s"insert into mybucket:myprefix select * from TopicName PARTITIONBY _key STOREAS `Bytes` PROPERTIES('${FlushCount.entryName}'=1,'${DataStorageSettings.StoreEnvelopeKey}'=true)", + "connect.gcpstorage.max.retries" -> s"$ret", + "connect.gcpstorage.retry.interval" -> s"$interval", + ) + + GCPStorageSinkConfigDefBuilder(props).getRetryConfig should be(result) + } + } + } diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala index 4bdfc0a73..95fbc31e3 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala @@ -17,8 +17,10 @@ package io.lenses.streamreactor.connect.gcp.storage.source.config import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocationValidator +import io.lenses.streamreactor.connect.gcp.common.auth.mode.CredentialsAuthMode import io.lenses.streamreactor.connect.gcp.storage.model.location.GCPStorageLocationValidator import org.apache.kafka.common.config.ConfigException +import org.apache.kafka.common.config.types.Password import org.scalatest.EitherValues import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers._ @@ -71,12 +73,25 @@ class GCPStorageSourceConfigTest extends AnyFunSuite with EitherValues { } test("apply should return Right with GCPStorageSourceConfig when valid properties are provided") { + val password = new Password("password") + val props = Map[String, AnyRef]( + "connect.gcpstorage.kcql" -> "select * from myBucket.azure insert into myTopic", + "connect.gcpstorage.gcp.auth.mode" -> "credentials", + "connect.gcpstorage.gcp.credentials" -> password, + ) + val storageConfig = GCPStorageSourceConfig.fromProps(taskId, props).value + storageConfig.connectionConfig.getAuthMode should be(new CredentialsAuthMode(password)) + } + + test("apply should return Left with ConnectException when password property is missed") { val props = Map[String, String]( "connect.gcpstorage.kcql" -> "select * from myBucket.azure insert into myTopic", "connect.gcpstorage.gcp.auth.mode" -> "credentials", ) - val result = GCPStorageSourceConfig.fromProps(taskId, props) - result.isRight shouldBe true + val ex = GCPStorageSourceConfig.fromProps(taskId, props).left.value + ex should be(a[ConfigException]) + ex.getMessage should be("No `connect.gcpstorage.gcp.credentials` specified in configuration") + } private def assertEitherException( diff --git a/kafka-connect-sql-common/src/test/scala/io/lenses/streamreactor/common/config/KcqlWithFieldsSettingsTest.scala b/kafka-connect-sql-common/src/test/scala/io/lenses/streamreactor/common/config/KcqlWithFieldsSettingsTest.scala index b2b2f3597..3911b8591 100644 --- a/kafka-connect-sql-common/src/test/scala/io/lenses/streamreactor/common/config/KcqlWithFieldsSettingsTest.scala +++ b/kafka-connect-sql-common/src/test/scala/io/lenses/streamreactor/common/config/KcqlWithFieldsSettingsTest.scala @@ -19,6 +19,7 @@ import org.apache.kafka.common.config.types.Password import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec +import java.lang import java.util import scala.jdk.CollectionConverters.SeqHasAsJava @@ -35,6 +36,7 @@ class KcqlWithFieldsSettingsTest extends AnyWordSpec with Matchers { override def getBoolean(key: String): java.lang.Boolean = false override def getPassword(key: String): Password = null override def getList(key: String): util.List[String] = List.empty[String].asJava + override def getLong(key: String): lang.Long = null } def testUpsertKeys( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index d1414198c..4d31f0104 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -80,11 +80,12 @@ object Dependencies { val azureDataLakeVersion = "12.18.4" val azureIdentityVersion = "1.12.0" val azureCoreVersion = "1.48.0" - val gcpStorageVersion = "2.37.0" + val gcpCloudVersion = "2.37.0" val jacksonVersion = "2.17.0" val json4sVersion = "4.0.7" val mockitoScalaVersion = "1.17.31" + val mockitoJavaVersion = "5.2.0" val openCsvVersion = "5.9" val jsonSmartVersion = "2.5.1" @@ -176,8 +177,9 @@ object Dependencies { val scalatest = "org.scalatest" %% "scalatest" % scalatestVersion val scalatestPlusScalaCheck = "org.scalatestplus" %% "scalatestplus-scalacheck" % scalatestPlusScalaCheckVersion - val scalaCheck = "org.scalacheck" %% "scalacheck" % scalaCheckVersion - val `mockitoScala` = "org.mockito" %% "mockito-scala" % mockitoScalaVersion + val scalaCheck = "org.scalacheck" %% "scalacheck" % scalaCheckVersion + val `mockitoScala` = "org.mockito" %% "mockito-scala" % mockitoScalaVersion + val `mockitoJava` = "org.mockito" % "mockito-inline" % mockitoJavaVersion val `junitJupiter` = "org.junit.jupiter" % "junit-jupiter-api" % junitJupiterVersion val `assertjCore` = "org.assertj" % "assertj-core" % assertjCoreVersion @@ -275,7 +277,9 @@ object Dependencies { lazy val azureIdentity: ModuleID = "com.azure" % "azure-identity" % azureIdentityVersion lazy val azureCore: ModuleID = "com.azure" % "azure-core" % azureCoreVersion - lazy val gcpStorageSdk = "com.google.cloud" % "google-cloud-storage" % gcpStorageVersion + lazy val gcpCloudCoreSdk = "com.google.cloud" % "google-cloud-core" % gcpCloudVersion + lazy val gcpCloudHttp = "com.google.cloud" % "google-cloud-core-http" % gcpCloudVersion + lazy val gcpStorageSdk = "com.google.cloud" % "google-cloud-storage" % gcpCloudVersion lazy val json4sNative = "org.json4s" %% "json4s-native" % json4sVersion lazy val json4sJackson = "org.json4s" %% "json4s-jackson" % json4sVersion @@ -448,7 +452,7 @@ trait Dependencies { ) ++ enumeratum ++ circe val javaCommonDeps: Seq[ModuleID] = Seq(lombok, kafkaConnectJson, kafkaClients) - val javaCommonTestDeps: Seq[ModuleID] = Seq(junitJupiter, assertjCore, `mockitoScala`, logback) + val javaCommonTestDeps: Seq[ModuleID] = Seq(junitJupiter, assertjCore, `mockitoJava`, logback) //Specific modules dependencies @@ -482,6 +486,13 @@ trait Dependencies { azureCore, ) + val kafkaConnectGcpCommonDeps: Seq[ModuleID] = Seq( + lombok, + kafkaClients, + gcpCloudCoreSdk, + gcpCloudHttp, + ) + val kafkaConnectGcpStorageDeps: Seq[ModuleID] = Seq( gcpStorageSdk, ) From 6ce00e16c582cf6ac15529745742a702bbd1a361 Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Sun, 5 May 2024 16:29:06 +0000 Subject: [PATCH 13/30] Update s3, sts to 2.25.45 --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 4d31f0104..01b8e7ce9 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -75,7 +75,7 @@ object Dependencies { val jerseyCommonVersion = "3.1.6" val calciteVersion = "1.34.0" - val awsSdkVersion = "2.25.40" + val awsSdkVersion = "2.25.45" val azureDataLakeVersion = "12.18.4" val azureIdentityVersion = "1.12.0" From c1cb89e63092acc051a79b429a3f39cbe90c1a0e Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Sun, 5 May 2024 16:26:57 +0000 Subject: [PATCH 14/30] Update elasticsearch to 7.17.21 --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 01b8e7ce9..85ca35946 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -146,7 +146,7 @@ object Dependencies { object Elastic7Versions extends ElasticVersions { override val elastic4sVersion: String = "7.17.4" - override val elasticSearchVersion: String = "7.17.20" + override val elasticSearchVersion: String = "7.17.21" override val jnaVersion: String = "5.14.0" } From 98b19ff92ea5521d419fe217b8dea707e517cd9b Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Sun, 5 May 2024 16:27:18 +0000 Subject: [PATCH 15/30] Update scala-library to 2.13.14 --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 85ca35946..5557ab0a0 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -21,7 +21,7 @@ object Dependencies { // scala versions val scalaOrganization = "org.scala-lang" - val scalaVersion = "2.13.13" + val scalaVersion = "2.13.14" val supportedScalaVersions: Seq[String] = List(Dependencies.scalaVersion) val commonResolvers: Seq[MavenRepository] = Resolver.sonatypeOssRepos("public") ++ From feed5182a2fc2c0f6c6af23d78323bc317daabbb Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Sun, 5 May 2024 16:24:39 +0000 Subject: [PATCH 16/30] Update azure-core to 1.49.0 --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 5557ab0a0..851e50a16 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -79,7 +79,7 @@ object Dependencies { val azureDataLakeVersion = "12.18.4" val azureIdentityVersion = "1.12.0" - val azureCoreVersion = "1.48.0" + val azureCoreVersion = "1.49.0" val gcpCloudVersion = "2.37.0" val jacksonVersion = "2.17.0" From 3a8dd6596c771ada627063582717fb52fcd81e7f Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Sun, 5 May 2024 16:28:44 +0000 Subject: [PATCH 17/30] Update sbt-scoverage to 2.0.12 --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 98315970e..5c9dfccc5 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,7 +2,7 @@ //addDependencyTreePlugin addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.12") addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.10.0") addSbtPlugin("org.xerial.sbt" % "sbt-pack" % "0.19") From 9a8537e198e0ec35c7f692ac11e89b633421afe8 Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Sun, 5 May 2024 16:25:46 +0000 Subject: [PATCH 18/30] Update jackson-module-scala to 2.17.1 --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 851e50a16..4414d148c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -82,7 +82,7 @@ object Dependencies { val azureCoreVersion = "1.49.0" val gcpCloudVersion = "2.37.0" - val jacksonVersion = "2.17.0" + val jacksonVersion = "2.17.1" val json4sVersion = "4.0.7" val mockitoScalaVersion = "1.17.31" val mockitoJavaVersion = "5.2.0" From ef2a4023a4cb9e0243c950f1c34e278c1ef411d6 Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Mon, 6 May 2024 19:40:13 +0100 Subject: [PATCH 19/30] Adding the support entry (#1204) Co-authored-by: stheppi --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index d9f00a255..f7278a145 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,16 @@ Lenses offers the leading Developer Experience solution for engineers building r Speak to us on our Community Slack channel (Register at https://launchpass.com/lensesio) or ask the Community a question in our [Ask Marios](http://www.lenses.io) forum. +## Version Support Policy + +Under our standard support agreement, we provide assistance for the current major version as well as the preceding major version exclusively. For instance, if version 8.x is the current major release, we offer support for Lenses connectors versions within the 8.x and 7.x series. + +Enterprise-level support commences from version 7.0 onwards. + +Lenses prioritizes backporting fixes rather than incorporating new features, and this is done on a limited basis across select prior releases. For further clarification, please refer to our maintenance policy. + +Should you reach out to our support team regarding issues encountered while using an unsupported version, we will direct you to this section of our policy page and encourage you to upgrade. + ## Kafka Connectors Roadmap A series of next-generation Connectors are in active development. Give us your feedback of which connectors we should be working on or to to get the latest information, send us an email at info@lenses.io From 032351614d94f27b6e753033252d2e4056c06eba Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Tue, 7 May 2024 11:35:58 +0200 Subject: [PATCH 20/30] Update azure-identity to 1.12.1 (#1195) Signed-off-by: David Sloan <33483659+davidsloan@users.noreply.github.com> Co-authored-by: David Sloan <33483659+davidsloan@users.noreply.github.com> --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 4414d148c..82aab6d2b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -78,7 +78,7 @@ object Dependencies { val awsSdkVersion = "2.25.45" val azureDataLakeVersion = "12.18.4" - val azureIdentityVersion = "1.12.0" + val azureIdentityVersion = "1.12.1" val azureCoreVersion = "1.49.0" val gcpCloudVersion = "2.37.0" From 224ce8da7a7e766920c2561c13f4f93039badcdf Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Wed, 8 May 2024 10:08:53 +0200 Subject: [PATCH 21/30] Update s3, sts to 2.25.47 (#1209) --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 82aab6d2b..95f1f8a97 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -75,7 +75,7 @@ object Dependencies { val jerseyCommonVersion = "3.1.6" val calciteVersion = "1.34.0" - val awsSdkVersion = "2.25.45" + val awsSdkVersion = "2.25.47" val azureDataLakeVersion = "12.18.4" val azureIdentityVersion = "1.12.1" From 4c02baf65fb31e51e853d8ffe9459bed3db4655f Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Wed, 8 May 2024 10:17:55 +0200 Subject: [PATCH 22/30] Update sbt to 1.10.0 (#1208) --- project/build.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.properties b/project/build.properties index 04267b14a..081fdbbc7 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.9.9 +sbt.version=1.10.0 From 523057148a6748db88c8515ad5c3d373cc53edba Mon Sep 17 00:00:00 2001 From: Brandon Powers Date: Wed, 8 May 2024 04:30:05 -0400 Subject: [PATCH 23/30] Add ADOPTERS.md (#1190) --- ADOPTERS.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 ADOPTERS.md diff --git a/ADOPTERS.md b/ADOPTERS.md new file mode 100644 index 000000000..263b0d9f0 --- /dev/null +++ b/ADOPTERS.md @@ -0,0 +1,8 @@ +# Stream Reactor Adopters + +If you're currently using Lenses.io [Stream Reactor](https://github.com/lensesio/stream-reactor) or [SMT](https://github.com/lensesio/kafka-connect-smt) Kafka connector plugins in production, please let us know by adding your company below. + + +| Organization | Contact | Usage Description | +| ------------ |---------------------|-----------------------------------------------------------------------------------| +| [Skillsoft](https://www.skillsoft.com/) | [@brandon-powers](https://github.com/brandon-powers) | Amazon S3 sink & source connectors + SMTs for Apache Kafka backup and disaster recovery. | \ No newline at end of file From 2859a8b16a60b1ad8e6730d0c3715a0ed7449de0 Mon Sep 17 00:00:00 2001 From: David Sloan <33483659+davidsloan@users.noreply.github.com> Date: Wed, 8 May 2024 11:46:34 +0100 Subject: [PATCH 24/30] GCP Commons Extraction - Part II (#1191) * Part Deux * Add gradle build for GCP commons * Wrapping AbstractConfig with ConfigAdaptorSource via interface ConfigSource * Changes post review #1 * Changes post review #2 Co-authored-by: Mati Urban * Test fix * Introduce ConfigSettings * Add license header --------- Co-authored-by: Mati Urban --- java-connectors/build.gradle | 6 +- .../build.gradle | 2 +- .../common/config/base/ConfigSettings.java | 43 ++++ .../common/config/source/ConfigSource.java | 57 +++++ .../config/source/ConfigWrapperSource.java | 50 ++++ .../MapConfigSource.java} | 36 ++- .../common/config/base/ConfigMapTest.java | 72 ------ .../config/source/ConfigSourceTestBase.java | 69 ++++++ .../source/ConfigWrapperSourceTest.java | 33 +++ .../config/source/MapConfigSourceTest.java | 31 +++ .../kafka-connect-gcp-common/build.gradle | 22 ++ .../gcp/common/auth/GCPConnectionConfig.java | 60 ++--- .../auth/GCPServiceBuilderConfigurer.java | 50 ++-- .../gcp/common/auth/HttpTimeoutConfig.java | 9 +- .../gcp/common/auth/mode/AuthMode.java | 16 +- .../common/auth/mode/CredentialsAuthMode.java | 20 +- .../gcp/common/auth/mode/DefaultAuthMode.java | 11 +- .../gcp/common/auth/mode/FileAuthMode.java | 17 +- .../gcp/common/auth/mode/NoAuthMode.java | 8 +- .../gcp/common/config/AuthModeSettings.java | 187 ++++++++------- .../gcp/common/config/GCPSettings.java | 151 ++++++++++++ .../auth/GCPServiceBuilderConfigurerTest.java | 217 +++++++++--------- .../connect/gcp/common/auth/TestService.java | 50 ++-- .../auth/mode/CredentialsAuthModeTest.java | 23 +- .../common/auth/mode/DefaultAuthModeTest.java | 26 +-- .../common/auth/mode/FileAuthModeTest.java | 23 +- .../gcp/common/auth/mode/NoAuthModeTest.java | 17 +- .../gcp/common/auth/mode/TestFileUtil.java | 32 +-- .../common/config/AuthModeSettingsTest.java | 190 +++++++-------- .../gcp/common/config/GCPSettingsTest.java | 58 +++++ java-connectors/settings.gradle | 4 +- .../storage/utils/GCPProxyContainerTest.scala | 6 +- .../gcp/storage/config/CommonConfigDef.scala | 77 +------ .../storage/config/GCPConfigSettings.scala | 22 -- .../config/GCPConnectionConfigBuilder.scala | 42 ---- ...thModeSettings.scala => GCPSettings.scala} | 17 +- .../sink/config/GCPStorageSinkConfig.scala | 12 +- .../GCPStorageSinkConfigDefBuilder.scala | 4 +- .../config/GCPStorageSourceConfig.scala | 9 +- .../GCPStorageSourceConfigDefBuilder.scala | 4 +- .../storage/config/CommonConfigDefTest.scala | 10 +- .../config/GCPConfigSettingsTest.scala | 2 +- .../gcp/storage/config/GCPConfigTest.scala | 66 ------ .../config/GCPStorageSourceConfigTest.scala | 15 +- project/Dependencies.scala | 7 +- 45 files changed, 1078 insertions(+), 805 deletions(-) create mode 100644 java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java create mode 100644 java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java create mode 100644 java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java rename java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/{base/ConfigMap.java => source/MapConfigSource.java} (67%) delete mode 100644 java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java create mode 100644 java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java create mode 100644 java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java create mode 100644 java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java create mode 100644 java-connectors/kafka-connect-gcp-common/build.gradle create mode 100644 java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java create mode 100644 java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java delete mode 100644 kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala rename kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/{AuthModeSettings.scala => GCPSettings.scala} (55%) delete mode 100644 kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala diff --git a/java-connectors/build.gradle b/java-connectors/build.gradle index 0a413c30e..a6a8663dc 100644 --- a/java-connectors/build.gradle +++ b/java-connectors/build.gradle @@ -55,8 +55,10 @@ allprojects { implementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion //lombok - compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30' + compileOnly group: 'org.projectlombok', name: 'lombok', version: lombokVersion + testCompileOnly group: 'org.projectlombok', name: 'lombok', version: lombokVersion annotationProcessor group: 'org.projectlombok', name: 'lombok', version: lombokVersion + testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version: lombokVersion //tests testImplementation group: 'org.mockito', name: 'mockito-core', version: mockitoJupiterVersion @@ -162,7 +164,7 @@ task prepareRelease(dependsOn: [collectFatJar]) { task releaseModuleList() { def nonReleaseModules = ["java-reactor", "kafka-connect-cloud-common", - "kafka-connect-common", "kafka-connect-query-language"] + "kafka-connect-common", "kafka-connect-query-language"] def modulesFile = new File("gradle-modules.txt") modulesFile.delete() diff --git a/java-connectors/kafka-connect-azure-eventhubs/build.gradle b/java-connectors/kafka-connect-azure-eventhubs/build.gradle index 60ea80b6c..68148b5f4 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/build.gradle +++ b/java-connectors/kafka-connect-azure-eventhubs/build.gradle @@ -16,4 +16,4 @@ project(':kafka-connect-azure-eventhubs') { // implementation group: 'com.azure', name: 'azure-messaging-eventhubs-checkpointstore-blob', version: '1.19.0' } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java new file mode 100644 index 000000000..47a253472 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.base; + +import io.lenses.streamreactor.common.config.source.ConfigSource; +import org.apache.kafka.common.config.ConfigDef; + +/** + * Defines operations to manage settings and parse configurations into objects. + * + * @param the type of object to materialize from settings + */ +public interface ConfigSettings { + + /** + * Adds the settings defined by this interface to the provided {@code ConfigDef}. + * + * @param configDef the {@code ConfigDef} to which settings should be added + * @return the updated {@code ConfigDef} with added settings + */ + ConfigDef withSettings(ConfigDef configDef); + + /** + * Parses settings from the specified {@code ConfigSource} and materializes an object of type {@code M}. + * + * @param configSource the {@code ConfigSource} containing configuration settings + * @return an object of type {@code M} materialized from the given {@code ConfigSource} + */ + M parseFromConfig(ConfigSource configSource); +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java new file mode 100644 index 000000000..25d70bd8e --- /dev/null +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.source; + +import java.util.Optional; +import org.apache.kafka.common.config.types.Password; + +/** + * A source for retrieving configuration properties, including sensitive data like passwords. + * Implementations of this interface provide methods to retrieve property values based on specific key types. + */ +public interface ConfigSource { + /** + * Retrieves a String property value associated with the given key. + * + * @param key the property key + * @return an {@link Optional} containing the property value if present, otherwise empty + */ + Optional getString(String key); + + /** + * Retrieves a String property value associated with the given key. + * + * @param key the property key + * @return an {@link Optional} containing the property value if present, otherwise empty + */ + Optional getInt(String key); + + /** + * Retrieves a String property value associated with the given key. + * + * @param key the property key + * @return an {@link Optional} containing the property value if present, otherwise empty + */ + Optional getLong(String key); + + /** + * Retrieves a Password property value associated with the given key. + * + * @param key the property key + * @return an {@link Optional} containing the {@link Password} value if present, otherwise empty + */ + Optional getPassword(String key); +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java new file mode 100644 index 000000000..f578f57c5 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java @@ -0,0 +1,50 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.source; + +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.apache.kafka.common.config.AbstractConfig; +import org.apache.kafka.common.config.types.Password; + +/** + * A wrapper for Kafka Connect properties stored in the `AbstractConfig` that provides methods to retrieve property values. + */ +@AllArgsConstructor +public class ConfigWrapperSource implements ConfigSource { + + private final AbstractConfig abstractConfig; + + @Override + public Optional getString(String key) { + return Optional.ofNullable(abstractConfig.getString(key)); + } + + @Override + public Optional getInt(String key) { + return Optional.ofNullable(abstractConfig.getInt(key)); + } + + @Override + public Optional getLong(String key) { + return Optional.ofNullable(abstractConfig.getLong(key)); + } + + @Override + public Optional getPassword(String key) { + return Optional.ofNullable(abstractConfig.getPassword(key)); + } +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigMap.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/MapConfigSource.java similarity index 67% rename from java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigMap.java rename to java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/MapConfigSource.java index 4154e5687..66ab9c316 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigMap.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/MapConfigSource.java @@ -13,40 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.lenses.streamreactor.common.config.base; - -import lombok.AllArgsConstructor; -import org.apache.kafka.common.config.types.Password; +package io.lenses.streamreactor.common.config.source; import java.util.Map; import java.util.Optional; +import lombok.AllArgsConstructor; +import org.apache.kafka.common.config.types.Password; /** - * A wrapper for Kafka Connect properties that provides methods to retrieve property values. + * A wrapper for Kafka Connect properties that provides methods to retrieve property values from a Map.. */ @AllArgsConstructor -public class ConfigMap { +public class MapConfigSource implements ConfigSource { private final Map wrapped; - /** - * Retrieves a String property value associated with the given key. - * - * @param key the property key - * @return an {@link Optional} containing the property value if present, otherwise empty - */ + @Override public Optional getString(String key) { return Optional.ofNullable((String) wrapped.get(key)); } - /** - * Retrieves a Password property value associated with the given key. - * - * @param key the property key - * @return an {@link Optional} containing the {@link Password} value if present, otherwise empty - */ + @Override + public Optional getInt(String key) { + return Optional.ofNullable((Integer) wrapped.get(key)); + } + + @Override + public Optional getLong(String key) { + return Optional.ofNullable((Long) wrapped.get(key)); + } + + @Override public Optional getPassword(String key) { return Optional.ofNullable((Password) wrapped.get(key)); } - } diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java deleted file mode 100644 index 5a8a67792..000000000 --- a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/base/ConfigMapTest.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.common.config.base; - -import org.apache.kafka.common.config.types.Password; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ConfigMapTest { - - private ConfigMap configMap; - - @BeforeEach - void setUp() { - Map testMap = new HashMap<>(); - testMap.put("username", "user123"); - testMap.put("password", new Password("secret")); - - configMap = new ConfigMap(testMap); - } - - @Test - void testGetString_existingKey_shouldReturnValue() { - Optional value = configMap.getString("username"); - - assertTrue(value.isPresent()); - assertEquals("user123", value.get()); - } - - @Test - void testGetString_nonExistingKey_shouldReturnEmpty() { - Optional value = configMap.getString("invalidKey"); - - assertFalse(value.isPresent()); - } - - @Test - void testGetPassword_existingKey_shouldReturnPassword() { - Optional password = configMap.getPassword("password"); - - assertTrue(password.isPresent()); - assertEquals("secret", password.get().value()); - } - - @Test - void testGetPassword_nonExistingKey_shouldReturnEmpty() { - Optional password = configMap.getPassword("invalidKey"); - - assertFalse(password.isPresent()); - } -} diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java new file mode 100644 index 000000000..cdaec6776 --- /dev/null +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java @@ -0,0 +1,69 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.source; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Optional; +import org.apache.kafka.common.config.types.Password; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +abstract class ConfigSourceTestBase { + + protected static final String PASSWORD_KEY = "password"; + protected static final Password PASSWORD_VALUE = new Password("secret"); + protected static final String USERNAME_KEY = "username"; + protected static final String USERNAME_VALUE = "user123"; + private ConfigSource configSource; + + @BeforeEach + public void setUp() { + configSource = createConfigSource(); + } + + abstract ConfigSource createConfigSource(); + + @Test + void testGetString_existingKey_shouldReturnValue() { + Optional value = configSource.getString(USERNAME_KEY); + + assertTrue(value.isPresent()); + assertEquals(USERNAME_VALUE, value.get()); + } + + @Test + void testGetString_nonExistingKey_shouldReturnEmpty() { + Optional value = configSource.getString("invalidKey"); + + assertFalse(value.isPresent()); + } + + @Test + void testGetPassword_existingKey_shouldReturnPassword() { + Optional password = configSource.getPassword(PASSWORD_KEY); + + assertTrue(password.isPresent()); + assertEquals(PASSWORD_VALUE, password.get()); + } + + @Test + void testGetPassword_nonExistingKey_shouldReturnEmpty() { + Optional password = configSource.getPassword("invalidKey"); + + assertFalse(password.isPresent()); + } +} diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java new file mode 100644 index 000000000..ddd22ebfd --- /dev/null +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.source; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.kafka.common.config.AbstractConfig; + +class ConfigWrapperSourceTest extends ConfigSourceTestBase { + + ConfigSource createConfigSource() { + + AbstractConfig config = mock(AbstractConfig.class); + when(config.getString(USERNAME_KEY)).thenReturn(USERNAME_VALUE); + when(config.getPassword(PASSWORD_KEY)).thenReturn(PASSWORD_VALUE); + + return new ConfigWrapperSource(config); + } +} diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java new file mode 100644 index 000000000..d8452732d --- /dev/null +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.common.config.source; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +class MapConfigSourceTest extends ConfigSourceTestBase { + + @Override + ConfigSource createConfigSource() { + return new MapConfigSource( + Map.of( + USERNAME_KEY, USERNAME_VALUE, + PASSWORD_KEY, PASSWORD_VALUE)); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/build.gradle b/java-connectors/kafka-connect-gcp-common/build.gradle new file mode 100644 index 000000000..f6b031abb --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/build.gradle @@ -0,0 +1,22 @@ +project(":kafka-connect-gcp-common") { + + test { + maxParallelForks = 1 + } + + ext { + gcpCloudVersion = "2.37.0" + } + + dependencies { + implementation project(':kafka-connect-common') + + //apache kafka + api group: 'org.apache.kafka', name: 'connect-json', version: kafkaVersion + api group: 'org.apache.kafka', name: 'kafka-clients', version: kafkaVersion + + //gcp + implementation group: 'com.google.cloud', name: 'google-cloud-core', version: gcpCloudVersion + implementation group: 'com.google.cloud', name: 'google-cloud-core-http', version: gcpCloudVersion + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java index d7f6c0f60..9e0ab6e27 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java @@ -18,36 +18,42 @@ import io.lenses.streamreactor.common.config.base.RetryConfig; import io.lenses.streamreactor.common.config.base.intf.ConnectionConfig; import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.Getter; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; +import java.util.Optional; + +import static io.lenses.streamreactor.connect.gcp.common.config.GCPSettings.HTTP_ERROR_RETRY_INTERVAL_DEFAULT; +import static io.lenses.streamreactor.connect.gcp.common.config.GCPSettings.HTTP_NUMBER_OF_RETIRES_DEFAULT; -@Data @Builder -@AllArgsConstructor -public class GCPConnectionConfig implements ConnectionConfig { - - // TODO: These values are duplicated with GCPConfigSettings. This will be fixed in the next PR. - private static final int HTTP_NUM_OF_RETRIES_DEFAULT = 5; - private static final long HTTP_ERROR_RETRY_INTERVAL_DEFAULT = 50L; - - @Nullable - private String projectId; - @Nullable - private String quotaProjectId; - @Nonnull - private AuthMode authMode; - @Nullable - private String host; - @Nonnull - @Builder.Default - private RetryConfig httpRetryConfig = RetryConfig.builder().retryLimit(HTTP_NUM_OF_RETRIES_DEFAULT).retryIntervalMillis(HTTP_ERROR_RETRY_INTERVAL_DEFAULT).build(); - @Nonnull - @Builder.Default - private HttpTimeoutConfig timeouts = HttpTimeoutConfig.builder().build(); +@Getter +public class GCPConnectionConfig implements ConnectionConfig { -} + private String projectId; + private String quotaProjectId; + private AuthMode authMode; + private String host; + + @Builder.Default + private RetryConfig httpRetryConfig = + RetryConfig.builder() + .retryLimit(HTTP_NUMBER_OF_RETIRES_DEFAULT) + .retryIntervalMillis(HTTP_ERROR_RETRY_INTERVAL_DEFAULT) + .build(); + + @Builder.Default + private HttpTimeoutConfig timeouts = HttpTimeoutConfig.builder().build(); + public Optional getAuthMode() { + return Optional.ofNullable(authMode); + } + + public Optional getHttpRetryConfig() { + return Optional.ofNullable(httpRetryConfig); + } + + public Optional getTimeouts() { + return Optional.ofNullable(timeouts); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java index 43bf2211b..d94929c38 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java @@ -21,12 +21,14 @@ import com.google.cloud.TransportOptions; import com.google.cloud.http.HttpTransportOptions; import io.lenses.streamreactor.common.config.base.RetryConfig; +import java.io.IOException; +import java.util.Optional; +import java.util.function.Supplier; import lombok.experimental.UtilityClass; import lombok.val; +import org.apache.kafka.common.config.ConfigException; import org.threeten.bp.Duration; -import java.io.IOException; -import java.util.Optional; /** * Utility class for configuring generic GCP service clients using a {@link GCPConnectionConfig}. */ @@ -47,38 +49,40 @@ public class GCPServiceBuilderConfigurer { public static < X extends Service, Y extends ServiceOptions, - B extends ServiceOptions.Builder - > - B configure(GCPConnectionConfig config, B builder) throws IOException { + B extends ServiceOptions.Builder> + B configure(GCPConnectionConfig config, B builder) throws IOException { Optional.ofNullable(config.getHost()).ifPresent(builder::setHost); - Optional.ofNullable(config.getProjectId()).ifPresent(builder :: setProjectId); + Optional.ofNullable(config.getProjectId()).ifPresent(builder::setProjectId); - Optional.ofNullable(config.getQuotaProjectId()).ifPresent(builder :: setQuotaProjectId); + Optional.ofNullable(config.getQuotaProjectId()).ifPresent(builder::setQuotaProjectId); - val authMode = config.getAuthMode(); + val authMode = config.getAuthMode() + .orElseThrow(createConfigException("AuthMode has to be configured by setting x.y.z property")); builder.setCredentials(authMode.getCredentials()); - builder.setRetrySettings(createRetrySettings(config.getHttpRetryConfig())); + builder.setRetrySettings(createRetrySettings( + config.getHttpRetryConfig() + .orElseThrow(createConfigException("RetrySettings has to be configured by setting a.b")))); - createTransportOptions(config.getTimeouts()).ifPresent(builder::setTransportOptions); + createTransportOptions(config.getTimeouts() + .orElseThrow(createConfigException("TransportOptions have to be configured by setting c.d"))) + .ifPresent(builder::setTransportOptions); return builder; } - private static Optional createTransportOptions(HttpTimeoutConfig timeoutConfig) { + private static Optional createTransportOptions( + HttpTimeoutConfig timeoutConfig) { val connectionTimeout = Optional.ofNullable(timeoutConfig.getConnectionTimeoutMillis()); val socketTimeout = Optional.ofNullable(timeoutConfig.getSocketTimeoutMillis()); if (connectionTimeout.isPresent() || socketTimeout.isPresent()) { HttpTransportOptions.Builder httpTransportOptionsBuilder = HttpTransportOptions.newBuilder(); - socketTimeout.ifPresent(sock -> - httpTransportOptionsBuilder.setReadTimeout(sock.intValue()) - ); - connectionTimeout.ifPresent(conn -> - httpTransportOptionsBuilder.setConnectTimeout(conn.intValue()) - ); + socketTimeout.ifPresent(sock -> httpTransportOptionsBuilder.setReadTimeout(sock.intValue())); + connectionTimeout.ifPresent( + conn -> httpTransportOptionsBuilder.setConnectTimeout(conn.intValue())); return Optional.of(httpTransportOptionsBuilder.build()); } return Optional.empty(); @@ -87,9 +91,13 @@ private static Optional createTransportOptions(HttpTimeoutConf private static RetrySettings createRetrySettings(RetryConfig httpRetryConfig) { return RetrySettings.newBuilder() - .setInitialRetryDelay(Duration.ofMillis(httpRetryConfig.getRetryIntervalMillis())) - .setMaxRetryDelay(Duration.ofMillis(httpRetryConfig.getRetryIntervalMillis() * 5)) - .setMaxAttempts(httpRetryConfig.getRetryLimit()) - .build(); + .setInitialRetryDelay(Duration.ofMillis(httpRetryConfig.getRetryIntervalMillis())) + .setMaxRetryDelay(Duration.ofMillis(httpRetryConfig.getRetryIntervalMillis() * 5L)) + .setMaxAttempts(httpRetryConfig.getRetryLimit()) + .build(); + } + + private Supplier createConfigException(String message) { + return () -> new ConfigException(message); } } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java index 44514eb15..1b53c9b3d 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java @@ -19,15 +19,10 @@ import lombok.Builder; import lombok.Data; -import javax.annotation.Nullable; -import java.util.Optional; - @Data @Builder @AllArgsConstructor public class HttpTimeoutConfig { - @Nullable - private Long socketTimeoutMillis; - @Nullable - private Long connectionTimeoutMillis; + private Long socketTimeoutMillis; + private Long connectionTimeoutMillis; } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java index 9e1fda8e5..cd335f000 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java @@ -16,7 +16,6 @@ package io.lenses.streamreactor.connect.gcp.common.auth.mode; import com.google.auth.Credentials; - import java.io.IOException; /** @@ -25,12 +24,11 @@ */ public interface AuthMode { - /** - * Retrieves the GCP credentials required for authentication. - * - * @return The GCP {@link Credentials}. - * @throws IOException If an I/O error occurs while obtaining credentials. - */ - Credentials getCredentials() throws IOException; + /** + * Retrieves the GCP credentials required for authentication. + * + * @return The GCP {@link Credentials}. + * @throws IOException If an I/O error occurs while obtaining credentials. + */ + Credentials getCredentials() throws IOException; } - diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java index b9326ee2a..953632a3c 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java @@ -17,25 +17,25 @@ import com.google.auth.Credentials; import com.google.auth.oauth2.GoogleCredentials; +import java.io.ByteArrayInputStream; +import java.io.IOException; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; +import lombok.ToString; import org.apache.kafka.common.config.types.Password; -import java.io.ByteArrayInputStream; -import java.io.IOException; - /** * Authentication mode using credentials from a string in configuration. */ @AllArgsConstructor @EqualsAndHashCode +@ToString public class CredentialsAuthMode implements AuthMode { - private final Password passwordCredentials; + private final Password passwordCredentials; - @Override - public Credentials getCredentials() throws IOException { - return GoogleCredentials.fromStream( - new ByteArrayInputStream(passwordCredentials.value().getBytes()) - ); - } + @Override + public Credentials getCredentials() throws IOException { + return GoogleCredentials.fromStream( + new ByteArrayInputStream(passwordCredentials.value().getBytes())); + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java index d3542e2a7..520ae8d38 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java @@ -17,9 +17,8 @@ import com.google.auth.Credentials; import com.google.auth.oauth2.GoogleCredentials; -import lombok.EqualsAndHashCode; - import java.io.IOException; +import lombok.EqualsAndHashCode; /** * Default authentication mode without explicit credentials. @@ -31,8 +30,8 @@ @EqualsAndHashCode public class DefaultAuthMode implements AuthMode { - @Override - public Credentials getCredentials() throws IOException { - return GoogleCredentials.getApplicationDefault(); - } + @Override + public Credentials getCredentials() throws IOException { + return GoogleCredentials.getApplicationDefault(); + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java index 1c4689579..e6cba5775 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java @@ -17,22 +17,23 @@ import com.google.auth.Credentials; import com.google.auth.oauth2.GoogleCredentials; -import lombok.AllArgsConstructor; - import java.io.FileInputStream; import java.io.IOException; +import lombok.AllArgsConstructor; +import lombok.ToString; /** * Authentication mode using a json file for credentials. */ @AllArgsConstructor +@ToString public class FileAuthMode implements AuthMode { - private final String filePath; + private final String filePath; - @Override - public Credentials getCredentials() throws IOException { - FileInputStream fileInputStream = new FileInputStream(filePath); - return GoogleCredentials.fromStream(fileInputStream); - } + @Override + public Credentials getCredentials() throws IOException { + FileInputStream fileInputStream = new FileInputStream(filePath); + return GoogleCredentials.fromStream(fileInputStream); + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java index 860155f4b..916a605f6 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java @@ -25,8 +25,8 @@ @EqualsAndHashCode public class NoAuthMode implements AuthMode { - @Override - public Credentials getCredentials() { - return NoCredentials.getInstance(); - } + @Override + public Credentials getCredentials() { + return NoCredentials.getInstance(); + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java index 289bc7fee..21603a787 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java @@ -15,8 +15,9 @@ */ package io.lenses.streamreactor.connect.gcp.common.config; -import io.lenses.streamreactor.common.config.base.ConfigMap; +import io.lenses.streamreactor.common.config.base.ConfigSettings; import io.lenses.streamreactor.common.config.base.model.ConnectorPrefix; +import io.lenses.streamreactor.common.config.source.ConfigSource; import io.lenses.streamreactor.connect.gcp.common.auth.mode.*; import lombok.Getter; import org.apache.kafka.common.config.ConfigDef; @@ -40,94 +41,112 @@ * - {@code gcp.file}: Key for specifying the file path containing GCP credentials (used with 'file' auth mode). */ @Getter -public class AuthModeSettings { +public class AuthModeSettings implements ConfigSettings { - public static final String EMPTY_STRING = ""; + public static final String EMPTY_STRING = ""; - // Auth Mode values - public static final String PROP_KEY_CREDENTIALS = "CREDENTIALS"; - public static final String PROP_KEY_FILE = "FILE"; - public static final String PROP_KEY_NONE = "NONE"; - public static final String PROP_KEY_DEFAULT = "DEFAULT"; + // Auth Mode values + public static final String PROP_KEY_CREDENTIALS = "CREDENTIALS"; + public static final String PROP_KEY_FILE = "FILE"; + public static final String PROP_KEY_NONE = "NONE"; + public static final String PROP_KEY_DEFAULT = "DEFAULT"; - private final String authModeKey; - private final String credentialsKey; - private final String fileKey; + private final String authModeKey; + private final String credentialsKey; + private final String fileKey; - /** - * Constructs an instance of AuthModeSettings. - * - * @param connectorPrefix The prefix used to generate keys for configuration settings. - */ - public AuthModeSettings(ConnectorPrefix connectorPrefix) { - this.authModeKey = connectorPrefix.prefixKey("gcp.auth.mode"); - this.credentialsKey = connectorPrefix.prefixKey("gcp.credentials"); - this.fileKey = connectorPrefix.prefixKey("gcp.file"); - } + /** + * Constructs an instance of AuthModeSettings. + * + * @param connectorPrefix The prefix used to generate keys for configuration settings. + */ + public AuthModeSettings(ConnectorPrefix connectorPrefix) { + authModeKey = connectorPrefix.prefixKey("gcp.auth.mode"); + credentialsKey = connectorPrefix.prefixKey("gcp.credentials"); + fileKey = connectorPrefix.prefixKey("gcp.file"); + } + /** + * Configures the provided ConfigDef with authentication mode settings. + * + * @param configDef The ConfigDef instance to be updated with authentication mode definitions. + * @return The updated ConfigDef with authentication mode settings defined. + */ + @Override + public ConfigDef withSettings(ConfigDef configDef) { + return configDef + .define( + authModeKey, + Type.STRING, + PROP_KEY_DEFAULT, + Importance.HIGH, + "Authenticate mode, 'credentials', 'file', 'default' or 'none'") + .define( + credentialsKey, + Type.PASSWORD, + EMPTY_STRING, + Importance.HIGH, + "GCP Credentials if using 'credentials' auth mode.") + .define( + fileKey, + Type.STRING, + EMPTY_STRING, + Importance.HIGH, + "File containing GCP Credentials if using 'file' auth mode. This can be relative from" + + " the current working directory of the java process or from the root. Remember" + + " your path format is operating system dependent. (eg for unix-based" + + " /home/my/path/file)"); + } - /** - * Configures the provided ConfigDef with authentication mode settings. - * - * @param configDef The ConfigDef instance to be updated with authentication mode definitions. - * @return The updated ConfigDef with authentication mode settings defined. - */ - public ConfigDef withAuthModeSettings(ConfigDef configDef) { - return configDef.define( - authModeKey, - Type.STRING, - PROP_KEY_DEFAULT, - Importance.HIGH, - "Authenticate mode, 'credentials', 'file', 'default' or 'none'" - ) - .define( - credentialsKey, - Type.PASSWORD, - EMPTY_STRING, - Importance.HIGH, - "GCP Credentials if using 'credentials' auth mode." - ) - .define( - fileKey, - Type.STRING, - EMPTY_STRING, - Importance.HIGH, - "File containing GCP Credentials if using 'file' auth mode. This can be relative from the current working directory of the java process or from the root. Remember your path format is operating system dependent. (eg for unix-based /home/my/path/file)" - ); - } + /** + * Parses authentication mode from the provided ConfigMap and returns the corresponding AuthMode instance. + * + * @param configSource The ConfigSource containing configuration settings. + * @return The parsed AuthMode based on the configuration settings. + * @throws ConfigException If an invalid or unsupported authentication mode is specified. + */ + @Override public AuthMode parseFromConfig(ConfigSource configSource) { + return configSource + .getString(getAuthModeKey()) + .map( + authModeString -> { + switch (authModeString.toUpperCase()) { + case PROP_KEY_CREDENTIALS: + return createCredentialsAuthMode(configSource); + case PROP_KEY_FILE: + return createFileAuthMode(configSource); + case PROP_KEY_NONE: + return new NoAuthMode(); + case PROP_KEY_DEFAULT: + return new DefaultAuthMode(); + case EMPTY_STRING: + default: + throw new ConfigException( + String.format("Unsupported auth mode `%s`", authModeString)); + } + }) + .orElse(new DefaultAuthMode()); + } - /** - * Parses authentication mode from the provided ConfigMap and returns the corresponding AuthMode instance. - * - * @param configMap The ConfigMap containing configuration settings. - * @return The parsed AuthMode based on the configuration settings. - * @throws ConfigException If an invalid or unsupported authentication mode is specified. - */ - public AuthMode parseFromConfig(ConfigMap configMap) { - return configMap.getString(getAuthModeKey()) - .map(authModeString -> { - switch (authModeString.toUpperCase()) { - case PROP_KEY_CREDENTIALS: - return createCredentialsAuthMode(configMap); - case PROP_KEY_FILE: - return createFileAuthMode(configMap); - case PROP_KEY_NONE: - return new NoAuthMode(); - case PROP_KEY_DEFAULT: - return new DefaultAuthMode(); - case EMPTY_STRING: - default: - throw new ConfigException(String.format("Unsupported auth mode `%s`", authModeString)); - } - }) - .orElse(new DefaultAuthMode()); - } + private FileAuthMode createFileAuthMode(ConfigSource configSource) { + return configSource + .getString(getFileKey()) + .filter(file -> !(file.isEmpty())) + .map(FileAuthMode::new) + .orElseThrow( + () -> + new ConfigException( + String.format("No `%s` specified in configuration", getFileKey()))); + } - private FileAuthMode createFileAuthMode(ConfigMap configMap) { - return configMap.getString(getFileKey()).map(FileAuthMode::new).orElseThrow(() -> new ConfigException(String.format("No `%s` specified in configuration", getFileKey()))); - } - - private CredentialsAuthMode createCredentialsAuthMode(ConfigMap configMap) { - return configMap.getPassword(getCredentialsKey()).map(CredentialsAuthMode::new).orElseThrow(() -> new ConfigException(String.format("No `%s` specified in configuration", getCredentialsKey()))); - } -} \ No newline at end of file + private CredentialsAuthMode createCredentialsAuthMode(ConfigSource configAdaptor) { + return configAdaptor + .getPassword(getCredentialsKey()) + .filter(password -> !(password.value().isEmpty())) + .map(CredentialsAuthMode::new) + .orElseThrow( + () -> + new ConfigException( + String.format("No `%s` specified in configuration", getCredentialsKey()))); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java new file mode 100644 index 000000000..747e85987 --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java @@ -0,0 +1,151 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.config; + +import io.lenses.streamreactor.common.config.base.ConfigSettings; +import io.lenses.streamreactor.common.config.base.RetryConfig; +import io.lenses.streamreactor.common.config.base.model.ConnectorPrefix; +import io.lenses.streamreactor.common.config.source.ConfigSource; +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig; +import io.lenses.streamreactor.connect.gcp.common.auth.HttpTimeoutConfig; +import lombok.Getter; +import lombok.val; +import org.apache.kafka.common.config.ConfigDef; + +/** + * Configuration settings for connecting to Google Cloud Platform (GCP) services. + * This class provides methods for defining and parsing GCP-specific configuration properties. + */ +@Getter +public class GCPSettings implements ConfigSettings { + + public static final String EMPTY_STRING = ""; + + private final String gcpProjectId; + private final String gcpQuotaProjectId; + private final String host; + private final String httpErrorRetryInterval; + private final String httpNbrOfRetries; + private final String httpSocketTimeout; + private final String httpConnectionTimeout; + + public static final Long HTTP_ERROR_RETRY_INTERVAL_DEFAULT = 50L; + public static final Integer HTTP_NUMBER_OF_RETIRES_DEFAULT = 5; + public static final Long HTTP_SOCKET_TIMEOUT_DEFAULT = 60000L; + public static final Long HTTP_CONNECTION_TIMEOUT_DEFAULT = 60000L; + + private final AuthModeSettings authModeSettings; + + /** + * Constructs a new instance of {@code GCPSettings} with the specified connector prefix. + * + * @param connectorPrefix the prefix used for configuration keys + */ + public GCPSettings(ConnectorPrefix connectorPrefix) { + gcpProjectId = connectorPrefix.prefixKey("gcp.project.id"); + gcpQuotaProjectId = connectorPrefix.prefixKey("gcp.quota.project.id"); + host = connectorPrefix.prefixKey("endpoint"); + httpErrorRetryInterval = connectorPrefix.prefixKey("http.retry.interval"); + httpNbrOfRetries = connectorPrefix.prefixKey("http.max.retries"); + httpSocketTimeout = connectorPrefix.prefixKey("http.socket.timeout"); + httpConnectionTimeout = connectorPrefix.prefixKey("http.connection.timeout"); + + authModeSettings = new AuthModeSettings(connectorPrefix); + } + + /** + * Configures the provided {@link ConfigDef} with GCP-specific settings. + * + * @param configDef the base configuration definition to extend + * @return the updated {@link ConfigDef} with GCP-specific settings + */ + @Override + public ConfigDef withSettings(ConfigDef configDef) { + val conf = + configDef + .define( + gcpProjectId, + ConfigDef.Type.STRING, + EMPTY_STRING, + ConfigDef.Importance.HIGH, + "GCP Project ID") + .define( + gcpQuotaProjectId, + ConfigDef.Type.STRING, + EMPTY_STRING, + ConfigDef.Importance.HIGH, + "GCP Quota Project ID") + .define(host, ConfigDef.Type.STRING, EMPTY_STRING, ConfigDef.Importance.LOW, "GCP Host") + .define( + httpNbrOfRetries, + ConfigDef.Type.INT, + HTTP_NUMBER_OF_RETIRES_DEFAULT, + ConfigDef.Importance.MEDIUM, + "Number of times to retry the http request, in the case of a resolvable error on" + + " the server side.", + "Error", + 2, + ConfigDef.Width.LONG, + httpNbrOfRetries) + .define( + httpErrorRetryInterval, + ConfigDef.Type.LONG, + HTTP_ERROR_RETRY_INTERVAL_DEFAULT, + ConfigDef.Importance.MEDIUM, + "If greater than zero, used to determine the delay after which to retry the http" + + " request in milliseconds. Based on an exponential backoff algorithm.", + "Error", + 3, + ConfigDef.Width.LONG, + httpErrorRetryInterval) + .define( + httpSocketTimeout, + ConfigDef.Type.LONG, + HTTP_SOCKET_TIMEOUT_DEFAULT, + ConfigDef.Importance.LOW, + "Socket timeout (ms)") + .define( + httpConnectionTimeout, + ConfigDef.Type.LONG, + HTTP_CONNECTION_TIMEOUT_DEFAULT, + ConfigDef.Importance.LOW, + "Connection timeout (ms)"); + + return authModeSettings.withSettings(conf); + } + + public GCPConnectionConfig parseFromConfig(ConfigSource configSource) { + val builder = + GCPConnectionConfig.builder().authMode(authModeSettings.parseFromConfig(configSource)); + configSource.getString(gcpProjectId).ifPresent(builder::projectId); + configSource.getString(gcpQuotaProjectId).ifPresent(builder::quotaProjectId); + configSource.getString(host).ifPresent(builder::host); + + val retryConfig = + new RetryConfig( + configSource.getInt(httpNbrOfRetries).orElse(HTTP_NUMBER_OF_RETIRES_DEFAULT), + configSource.getLong(httpErrorRetryInterval).orElse(HTTP_ERROR_RETRY_INTERVAL_DEFAULT)); + + val timeoutConfig = + new HttpTimeoutConfig( + configSource.getLong(httpSocketTimeout).orElse(null), + configSource.getLong(httpConnectionTimeout).orElse(null)); + + builder.httpRetryConfig(retryConfig); + builder.timeouts(timeoutConfig); + return builder.build(); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java index 79c869479..e609af465 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java @@ -15,6 +15,9 @@ */ package io.lenses.streamreactor.connect.gcp.common.auth; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; import com.google.api.gax.retrying.RetrySettings; import com.google.cloud.NoCredentials; @@ -22,116 +25,122 @@ import com.google.cloud.http.HttpTransportOptions; import io.lenses.streamreactor.common.config.base.RetryConfig; import io.lenses.streamreactor.connect.gcp.common.auth.mode.NoAuthMode; +import java.io.IOException; import lombok.val; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; - -import java.io.IOException; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.*; +import org.threeten.bp.Duration; class GCPServiceBuilderConfigurerTest { - private GCPConnectionConfig.GCPConnectionConfigBuilder configBuilder; - - @BeforeEach - public void setUp() { - configBuilder = GCPConnectionConfig.builder() - .authMode(new NoAuthMode()); - } - - @Test - void testConfigure_withHostAndProjectIdConfigured() throws IOException { - val config = configBuilder - .host("example.com") - .projectId("test-project") - .build(); - - val builder = createMockBuilder(); - - GCPServiceBuilderConfigurer.configure(config, builder); - - assertHostAndProjectIdConfigured(builder, "example.com", "test-project"); - } - - @Test - void testConfigure_withRetrySettingsConfigured() throws IOException { - RetryConfig retryConfig = RetryConfig.builder().retryIntervalMillis(1000).retryLimit(3).build(); - - val config = configBuilder.httpRetryConfig(retryConfig) - .build(); - - val builder = createMockBuilder(); - - GCPServiceBuilderConfigurer.configure(config, builder); - - assertRetrySettingsConfigured(builder, 1000, 5000, 3); - } - - @Test - void testConfigure_withTransportOptionsConfigured() throws IOException { - val timeoutConfig = HttpTimeoutConfig.builder() - .socketTimeoutMillis(5000L) - .connectionTimeoutMillis(3000L) - .build(); - - val config = configBuilder.timeouts(timeoutConfig).build(); - - val builder = createMockBuilder(); - - GCPServiceBuilderConfigurer.configure(config, builder); - - assertTransportOptionsConfigured(builder, 5000, 3000); - } - - @Test - void testConfigure_withEmptyConfig() throws IOException { - val builder = createMockBuilder(); - - val config = configBuilder.build(); - - GCPServiceBuilderConfigurer.configure(config, builder); - - // Ensure that no properties are set if configuration is empty - verify(builder, never()).setHost(anyString()); - verify(builder, never()).setProjectId(anyString()); - verify(builder, times(1)).setRetrySettings(RetrySettings.newBuilder().build()); - verify(builder, times(1)).setCredentials(NoCredentials.getInstance()); - verify(builder, never()).setTransportOptions(any()); - } - - private TestSvcServiceOptionsBuilder createMockBuilder() { - return mock(TestSvcServiceOptionsBuilder.class); - } - - private void assertHostAndProjectIdConfigured(ServiceOptions.Builder builder, String expectedHost, String expectedProjectId) { - verify(builder, times(1)).setHost(expectedHost); - verify(builder, times(1)).setProjectId(expectedProjectId); - } - - private void assertRetrySettingsConfigured(ServiceOptions.Builder builder, long expectedInitialRetryDelay, long expectedMaxRetryDelay, int expectedMaxAttempts) { - ArgumentCaptor retrySettingsCaptor = ArgumentCaptor.forClass(RetrySettings.class); - verify(builder).setRetrySettings(retrySettingsCaptor.capture()); - - RetrySettings capturedRetrySettings = retrySettingsCaptor.getValue(); - assertNotNull(capturedRetrySettings); - assertEquals(1000, capturedRetrySettings.getInitialRetryDelay().toMillis()); - assertEquals(5000, capturedRetrySettings.getMaxRetryDelay().toMillis()); - assertEquals(3, capturedRetrySettings.getMaxAttempts()); - } - - private void assertTransportOptionsConfigured(ServiceOptions.Builder builder, int expectedReadTimeout, int expectedConnectTimeout) { - ArgumentCaptor transportOptionsCaptor = ArgumentCaptor.forClass(HttpTransportOptions.class); - verify(builder).setTransportOptions(transportOptionsCaptor.capture()); - - HttpTransportOptions capturedTransportOptions = transportOptionsCaptor.getValue(); - assertNotNull(capturedTransportOptions); - assertEquals(5000, capturedTransportOptions.getReadTimeout()); - assertEquals(3000, capturedTransportOptions.getConnectTimeout()); - } + private GCPConnectionConfig.GCPConnectionConfigBuilder configBuilder; + @BeforeEach + public void setUp() { + configBuilder = GCPConnectionConfig.builder().authMode(new NoAuthMode()); + } + + @Test + void testConfigure_withHostAndProjectIdConfigured() throws IOException { + val config = configBuilder.host("example.com").projectId("test-project").build(); + + val builder = createMockBuilder(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + assertHostAndProjectIdConfigured(builder, "example.com", "test-project"); + } + + @Test + void testConfigure_withRetrySettingsConfigured() throws IOException { + RetryConfig retryConfig = RetryConfig.builder().retryIntervalMillis(1000).retryLimit(3).build(); + + val config = configBuilder.httpRetryConfig(retryConfig).build(); + + val builder = createMockBuilder(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + assertRetrySettingsConfigured(builder, 1000, 5000, 3); + } + + @Test + void testConfigure_withTransportOptionsConfigured() throws IOException { + val timeoutConfig = + HttpTimeoutConfig.builder() + .socketTimeoutMillis(5000L) + .connectionTimeoutMillis(3000L) + .build(); + + val config = configBuilder.timeouts(timeoutConfig).build(); + + val builder = createMockBuilder(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + assertTransportOptionsConfigured(builder, 5000, 3000); + } + + @Test + void testConfigure_withEmptyConfig() throws IOException { + val builder = createMockBuilder(); + + val config = configBuilder.build(); + + GCPServiceBuilderConfigurer.configure(config, builder); + + // Ensure that no properties are set if configuration is empty + verify(builder, never()).setHost(anyString()); + verify(builder, never()).setProjectId(anyString()); + verify(builder, times(1)) + .setRetrySettings( + RetrySettings.newBuilder() + .setInitialRetryDelay(Duration.ofMillis(50)) + .setMaxRetryDelay(Duration.ofMillis(250)) + .setMaxAttempts(5) + .build()); + verify(builder, times(1)).setCredentials(NoCredentials.getInstance()); + verify(builder, never()).setTransportOptions(any()); + } + + private TestSvcServiceOptionsBuilder createMockBuilder() { + return mock(TestSvcServiceOptionsBuilder.class); + } + + private void assertHostAndProjectIdConfigured( + ServiceOptions.Builder builder, String expectedHost, String expectedProjectId) { + verify(builder, times(1)).setHost(expectedHost); + verify(builder, times(1)).setProjectId(expectedProjectId); + } + + private void assertRetrySettingsConfigured( + ServiceOptions.Builder builder, + long expectedInitialRetryDelay, + long expectedMaxRetryDelay, + int expectedMaxAttempts) { + ArgumentCaptor retrySettingsCaptor = + ArgumentCaptor.forClass(RetrySettings.class); + verify(builder).setRetrySettings(retrySettingsCaptor.capture()); + + RetrySettings capturedRetrySettings = retrySettingsCaptor.getValue(); + assertNotNull(capturedRetrySettings); + assertEquals(1000, capturedRetrySettings.getInitialRetryDelay().toMillis()); + assertEquals(5000, capturedRetrySettings.getMaxRetryDelay().toMillis()); + assertEquals(3, capturedRetrySettings.getMaxAttempts()); + } + + private void assertTransportOptionsConfigured( + ServiceOptions.Builder builder, + int expectedReadTimeout, + int expectedConnectTimeout) { + ArgumentCaptor transportOptionsCaptor = + ArgumentCaptor.forClass(HttpTransportOptions.class); + verify(builder).setTransportOptions(transportOptionsCaptor.capture()); + + HttpTransportOptions capturedTransportOptions = transportOptionsCaptor.getValue(); + assertNotNull(capturedTransportOptions); + assertEquals(5000, capturedTransportOptions.getReadTimeout()); + assertEquals(3000, capturedTransportOptions.getConnectTimeout()); + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java index 9e1d814e2..3fe99c709 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java @@ -15,42 +15,46 @@ */ package io.lenses.streamreactor.connect.gcp.common.auth; - import com.google.cloud.BaseService; import com.google.cloud.ServiceDefaults; import com.google.cloud.ServiceFactory; import com.google.cloud.ServiceOptions; import com.google.cloud.spi.ServiceRpcFactory; - import java.util.Set; class TestService extends BaseService { - protected TestService(TestSvcServiceOptions options) { - super(options); - } + protected TestService(TestSvcServiceOptions options) { + super(options); + } } class TestSvcServiceOptions extends ServiceOptions { - protected TestSvcServiceOptions(Class> serviceFactoryClass, Class> rpcFactoryClass, Builder builder, ServiceDefaults serviceDefaults) { - super(serviceFactoryClass, rpcFactoryClass, builder, serviceDefaults); - } - - @Override - protected Set getScopes() { - return null; - } - - @Override - public > B toBuilder() { - return null; - } + protected TestSvcServiceOptions( + Class> serviceFactoryClass, + Class> rpcFactoryClass, + Builder builder, + ServiceDefaults serviceDefaults) { + super(serviceFactoryClass, rpcFactoryClass, builder, serviceDefaults); + } + + @Override + protected Set getScopes() { + return null; + } + + @Override + public > B toBuilder() { + return null; + } } -class TestSvcServiceOptionsBuilder extends ServiceOptions.Builder { +class TestSvcServiceOptionsBuilder + extends ServiceOptions.Builder< + TestService, TestSvcServiceOptions, TestSvcServiceOptionsBuilder> { - @Override - protected ServiceOptions build() { - return null; - } + @Override + protected ServiceOptions build() { + return null; + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java index edd5c636f..53595a389 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java @@ -15,23 +15,22 @@ */ package io.lenses.streamreactor.connect.gcp.common.auth.mode; +import static io.lenses.streamreactor.connect.gcp.common.auth.mode.TestFileUtil.resourceAsString; +import static org.junit.jupiter.api.Assertions.assertEquals; + import com.google.auth.oauth2.ServiceAccountCredentials; import lombok.val; import org.apache.kafka.common.config.types.Password; import org.junit.jupiter.api.Test; -import static io.lenses.streamreactor.connect.gcp.common.auth.mode.TestFileUtil.resourceAsString; -import static org.junit.jupiter.api.Assertions.assertEquals; - class CredentialsAuthModeTest { - @Test - void getCredentialsShouldReturnCredentials() throws Exception { - val password = new Password(resourceAsString("/test-gcp-credentials.json")); - val authMode = new CredentialsAuthMode(password); - - val googleCredentials = (ServiceAccountCredentials) authMode.getCredentials(); - assertEquals("your-client-id", googleCredentials.getClientId()); - } + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val password = new Password(resourceAsString("/test-gcp-credentials.json")); + val authMode = new CredentialsAuthMode(password); -} \ No newline at end of file + val googleCredentials = (ServiceAccountCredentials) authMode.getCredentials(); + assertEquals("your-client-id", googleCredentials.getClientId()); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java index fd110041b..cb30b697e 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java @@ -15,27 +15,27 @@ */ package io.lenses.streamreactor.connect.gcp.common.auth.mode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + import com.google.auth.oauth2.GoogleCredentials; import lombok.val; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.*; - class DefaultAuthModeTest { - @Test - void getCredentialsShouldReturnCredentials() throws Exception { - val mockCreds = mock(GoogleCredentials.class); + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val mockCreds = mock(GoogleCredentials.class); - try (MockedStatic mockedStatic = mockStatic(GoogleCredentials.class)) { - when(GoogleCredentials.getApplicationDefault()).thenReturn(mockCreds); + try (MockedStatic mockedStatic = mockStatic(GoogleCredentials.class)) { + when(GoogleCredentials.getApplicationDefault()).thenReturn(mockCreds); - val credentials = new DefaultAuthMode().getCredentials(); - assertEquals(mockCreds, credentials); + val credentials = new DefaultAuthMode().getCredentials(); + assertEquals(mockCreds, credentials); - mockedStatic.verify(GoogleCredentials::getApplicationDefault); - } + mockedStatic.verify(GoogleCredentials::getApplicationDefault); } -} \ No newline at end of file + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java index ed5b7d3dd..2ddcb5225 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java @@ -15,22 +15,21 @@ */ package io.lenses.streamreactor.connect.gcp.common.auth.mode; +import static io.lenses.streamreactor.connect.gcp.common.auth.mode.TestFileUtil.absolutePathForResource; +import static org.junit.jupiter.api.Assertions.assertEquals; + import com.google.auth.oauth2.ServiceAccountCredentials; import lombok.val; import org.junit.jupiter.api.Test; -import static io.lenses.streamreactor.connect.gcp.common.auth.mode.TestFileUtil.absolutePathForResource; -import static org.junit.jupiter.api.Assertions.assertEquals; - class FileAuthModeTest { - @Test - void getCredentialsShouldReturnCredentials() throws Exception { - val filePath = absolutePathForResource("/test-gcp-credentials.json"); - val authMode = new FileAuthMode(filePath); - - val googleCredentials = (ServiceAccountCredentials) authMode.getCredentials(); - assertEquals("your-client-id", googleCredentials.getClientId()); - } + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val filePath = absolutePathForResource("/test-gcp-credentials.json"); + val authMode = new FileAuthMode(filePath); -} \ No newline at end of file + val googleCredentials = (ServiceAccountCredentials) authMode.getCredentials(); + assertEquals("your-client-id", googleCredentials.getClientId()); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java index f8708536b..cb112d939 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java @@ -15,19 +15,18 @@ */ package io.lenses.streamreactor.connect.gcp.common.auth.mode; +import static org.junit.jupiter.api.Assertions.assertEquals; + import com.google.cloud.NoCredentials; import lombok.val; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; - class NoAuthModeTest { - @Test - void getCredentialsShouldReturnCredentials() throws Exception { - val authMode = new NoAuthMode(); - - assertEquals(NoCredentials.getInstance(), authMode.getCredentials()); - } + @Test + void getCredentialsShouldReturnCredentials() throws Exception { + val authMode = new NoAuthMode(); -} \ No newline at end of file + assertEquals(NoCredentials.getInstance(), authMode.getCredentials()); + } +} diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java index ddbd1fca2..957fe150b 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java @@ -15,30 +15,30 @@ */ package io.lenses.streamreactor.connect.gcp.common.auth.mode; -import com.google.common.io.ByteStreams; -import lombok.experimental.UtilityClass; +import static com.google.common.base.Preconditions.checkNotNull; +import com.google.common.io.ByteStreams; import java.io.File; import java.io.InputStream; import java.net.URL; import java.nio.charset.StandardCharsets; - -import static com.google.common.base.Preconditions.checkNotNull; +import lombok.experimental.UtilityClass; @UtilityClass public class TestFileUtil { - static String streamToString(InputStream inputStream) throws Exception { - byte[] bytes = ByteStreams.toByteArray(inputStream); - return new String(bytes, StandardCharsets.UTF_8); - } + static String streamToString(InputStream inputStream) throws Exception { + byte[] bytes = ByteStreams.toByteArray(inputStream); + return new String(bytes, StandardCharsets.UTF_8); + } + + static String resourceAsString(String resourceFile) throws Exception { + return streamToString(TestFileUtil.class.getResourceAsStream(resourceFile)); + } - static String resourceAsString(String resourceFile) throws Exception { - return streamToString(TestFileUtil.class.getResourceAsStream(resourceFile)); - } - static String absolutePathForResource(String resourceName) { - URL resourceUrl = TestFileUtil.class.getResource(resourceName); - checkNotNull(resourceUrl, String.format("Resource not found: %s", resourceName)); - return new File(resourceUrl.getFile()).getAbsolutePath(); - } + static String absolutePathForResource(String resourceName) { + URL resourceUrl = TestFileUtil.class.getResource(resourceName); + checkNotNull(resourceUrl, String.format("Resource not found: %s", resourceName)); + return new File(resourceUrl.getFile()).getAbsolutePath(); + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java index 93a882465..22de40a65 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java @@ -15,12 +15,15 @@ */ package io.lenses.streamreactor.connect.gcp.common.config; -import io.lenses.streamreactor.common.config.base.ConfigMap; +import static org.junit.jupiter.api.Assertions.*; + import io.lenses.streamreactor.common.config.base.model.ConnectorPrefix; +import io.lenses.streamreactor.common.config.source.MapConfigSource; import io.lenses.streamreactor.connect.gcp.common.auth.mode.CredentialsAuthMode; import io.lenses.streamreactor.connect.gcp.common.auth.mode.DefaultAuthMode; import io.lenses.streamreactor.connect.gcp.common.auth.mode.FileAuthMode; import io.lenses.streamreactor.connect.gcp.common.auth.mode.NoAuthMode; +import java.util.Map; import lombok.val; import org.apache.kafka.common.config.ConfigDef; import org.apache.kafka.common.config.ConfigException; @@ -28,107 +31,90 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - class AuthModeSettingsTest { - private AuthModeSettings authModeSettings; - private final String CONNECTOR_PREFIX = "test.connector"; - - @BeforeEach - public void setUp() { - val connectorPrefix = new ConnectorPrefix(CONNECTOR_PREFIX); - authModeSettings = new AuthModeSettings(connectorPrefix); - } - - @Test - void testGenerateKey() { - assertEquals("test.connector.gcp.auth.mode", authModeSettings.getAuthModeKey()); - assertEquals("test.connector.gcp.credentials", authModeSettings.getCredentialsKey()); - assertEquals("test.connector.gcp.file", authModeSettings.getFileKey()); - } - - @Test - void testWithAuthModeSettings() { - val configDef = new ConfigDef(); - val result = authModeSettings.withAuthModeSettings(configDef); - - assertNotNull(result); - assertTrue(result.configKeys().containsKey(authModeSettings.getAuthModeKey())); - assertTrue(result.configKeys().containsKey(authModeSettings.getCredentialsKey())); - assertTrue(result.configKeys().containsKey(authModeSettings.getFileKey())); - } - - @Test - void testParseFromConfig_CredentialsAuthMode() { - val configMap = new ConfigMap( - Map.of( - authModeSettings.getAuthModeKey(), "credentials", - authModeSettings.getCredentialsKey(), new Password("password") - ) - ); - - val authMode = authModeSettings.parseFromConfig(configMap); - - assertNotNull(authMode); - assertTrue(authMode instanceof CredentialsAuthMode); - } - - @Test - void testParseFromConfig_FileAuthMode() { - - val configMap = new ConfigMap( - Map.of( - authModeSettings.getAuthModeKey(), "file", - authModeSettings.getFileKey(), "\"path/to/file\"" - ) - ); - val authMode = authModeSettings.parseFromConfig(configMap); - - assertNotNull(authMode); - assertTrue(authMode instanceof FileAuthMode); - } - - @Test - void testParseFromConfig_NoneAuthMode() { - - val configMap = new ConfigMap( - Map.of( - authModeSettings.getAuthModeKey(), "none" - ) - ); - val authMode = authModeSettings.parseFromConfig(configMap); - - assertNotNull(authMode); - assertTrue(authMode instanceof NoAuthMode); - } - - @Test - void testParseFromConfig_DefaultAuthMode() { - - val configMap = new ConfigMap( - Map.of( - authModeSettings.getAuthModeKey(), "default" - ) - ); - - val authMode = authModeSettings.parseFromConfig(configMap); - - assertNotNull(authMode); - assertTrue(authMode instanceof DefaultAuthMode); - } - - @Test - void testParseFromConfig_UnsupportedAuthMode() { - - val configMap = new ConfigMap( - Map.of( - authModeSettings.getAuthModeKey(), "invalid" - ) - ); - - assertThrows(ConfigException.class, () -> authModeSettings.parseFromConfig(configMap)); - } + private AuthModeSettings authModeSettings; + private final String CONNECTOR_PREFIX = "test.connector"; + + @BeforeEach + public void setUp() { + val connectorPrefix = new ConnectorPrefix(CONNECTOR_PREFIX); + authModeSettings = new AuthModeSettings(connectorPrefix); + } + + @Test + void testGenerateKey() { + assertEquals("test.connector.gcp.auth.mode", authModeSettings.getAuthModeKey()); + assertEquals("test.connector.gcp.credentials", authModeSettings.getCredentialsKey()); + assertEquals("test.connector.gcp.file", authModeSettings.getFileKey()); + } + + @Test + void testWithAuthModeSettings() { + val configDef = new ConfigDef(); + val result = authModeSettings.withSettings(configDef); + + assertNotNull(result); + assertTrue(result.configKeys().containsKey(authModeSettings.getAuthModeKey())); + assertTrue(result.configKeys().containsKey(authModeSettings.getCredentialsKey())); + assertTrue(result.configKeys().containsKey(authModeSettings.getFileKey())); + } + + @Test + void testParseFromConfig_CredentialsAuthMode() { + val configMap = + new MapConfigSource( + Map.of( + authModeSettings.getAuthModeKey(), + "credentials", + authModeSettings.getCredentialsKey(), + new Password("password"))); + + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof CredentialsAuthMode); + } + + @Test + void testParseFromConfig_FileAuthMode() { + val configMap = + new MapConfigSource( + Map.of( + authModeSettings.getAuthModeKey(), "file", + authModeSettings.getFileKey(), "\"path/to/file\"")); + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof FileAuthMode); + } + + @Test + void testParseFromConfig_NoneAuthMode() { + + val configMap = new MapConfigSource(Map.of(authModeSettings.getAuthModeKey(), "none")); + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof NoAuthMode); + } + + @Test + void testParseFromConfig_DefaultAuthMode() { + + val configMap = new MapConfigSource(Map.of(authModeSettings.getAuthModeKey(), "default")); + + val authMode = authModeSettings.parseFromConfig(configMap); + + assertNotNull(authMode); + assertTrue(authMode instanceof DefaultAuthMode); + } + + @Test + void testParseFromConfig_UnsupportedAuthMode() { + + val configMap = new MapConfigSource(Map.of(authModeSettings.getAuthModeKey(), "invalid")); + + assertThrows(ConfigException.class, () -> authModeSettings.parseFromConfig(configMap)); + } } diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java new file mode 100644 index 000000000..c713e7d5b --- /dev/null +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2017-2024 Lenses.io Ltd + * + * 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. + */ +package io.lenses.streamreactor.connect.gcp.common.config; + +import io.lenses.streamreactor.common.config.base.RetryConfig; +import io.lenses.streamreactor.common.config.base.model.ConnectorPrefix; +import io.lenses.streamreactor.common.config.source.MapConfigSource; +import lombok.val; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class GCPSettingsTest { + + private static final ConnectorPrefix connectorPrefix = new ConnectorPrefix("connect.gcpstorage"); + private static final GCPSettings gcpSettings = new GCPSettings(connectorPrefix); + + private static Stream provideRetryConfigData() { + return Stream.of( + Arguments.of("no values", 0, 0L, new RetryConfig(0, 0L)), + Arguments.of("retry limit only", 10, 0L, new RetryConfig(10, 0L)), + Arguments.of("interval only", 0, 20L, new RetryConfig(0, 20L)), + Arguments.of("retry limit and interval", 30, 40L, new RetryConfig(30, 40L))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideRetryConfigData") + void testHttpRetryConfig(String testName, Object retries, Object interval, RetryConfig expected) { + val configMap = + new MapConfigSource( + Map.of( + "connect.gcpstorage.http.max.retries", retries, + "connect.gcpstorage.http.retry.interval", interval)); + + val optionalRetryConfig = gcpSettings.parseFromConfig(configMap).getHttpRetryConfig(); + assertThat(optionalRetryConfig) + .isPresent() + .contains(expected); + } +} diff --git a/java-connectors/settings.gradle b/java-connectors/settings.gradle index 842b4f643..51c2e87e6 100644 --- a/java-connectors/settings.gradle +++ b/java-connectors/settings.gradle @@ -1,5 +1,5 @@ rootProject.name = 'java-reactor' include 'kafka-connect-common', 'kafka-connect-azure-eventhubs', -'kafka-connect-query-language' - +'kafka-connect-query-language', +'kafka-connect-gcp-common' diff --git a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala index 61f4a378b..3451aa2eb 100644 --- a/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala +++ b/kafka-connect-gcp-storage/src/it/scala/io/lenses/streamreactor/connect/gcp/storage/utils/GCPProxyContainerTest.scala @@ -10,6 +10,7 @@ import io.lenses.streamreactor.connect.cloud.common.sink.CloudPlatformEmulatorSu import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig import io.lenses.streamreactor.connect.gcp.common.auth.mode.NoAuthMode import io.lenses.streamreactor.connect.gcp.common.config.AuthModeSettings +import io.lenses.streamreactor.connect.gcp.common.config.GCPSettings import io.lenses.streamreactor.connect.gcp.storage.auth.GCPStorageClientCreator import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings._ import io.lenses.streamreactor.connect.gcp.storage.config.UploadConfigKeys @@ -38,6 +39,7 @@ trait GCPProxyContainerTest with UploadConfigKeys with LazyLogging { + private val gcpSettings = new GCPSettings(javaConnectorPrefix) private val authModeConfig = new AuthModeSettings(javaConnectorPrefix) implicit val connectorTaskId: ConnectorTaskId = ConnectorTaskId("unit-tests", 1, 1) @@ -64,9 +66,9 @@ trait GCPProxyContainerTest lazy val defaultProps: Map[String, String] = Map( - GCP_PROJECT_ID -> "projectId", + gcpSettings.getGcpProjectId -> "projectId", authModeConfig.getAuthModeKey -> "none", - HOST -> container.getEndpointUrl(), + gcpSettings.getHost -> container.getEndpointUrl(), "name" -> "gcpSinkTaskTest", TASK_INDEX -> "1:1", AVOID_RESUMABLE_UPLOAD -> "true", diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala index fae0e8196..a49ead43d 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDef.scala @@ -16,63 +16,20 @@ package io.lenses.streamreactor.connect.gcp.storage.config import io.lenses.streamreactor.connect.cloud.common.config.CompressionCodecConfigKeys -import GCPConfigSettings.ERROR_POLICY -import GCPConfigSettings.ERROR_POLICY_DEFAULT -import GCPConfigSettings.ERROR_POLICY_DOC -import GCPConfigSettings.ERROR_RETRY_INTERVAL -import GCPConfigSettings.ERROR_RETRY_INTERVAL_DEFAULT -import GCPConfigSettings.ERROR_RETRY_INTERVAL_DOC -import GCPConfigSettings.GCP_PROJECT_ID -import GCPConfigSettings.GCP_QUOTA_PROJECT_ID -import GCPConfigSettings.HOST -import GCPConfigSettings.HTTP_CONNECTION_TIMEOUT -import GCPConfigSettings.HTTP_CONNECTION_TIMEOUT_DEFAULT -import GCPConfigSettings.HTTP_CONNECTION_TIMEOUT_DOC -import GCPConfigSettings.HTTP_NBR_OF_RETIRES_DEFAULT -import GCPConfigSettings.HTTP_NBR_OF_RETRIES -import GCPConfigSettings.HTTP_NBR_OF_RETRIES_DOC -import GCPConfigSettings.HTTP_SOCKET_TIMEOUT -import GCPConfigSettings.HTTP_SOCKET_TIMEOUT_DEFAULT -import GCPConfigSettings.HTTP_SOCKET_TIMEOUT_DOC -import GCPConfigSettings.KCQL_CONFIG -import GCPConfigSettings.KCQL_DOC -import GCPConfigSettings.NBR_OF_RETIRES_DEFAULT -import GCPConfigSettings.NBR_OF_RETRIES -import GCPConfigSettings.NBR_OF_RETRIES_DOC -import io.lenses.streamreactor.connect.gcp.common.config.AuthModeSettings +import io.lenses.streamreactor.connect.gcp.common.config.GCPSettings +import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings._ import org.apache.kafka.common.config.ConfigDef import org.apache.kafka.common.config.ConfigDef.Importance import org.apache.kafka.common.config.ConfigDef.Type trait CommonConfigDef extends CompressionCodecConfigKeys { - private val authModeSettingsConfigKeys = new AuthModeSettings(javaConnectorPrefix) + private val authModeSettingsConfigKeys: GCPSettings = new GCPSettings(javaConnectorPrefix) import authModeSettingsConfigKeys._ def config: ConfigDef = { val conf = new ConfigDef() - .define( - GCP_PROJECT_ID, - Type.STRING, - "", - Importance.HIGH, - "GCP Project ID", - ) - .define( - GCP_QUOTA_PROJECT_ID, - Type.STRING, - "", - Importance.HIGH, - "GCP Quota Project ID", - ) - .define( - HOST, - Type.STRING, - "", - Importance.LOW, - "GCP Host", - ) .define( KCQL_CONFIG, Type.STRING, @@ -112,31 +69,6 @@ trait CommonConfigDef extends CompressionCodecConfigKeys { ConfigDef.Width.LONG, ERROR_RETRY_INTERVAL, ) - .define( - HTTP_NBR_OF_RETRIES, - Type.INT, - HTTP_NBR_OF_RETIRES_DEFAULT, - Importance.MEDIUM, - HTTP_NBR_OF_RETRIES_DOC, - "Error", - 2, - ConfigDef.Width.LONG, - HTTP_NBR_OF_RETRIES, - ) - .define( - HTTP_SOCKET_TIMEOUT, - Type.LONG, - HTTP_SOCKET_TIMEOUT_DEFAULT, - Importance.LOW, - HTTP_SOCKET_TIMEOUT_DOC, - ) - .define( - HTTP_CONNECTION_TIMEOUT, - Type.INT, - HTTP_CONNECTION_TIMEOUT_DEFAULT, - Importance.LOW, - HTTP_CONNECTION_TIMEOUT_DOC, - ) .define( COMPRESSION_CODEC, Type.STRING, @@ -151,6 +83,7 @@ trait CommonConfigDef extends CompressionCodecConfigKeys { Importance.LOW, COMPRESSION_LEVEL_DOC, ) - withAuthModeSettings(conf) + withSettings(conf) + } } diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettings.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettings.scala index 78d43d769..f1c89ad26 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettings.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettings.scala @@ -21,10 +21,6 @@ object GCPConfigSettings { val CONNECTOR_PREFIX = "connect.gcpstorage" - val GCP_PROJECT_ID: String = s"$CONNECTOR_PREFIX.gcp.project.id" - val GCP_QUOTA_PROJECT_ID: String = s"$CONNECTOR_PREFIX.gcp.quota.project.id" - val HOST: String = s"$CONNECTOR_PREFIX.endpoint" - val KCQL_CONFIG = s"$CONNECTOR_PREFIX.$KCQL_PROP_SUFFIX" val KCQL_DOC = "Contains the Kafka Connect Query Language describing the flow from Apache Kafka topics to Apache Hive tables." @@ -49,24 +45,6 @@ object GCPConfigSettings { val NBR_OF_RETRIES_DOC = "The maximum number of times to try the write again." val NBR_OF_RETIRES_DEFAULT: Int = 20 - val HTTP_ERROR_RETRY_INTERVAL = s"$CONNECTOR_PREFIX.http.$RETRY_INTERVAL_PROP_SUFFIX" - val HTTP_ERROR_RETRY_INTERVAL_DOC = - "If greater than zero, used to determine the delay after which to retry the http request in milliseconds. Based on an exponential backoff algorithm." - val HTTP_ERROR_RETRY_INTERVAL_DEFAULT: Long = 50L - - val HTTP_NBR_OF_RETRIES = s"$CONNECTOR_PREFIX.http.$MAX_RETRIES_PROP_SUFFIX" - val HTTP_NBR_OF_RETRIES_DOC = - "Number of times to retry the http request, in the case of a resolvable error on the server side." - val HTTP_NBR_OF_RETIRES_DEFAULT: Int = 5 - - val HTTP_SOCKET_TIMEOUT = s"$CONNECTOR_PREFIX.http.socket.timeout" - val HTTP_SOCKET_TIMEOUT_DOC = "Socket timeout (ms)" - val HTTP_SOCKET_TIMEOUT_DEFAULT = 60000L - - val HTTP_CONNECTION_TIMEOUT = s"$CONNECTOR_PREFIX.http.connection.timeout" - val HTTP_CONNECTION_TIMEOUT_DOC = "Connection timeout (ms)" - val HTTP_CONNECTION_TIMEOUT_DEFAULT = 60000 - val SEEK_MAX_INDEX_FILES = s"$CONNECTOR_PREFIX.seek.max.files" val SEEK_MAX_INDEX_FILES_DOC = s"Maximum index files to allow per topic/partition. Advisable to not raise this: if a large number of files build up this means there is a problem with file deletion." diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala deleted file mode 100644 index 6203820bc..000000000 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConnectionConfigBuilder.scala +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.connect.gcp.storage.config - -import io.lenses.streamreactor.common.config.base.RetryConfig -import io.lenses.streamreactor.connect.cloud.common.config.ConfigParse._ -import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode -import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig -import io.lenses.streamreactor.connect.gcp.common.auth.HttpTimeoutConfig -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings._ - -object GCPConnectionConfigBuilder { - - def apply(props: Map[String, _], authMode: AuthMode): GCPConnectionConfig = new GCPConnectionConfig( - getString(props, GCP_PROJECT_ID).orNull, - getString(props, GCP_QUOTA_PROJECT_ID).orNull, - authMode, - getString(props, HOST).orNull, - new RetryConfig( - getInt(props, HTTP_NBR_OF_RETRIES).getOrElse(HTTP_NBR_OF_RETIRES_DEFAULT), - getLong(props, HTTP_ERROR_RETRY_INTERVAL).getOrElse(HTTP_ERROR_RETRY_INTERVAL_DEFAULT), - ), - new HttpTimeoutConfig( - getLong(props, HTTP_SOCKET_TIMEOUT).map(Long.box).orNull, - getLong(props, HTTP_CONNECTION_TIMEOUT).map(Long.box).orNull, - ), - ) - -} diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/AuthModeSettings.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPSettings.scala similarity index 55% rename from kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/AuthModeSettings.scala rename to kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPSettings.scala index 551a88a9e..166351acb 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/AuthModeSettings.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPSettings.scala @@ -15,21 +15,18 @@ */ package io.lenses.streamreactor.connect.gcp.storage.config -import io.lenses.streamreactor.common.config.base.ConfigMap import io.lenses.streamreactor.common.config.base.traits.BaseSettings -import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode -import io.lenses.streamreactor.connect.gcp.common.config.{ AuthModeSettings => JavaAuthModeSettings } +import io.lenses.streamreactor.common.config.source.ConfigSource +import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig +import io.lenses.streamreactor.connect.gcp.common.config.{ GCPSettings => JavaGCPSettings } -import scala.jdk.CollectionConverters.MapHasAsJava import scala.util.Try -trait AuthModeSettings extends BaseSettings { +trait GCPSettings extends BaseSettings { - private val javaAuthModeSettings = new JavaAuthModeSettings(javaConnectorPrefix) + private val javaGcpSettings = new JavaGCPSettings(javaConnectorPrefix) - def getAuthMode(props: Map[String, AnyRef]): Either[Throwable, AuthMode] = { - val configMap = new ConfigMap(props.asJava) - Try(javaAuthModeSettings.parseFromConfig(configMap)).toEither - } + def getGcpConnectionSettings(config: ConfigSource): Either[Throwable, GCPConnectionConfig] = + Try(javaGcpSettings.parseFromConfig(config)).toEither } diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala index 604f4c800..f9e16bad3 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfig.scala @@ -16,6 +16,7 @@ package io.lenses.streamreactor.connect.gcp.storage.sink.config import io.lenses.streamreactor.common.config.base.RetryConfig +import io.lenses.streamreactor.common.config.source.ConfigWrapperSource import io.lenses.streamreactor.common.errors.ErrorPolicy import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSinkConfig @@ -25,7 +26,6 @@ import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkBucketOptions import io.lenses.streamreactor.connect.cloud.common.sink.config.OffsetSeekerOptions import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfigBuilder import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.SEEK_MAX_INDEX_FILES object GCPStorageSinkConfig extends PropsToConfigConverter[GCPStorageSinkConfig] { @@ -45,15 +45,16 @@ object GCPStorageSinkConfig extends PropsToConfigConverter[GCPStorageSinkConfig] )( implicit cloudLocationValidator: CloudLocationValidator, - ): Either[Throwable, GCPStorageSinkConfig] = + ): Either[Throwable, GCPStorageSinkConfig] = { + val configSource = new ConfigWrapperSource(gcpConfigDefBuilder) for { - authMode <- gcpConfigDefBuilder.getAuthMode(gcpConfigDefBuilder.props) - sinkBucketOptions <- CloudSinkBucketOptions(connectorTaskId, gcpConfigDefBuilder) + gcpConnectionSettings <- gcpConfigDefBuilder.getGcpConnectionSettings(configSource) + sinkBucketOptions <- CloudSinkBucketOptions(connectorTaskId, gcpConfigDefBuilder) offsetSeekerOptions = OffsetSeekerOptions( gcpConfigDefBuilder.getInt(SEEK_MAX_INDEX_FILES), ) } yield GCPStorageSinkConfig( - GCPConnectionConfigBuilder(gcpConfigDefBuilder.getParsedValues, authMode), + gcpConnectionSettings, sinkBucketOptions, offsetSeekerOptions, gcpConfigDefBuilder.getCompressionCodec(), @@ -61,6 +62,7 @@ object GCPStorageSinkConfig extends PropsToConfigConverter[GCPStorageSinkConfig] errorPolicy = gcpConfigDefBuilder.getErrorPolicyOrDefault, connectorRetryConfig = gcpConfigDefBuilder.getRetryConfig, ) + } } diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala index cd4eeeac9..5304d8827 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/sink/config/GCPStorageSinkConfigDefBuilder.scala @@ -17,7 +17,7 @@ package io.lenses.streamreactor.connect.gcp.storage.sink.config import io.lenses.streamreactor.common.config.base.traits._ import io.lenses.streamreactor.connect.cloud.common.sink.config.CloudSinkConfigDefBuilder -import io.lenses.streamreactor.connect.gcp.storage.config.AuthModeSettings +import io.lenses.streamreactor.connect.gcp.storage.config.GCPSettings import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings import io.lenses.streamreactor.connect.gcp.storage.config.UploadSettings @@ -28,7 +28,7 @@ case class GCPStorageSinkConfigDefBuilder(props: Map[String, AnyRef]) with CloudSinkConfigDefBuilder with ErrorPolicySettings with RetryConfigSettings - with AuthModeSettings + with GCPSettings with UploadSettings { def getParsedValues: Map[String, _] = values().asScala.toMap diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala index 2a5883ed5..45cbca230 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfig.scala @@ -15,6 +15,7 @@ */ package io.lenses.streamreactor.connect.gcp.storage.source.config +import io.lenses.streamreactor.common.config.source.ConfigWrapperSource import io.lenses.streamreactor.connect.cloud.common.config.ConnectorTaskId import io.lenses.streamreactor.connect.cloud.common.config.traits.CloudSourceConfig import io.lenses.streamreactor.connect.cloud.common.config.traits.PropsToConfigConverter @@ -23,7 +24,6 @@ import io.lenses.streamreactor.connect.cloud.common.model.location.CloudLocation import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceBucketOptions import io.lenses.streamreactor.connect.cloud.common.source.config.PartitionSearcherOptions import io.lenses.streamreactor.connect.gcp.common.auth.GCPConnectionConfig -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConnectionConfigBuilder import io.lenses.streamreactor.connect.gcp.storage.model.location.GCPStorageLocationValidator import io.lenses.streamreactor.connect.gcp.storage.storage.GCPStorageFileMetadata @@ -43,15 +43,16 @@ object GCPStorageSourceConfig extends PropsToConfigConverter[GCPStorageSourceCon Try(GCPStorageSourceConfig(GCPStorageSourceConfigDefBuilder(props))).toEither.flatten def apply(gcpConfigDefBuilder: GCPStorageSourceConfigDefBuilder): Either[Throwable, GCPStorageSourceConfig] = { - val parsedValues = gcpConfigDefBuilder.getParsedValues + val parsedValues = gcpConfigDefBuilder.getParsedValues + val configMapVersion = new ConfigWrapperSource(gcpConfigDefBuilder) for { - authMode <- gcpConfigDefBuilder.getAuthMode(gcpConfigDefBuilder.props) + gcpConnectionSettings <- gcpConfigDefBuilder.getGcpConnectionSettings(configMapVersion) sbo <- CloudSourceBucketOptions[GCPStorageFileMetadata]( gcpConfigDefBuilder, gcpConfigDefBuilder.getPartitionExtractor(parsedValues), ) } yield GCPStorageSourceConfig( - GCPConnectionConfigBuilder(parsedValues, authMode), + gcpConnectionSettings, sbo, gcpConfigDefBuilder.getCompressionCodec(), gcpConfigDefBuilder.getPartitionSearcherOptions(parsedValues), diff --git a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala index bef4e2453..46ab49316 100644 --- a/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala +++ b/kafka-connect-gcp-storage/src/main/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigDefBuilder.scala @@ -16,14 +16,14 @@ package io.lenses.streamreactor.connect.gcp.storage.source.config import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceConfigDefBuilder -import io.lenses.streamreactor.connect.gcp.storage.config.AuthModeSettings +import io.lenses.streamreactor.connect.gcp.storage.config.GCPSettings import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings import scala.jdk.CollectionConverters.MapHasAsScala case class GCPStorageSourceConfigDefBuilder(props: Map[String, AnyRef]) extends CloudSourceConfigDefBuilder(GCPConfigSettings.CONNECTOR_PREFIX, GCPStorageSourceConfigDef.config, props) - with AuthModeSettings { + with GCPSettings { def getParsedValues: Map[String, _] = values().asScala.toMap diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala index aaec37c90..d84bab120 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/CommonConfigDefTest.scala @@ -17,9 +17,8 @@ package io.lenses.streamreactor.connect.gcp.storage.config import cats.implicits.catsSyntaxOptionId import io.lenses.streamreactor.connect.gcp.common.config.AuthModeSettings +import io.lenses.streamreactor.connect.gcp.common.config.GCPSettings import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.CONNECTOR_PREFIX -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.GCP_PROJECT_ID -import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.HOST import io.lenses.streamreactor.connect.gcp.storage.config.GCPConfigSettings.KCQL_CONFIG import org.scalatest.EitherValues import org.scalatest.flatspec.AnyFlatSpec @@ -31,6 +30,7 @@ import scala.jdk.CollectionConverters.MapHasAsScala class CommonConfigDefTest extends AnyFlatSpec with Matchers with EitherValues with UploadConfigKeys { private val authModeConfig = new AuthModeSettings(javaConnectorPrefix) + private val gcpSettings = new GCPSettings(javaConnectorPrefix) private val commonConfigDef = new CommonConfigDef { override def connectorPrefix: String = CONNECTOR_PREFIX @@ -38,15 +38,15 @@ class CommonConfigDefTest extends AnyFlatSpec with Matchers with EitherValues wi private val DefaultProps: Map[String, String] = Map( - GCP_PROJECT_ID -> "projectId", + gcpSettings.getGcpProjectId -> "projectId", authModeConfig.getAuthModeKey -> "none", - HOST -> "localhost:9090", + gcpSettings.getHost -> "localhost:9090", KCQL_CONFIG -> "SELECT * FROM DEFAULT", ) "CommonConfigDef" should "retain original properties after parsing" in { val resultMap = commonConfigDef.config.parse(DefaultProps.asJava).asScala - resultMap should have size 15 + resultMap should have size 16 DefaultProps.foreach { case (k, v) => withClue("Unexpected property: " + k) { diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettingsTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettingsTest.scala index 79107f711..da6ee1509 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettingsTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigSettingsTest.scala @@ -29,7 +29,7 @@ class GCPConfigSettingsTest extends AnyFlatSpec with Matchers with LazyLogging { val configKeys = GCPStorageSinkConfigDef.config.configKeys().keySet().asScala - configKeys.size shouldBe 21 + configKeys.size shouldBe 22 configKeys.foreach { k => k.toLowerCase should be(k) } diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala deleted file mode 100644 index e7bd741fe..000000000 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/config/GCPConfigTest.scala +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2017-2024 Lenses.io Ltd - * - * 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. - */ -package io.lenses.streamreactor.connect.gcp.storage.config - -/* - * Copyright 2017-2023 Lenses.io Ltd - * - * 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. - */ -import com.typesafe.scalalogging.LazyLogging -import io.lenses.streamreactor.common.config.base.RetryConfig -import io.lenses.streamreactor.connect.gcp.common.auth.mode.AuthMode -import org.mockito.MockitoSugar -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import org.scalatest.prop.TableDrivenPropertyChecks._ - -class GCPConfigTest extends AnyFlatSpec with Matchers with LazyLogging with MockitoSugar { - - private val authMode = mock[AuthMode] - - val retryValuesMap = Table[String, Any, Any, RetryConfig]( - ("testName", "retries", "interval", "result"), - ("noret-noint", 0, 0, new RetryConfig(0, 0)), - ("ret-and-int", 1, 2, new RetryConfig(1, 2)), - ("noret-noint-strings", "0", "0", new RetryConfig(0, 0)), - ("ret-and-int-strings", "1", "2", new RetryConfig(1, 2)), - ) - - "GCPConfig" should "set http retry config" in { - forAll(retryValuesMap) { - (name: String, ret: Any, interval: Any, result: RetryConfig) => - logger.debug("Executing {}", name) - GCPConnectionConfigBuilder(Map( - "connect.gcpstorage.http.max.retries" -> ret, - "connect.gcpstorage.http.retry.interval" -> interval, - ), - authMode, - ).getHttpRetryConfig should be(result) - } - } - -} diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala index 95fbc31e3..e134bdee3 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala @@ -21,13 +21,15 @@ import io.lenses.streamreactor.connect.gcp.common.auth.mode.CredentialsAuthMode import io.lenses.streamreactor.connect.gcp.storage.model.location.GCPStorageLocationValidator import org.apache.kafka.common.config.ConfigException import org.apache.kafka.common.config.types.Password -import org.scalatest.EitherValues +import org.scalatest.{EitherValues, OptionValues} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers._ -class GCPStorageSourceConfigTest extends AnyFunSuite with EitherValues { +import scala.jdk.OptionConverters.RichOptional - val taskId = ConnectorTaskId("name", 1, 1) +class GCPStorageSourceConfigTest extends AnyFunSuite with EitherValues with OptionValues { + + private val taskId = ConnectorTaskId("name", 1, 1) implicit val validator: CloudLocationValidator = GCPStorageLocationValidator test("fromProps should reject configuration when no kcql string is provided") { val props = Map[String, String]() @@ -79,8 +81,8 @@ class GCPStorageSourceConfigTest extends AnyFunSuite with EitherValues { "connect.gcpstorage.gcp.auth.mode" -> "credentials", "connect.gcpstorage.gcp.credentials" -> password, ) - val storageConfig = GCPStorageSourceConfig.fromProps(taskId, props).value - storageConfig.connectionConfig.getAuthMode should be(new CredentialsAuthMode(password)) + val storageConfig = GCPStorageSourceConfig.fromProps(taskId, props) + storageConfig.value.connectionConfig.getAuthMode.toScala.value should be(new CredentialsAuthMode(password)) } test("apply should return Left with ConnectException when password property is missed") { @@ -102,6 +104,7 @@ class GCPStorageSourceConfigTest extends AnyFunSuite with EitherValues { result.left.value match { case ex if expectedExceptionClass == ex.getClass.getName => ex.getMessage should be(expectedMessage) - case ex => fail(s"Unexpected exception, was a ${ex.getClass.getName}") + case ex => + fail(s"Unexpected exception, was a ${ex.getClass.getName} with stacky ${ex.printStackTrace()}") } } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 95f1f8a97..da7e33c0c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -181,8 +181,9 @@ object Dependencies { val `mockitoScala` = "org.mockito" %% "mockito-scala" % mockitoScalaVersion val `mockitoJava` = "org.mockito" % "mockito-inline" % mockitoJavaVersion - val `junitJupiter` = "org.junit.jupiter" % "junit-jupiter-api" % junitJupiterVersion - val `assertjCore` = "org.assertj" % "assertj-core" % assertjCoreVersion + val `junitJupiter` = "org.junit.jupiter" % "junit-jupiter-api" % junitJupiterVersion + val `junitJupiterParams` = "org.junit.jupiter" % "junit-jupiter-params" % junitJupiterVersion + val `assertjCore` = "org.assertj" % "assertj-core" % assertjCoreVersion val catsEffectScalatest = "org.typelevel" %% "cats-effect-testing-scalatest" % `cats-effect-testing` @@ -452,7 +453,7 @@ trait Dependencies { ) ++ enumeratum ++ circe val javaCommonDeps: Seq[ModuleID] = Seq(lombok, kafkaConnectJson, kafkaClients) - val javaCommonTestDeps: Seq[ModuleID] = Seq(junitJupiter, assertjCore, `mockitoJava`, logback) + val javaCommonTestDeps: Seq[ModuleID] = Seq(junitJupiter, junitJupiterParams, assertjCore, `mockitoJava`, logback) //Specific modules dependencies From 1bbb529af8cd550ac4d39db8e7fd03396118e83e Mon Sep 17 00:00:00 2001 From: Mati Urban <157909548+GoMati-MU@users.noreply.github.com> Date: Wed, 8 May 2024 12:32:10 +0000 Subject: [PATCH 25/30] Experimental Java Autoformatting using Spotless (#1210) * * Implement code formatting using Spotless * Call gradle from sbt so formatAll works again * Header check build * Proposition to have Spotless formatting using Eclipse formatter * applying formatting to the code --------- Co-authored-by: David Sloan --- .github/workflows/java-build.yml | 5 +- build.sbt | 18 +- java-connectors/HEADER.txt | 28 +- java-connectors/build.gradle | 44 +- java-connectors/config/Lenses_IDEA.xml | 591 ++++++++ java-connectors/config/Lenses_eclipse.xml | 380 +++++ java-connectors/config/README-style.md | 24 + .../config/checkstyle/checkstyle.xml | 26 +- .../config/eclipseCodeFormatter.xml | 16 + .../config/AzureEventHubsConfigConstants.java | 3 +- .../config/AzureEventHubsSourceConfig.java | 84 +- .../eventhubs/config/SourceDataType.java | 3 +- .../eventhubs/mapping/SourceRecordMapper.java | 12 +- .../AzureConsumerRebalancerListener.java | 11 +- .../source/AzureEventHubsSourceConnector.java | 4 +- .../source/AzureEventHubsSourceTask.java | 30 +- .../source/BlockingQueueProducer.java | 2 +- .../source/BlockingQueueProducerProvider.java | 17 +- .../EventHubsKafkaConsumerController.java | 23 +- .../KafkaByteBlockingQueuedProducer.java | 7 +- .../eventhubs/source/ProducerProvider.java | 2 +- .../source/TopicPartitionOffsetProvider.java | 5 +- .../eventhubs/util/KcqlConfigTopicMapper.java | 3 +- .../eventhubs/config/SourceDataTypeTest.java | 4 +- .../mapping/SourceRecordMapperTest.java | 23 +- .../AzureConsumerRebalancerListenerTest.java | 19 +- .../AzureEventHubsSourceConnectorTest.java | 22 +- .../source/AzureEventHubsSourceTaskTest.java | 7 +- .../BlockingQueueProducerProviderTest.java | 37 +- .../EventHubsKafkaConsumerControllerTest.java | 46 +- .../KafkaByteBlockingQueuedProducerTest.java | 13 +- .../TopicPartitionOffsetProviderTest.java | 153 +- .../util/KcqlConfigTopicMapperTest.java | 25 +- .../kafka-connect-common/build.gradle | 2 +- .../common/config/base/BaseConfig.java | 2 +- .../common/config/base/ConfigSettings.java | 30 +- .../common/config/base/RetryConfig.java | 2 +- .../config/base/intf/ConnectionConfig.java | 2 +- .../config/base/intf/ConnectorPrefixed.java | 3 +- .../config/base/model/ConnectorPrefix.java | 10 +- .../common/config/source/ConfigSource.java | 3 +- .../config/source/ConfigWrapperSource.java | 5 +- .../common/config/source/MapConfigSource.java | 2 +- .../exception/ConnectorStartupException.java | 2 +- .../InputStreamExtractionException.java | 2 +- .../common/util/AsciiArtPrinter.java | 9 +- .../common/util/InputStreamHandler.java | 4 +- .../common/util/JarManifest.java | 9 +- .../config/source/ConfigSourceTestBase.java | 2 +- .../source/ConfigWrapperSourceTest.java | 2 +- .../config/source/MapConfigSourceTest.java | 2 +- .../common/util/AsciiArtPrinterTest.java | 4 +- .../common/util/JarManifestTest.java | 4 +- .../gcp/common/auth/GCPConnectionConfig.java | 2 +- .../auth/GCPServiceBuilderConfigurer.java | 28 +- .../gcp/common/auth/HttpTimeoutConfig.java | 3 +- .../gcp/common/auth/mode/AuthMode.java | 2 +- .../common/auth/mode/CredentialsAuthMode.java | 3 +- .../gcp/common/auth/mode/DefaultAuthMode.java | 11 +- .../gcp/common/auth/mode/FileAuthMode.java | 2 +- .../gcp/common/auth/mode/NoAuthMode.java | 2 +- .../gcp/common/config/AuthModeSettings.java | 18 +- .../gcp/common/config/GCPSettings.java | 2 +- .../auth/GCPServiceBuilderConfigurerTest.java | 2 +- .../connect/gcp/common/auth/TestService.java | 6 +- .../auth/mode/CredentialsAuthModeTest.java | 2 +- .../common/auth/mode/DefaultAuthModeTest.java | 2 +- .../common/auth/mode/FileAuthModeTest.java | 2 +- .../gcp/common/auth/mode/NoAuthModeTest.java | 2 +- .../gcp/common/auth/mode/TestFileUtil.java | 2 +- .../common/config/AuthModeSettingsTest.java | 2 +- .../gcp/common/config/GCPSettingsTest.java | 6 +- .../kafka-connect-query-language/build.gradle | 4 +- .../src/main/java/io/lenses/kcql/Field.java | 243 +-- .../main/java/io/lenses/kcql/FieldType.java | 30 +- .../main/java/io/lenses/kcql/FormatType.java | 3 +- .../src/main/java/io/lenses/kcql/Kcql.java | 1304 ++++++++--------- .../src/main/java/io/lenses/kcql/Tag.java | 3 +- .../java/io/lenses/kcql/WriteModeEnum.java | 2 +- 79 files changed, 2275 insertions(+), 1201 deletions(-) create mode 100644 java-connectors/config/Lenses_IDEA.xml create mode 100644 java-connectors/config/Lenses_eclipse.xml create mode 100644 java-connectors/config/README-style.md create mode 100644 java-connectors/config/eclipseCodeFormatter.xml diff --git a/.github/workflows/java-build.yml b/.github/workflows/java-build.yml index 402bd0b3c..0adb5be91 100644 --- a/.github/workflows/java-build.yml +++ b/.github/workflows/java-build.yml @@ -56,7 +56,10 @@ jobs: with: gradle-version: 8.6 - - name: Check License Headers and Test with Gradle + - name: Formatting and Headers Check + run: cd 'java-connectors' && ./gradlew ${{ matrix.module }}:spotlessCheck + + - name: Test with Gradle run: cd 'java-connectors' && ./gradlew ${{ matrix.module }}:test build-and-cache: diff --git a/build.sbt b/build.sbt index d21815fba..5ddda45c0 100644 --- a/build.sbt +++ b/build.sbt @@ -7,6 +7,7 @@ import sbt.* import sbt.Project.projectToLocalProject import java.io.File +import scala.sys.process._ ThisBuild / scalaVersion := Dependencies.scalaVersion @@ -474,10 +475,25 @@ addCommandAlias( "validateAll", "headerCheck;test:headerCheck;it:headerCheck;fun:headerCheck;scalafmtCheckAll;test-common/scalafmtCheck;test-common/headerCheck", ) + +lazy val gradleSpotlessApply = taskKey[Unit]("Run 'gradle spotlessApply' via external process") +gradleSpotlessApply := { + // Specify the desired working directory for the external process + val targetDirectory = baseDirectory.value / "java-connectors" + + // Execute 'gradle spotlessApply' in the specified directory + val exitCode = Process("gradle spotlessApply", targetDirectory).! + + if (exitCode != 0) { + throw new RuntimeException("gradle spotlessApply command failed") + } +} + addCommandAlias( "formatAll", - "headerCreateAll;scalafmtAll;scalafmtSbt;test-common/scalafmt;test-common/headerCreateAll", + ";headerCreateAll;scalafmtAll;scalafmtSbt;test-common/scalafmt;test-common/headerCreateAll;gradleSpotlessApply", ) + addCommandAlias("fullTest", ";test;it:test;fun:test") addCommandAlias("fullCoverageTest", ";coverage;test;it:test;coverageReport;coverageAggregate") diff --git a/java-connectors/HEADER.txt b/java-connectors/HEADER.txt index 8828f9cd2..c782355dd 100644 --- a/java-connectors/HEADER.txt +++ b/java-connectors/HEADER.txt @@ -1,13 +1,15 @@ -Copyright 2017-${year} ${name} Ltd - -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. \ No newline at end of file +/* + * Copyright 2017-$YEAR Lenses.io Ltd + * + * 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. + */ \ No newline at end of file diff --git a/java-connectors/build.gradle b/java-connectors/build.gradle index a6a8663dc..2358bb5f9 100644 --- a/java-connectors/build.gradle +++ b/java-connectors/build.gradle @@ -1,6 +1,6 @@ plugins { id 'com.github.johnrengelman.shadow' version '8.1.1' - id 'org.cadixdev.licenser' version '0.6.1' + id "com.diffplug.spotless" version "6.25.0" id 'java' id 'java-library' } @@ -14,7 +14,7 @@ allprojects { apply plugin: 'java' apply plugin: 'java-library' apply plugin: 'com.github.johnrengelman.shadow' - apply plugin: 'org.cadixdev.licenser' + apply plugin: 'com.diffplug.spotless' java { setSourceCompatibility(JavaVersion.VERSION_11) @@ -77,22 +77,6 @@ allprojects { } } - license { - include("**/**.java", "**/**Test.java") - exclude("**/kcql/antlr4/**.java") //antlr generated files - header = project.file("${project.rootDir}/HEADER.txt") - newLine = false - - style { - java = 'BLOCK_COMMENT' - } - - properties { - name = 'Lenses.io' - year = LocalDate.now().year - } - } - jar { manifest { attributes("StreamReactor-Version": project.version, @@ -147,14 +131,34 @@ allprojects { // exclude(dependency("com.google.guava:guava:28.1-android")) } + + spotless { + + format 'misc', { + // define the files to apply `misc` to + target '*.gradle', '.gitattributes', '.gitignore' + + // define the steps to apply to those files + trimTrailingWhitespace() + indentWithSpaces() // or spaces. Takes an integer argument if you don't like 4 + endWithNewline() + } + java { + toggleOffOn() + // optional: you can specify a specific version and/or config file + eclipse('4.30').configFile("${rootDir}/config/Lenses_eclipse.xml") + licenseHeaderFile(rootProject.file("HEADER.txt")) + } + } + } - compileJava.dependsOn("checkLicenses") + //compileJava.dependsOn("checkLicenses") task fatJar(dependsOn: [test, jar, shadowJar]) task collectFatJar(type: Copy, dependsOn: [fatJar]) { from("${buildDir}/libs").include("kafka-connect-*-all.jar") - .exclude("*-common-*").into(libsDir) + .exclude("*-common-*").into(libsDir) } } diff --git a/java-connectors/config/Lenses_IDEA.xml b/java-connectors/config/Lenses_IDEA.xml new file mode 100644 index 000000000..835ae191c --- /dev/null +++ b/java-connectors/config/Lenses_IDEA.xml @@ -0,0 +1,591 @@ + + + \ No newline at end of file diff --git a/java-connectors/config/Lenses_eclipse.xml b/java-connectors/config/Lenses_eclipse.xml new file mode 100644 index 000000000..497ab4889 --- /dev/null +++ b/java-connectors/config/Lenses_eclipse.xml @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/java-connectors/config/README-style.md b/java-connectors/config/README-style.md new file mode 100644 index 000000000..22d440908 --- /dev/null +++ b/java-connectors/config/README-style.md @@ -0,0 +1,24 @@ +# Lenses Code style +In order to be consistent with our Code Style of our Java sources we have three configurations that we use: + +- IntelliJ IDEA config +- Eclipse Code Formatter config +- Eclipse format config + +Below we'll quickly describe which config helps us in which scenario. + +## IntelliJ IDEA + +This configuration can be easily loaded if you're using JetBrains' IntelliJ IDEA. + +All you need to do is to go: **Settings> Editor > Code Style > Import (scheme) > IntelliJ IDEA code style (xml)** and load the file (`config/Lenses_IDEA.xml`). However for full compatibility we advise to pair it with [Adapter for Eclipse Code Formatter](https://plugins.jetbrains.com/plugin/6546-adapter-for-eclipse-code-formatter) plugin then importing its config by going through **Eclipse Code Formatter config** section. + +## Eclipse Code Formatter config + +If you use [Adapter for Eclipse Code Formatter](https://plugins.jetbrains.com/plugin/6546-adapter-for-eclipse-code-formatter) plugin in your IntelliJ you can import this config by putting `config/eclipseCodeFormatter.xml` file into your `.idea` folder then checking if it loaded correctly in **Settings > Adapter for Eclipse Code Formatter** section (it should load Eclipse format config from section below). Using this configuration you should be able to get the same results no matter whether you use *Reformat Code* option in IntelliJ or using Gradle. + +## Eclipse format config + +If you're using Eclipse IDE or you want to use our Spotless Gradle plugin with Eclipse config you can import `config/Lenses_eclipse.xml` into your IDE. If you build using our Gradle plugins, it will be automatically imported (then you can use e.g. `spotlessApply` command). + + \ No newline at end of file diff --git a/java-connectors/config/checkstyle/checkstyle.xml b/java-connectors/config/checkstyle/checkstyle.xml index c731fe024..f2271edba 100644 --- a/java-connectors/config/checkstyle/checkstyle.xml +++ b/java-connectors/config/checkstyle/checkstyle.xml @@ -4,16 +4,9 @@ "https://checkstyle.org/dtds/configuration_1_3.dtd"> @@ -268,12 +261,12 @@ - - - - - - + + + + + + - + + + + + \ No newline at end of file diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsConfigConstants.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsConfigConstants.java index 35fab9bb6..cd8a1abbc 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsConfigConstants.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsConfigConstants.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -22,7 +22,6 @@ */ public class AzureEventHubsConfigConstants { - private static final String DOT = "."; public static final String OPTIONAL_EMPTY_DEFAULT = ""; public static final String CONNECTOR_PREFIX = "connect.eventhubs"; diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsSourceConfig.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsSourceConfig.java index fba2ccd28..9f92fa4b6 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsSourceConfig.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/AzureEventHubsSourceConfig.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -35,57 +35,57 @@ public class AzureEventHubsSourceConfig extends BaseConfig implements ConnectorP public static final String CONNECTION_GROUP = "Connection"; - private static final UnaryOperator CONFIG_NAME_PREFIX_APPENDER = name -> - AzureEventHubsConfigConstants.CONNECTOR_WITH_CONSUMER_PREFIX + name; + private static final UnaryOperator CONFIG_NAME_PREFIX_APPENDER = + name -> AzureEventHubsConfigConstants.CONNECTOR_WITH_CONSUMER_PREFIX + name; private static final Set EXCLUDED_CONSUMER_PROPERTIES = Set.of(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ConsumerConfig.GROUP_ID_CONFIG, ConsumerConfig.CLIENT_ID_CONFIG, ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG); - @Getter static ConfigDef configDefinition; static { ConfigDef kafkaConsumerConfigToExpose = getKafkaConsumerConfigToExpose(); - configDefinition = new ConfigDef(kafkaConsumerConfigToExpose) - .define(AzureEventHubsConfigConstants.CONNECTOR_NAME, - Type.STRING, - AzureEventHubsConfigConstants.CONNECTOR_NAME_DEFAULT, - Importance.HIGH, - AzureEventHubsConfigConstants.CONNECTOR_NAME_DOC, - CONNECTION_GROUP, - 1, - ConfigDef.Width.LONG, - AzureEventHubsConfigConstants.CONNECTOR_NAME - ).define(AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT, - Type.INT, - AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT_DEFAULT, - Importance.MEDIUM, - AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT_DOC, - CONNECTION_GROUP, - 3, - ConfigDef.Width.LONG, - AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT - ) - .define(AzureEventHubsConfigConstants.CONSUMER_OFFSET, - Type.STRING, - AzureEventHubsConfigConstants.CONSUMER_OFFSET_DEFAULT, - Importance.MEDIUM, - AzureEventHubsConfigConstants.CONSUMER_OFFSET_DOC, - CONNECTION_GROUP, - 4, - ConfigDef.Width.LONG, - AzureEventHubsConfigConstants.CONSUMER_OFFSET - ).define(AzureEventHubsConfigConstants.KCQL_CONFIG, - Type.STRING, - Importance.HIGH, - AzureEventHubsConfigConstants.KCQL_DOC, - "Mappings", - 1, - ConfigDef.Width.LONG, - AzureEventHubsConfigConstants.KCQL_CONFIG - ); + configDefinition = + new ConfigDef(kafkaConsumerConfigToExpose) + .define(AzureEventHubsConfigConstants.CONNECTOR_NAME, + Type.STRING, + AzureEventHubsConfigConstants.CONNECTOR_NAME_DEFAULT, + Importance.HIGH, + AzureEventHubsConfigConstants.CONNECTOR_NAME_DOC, + CONNECTION_GROUP, + 1, + ConfigDef.Width.LONG, + AzureEventHubsConfigConstants.CONNECTOR_NAME + ).define(AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT, + Type.INT, + AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT_DEFAULT, + Importance.MEDIUM, + AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT_DOC, + CONNECTION_GROUP, + 3, + ConfigDef.Width.LONG, + AzureEventHubsConfigConstants.CONSUMER_CLOSE_TIMEOUT + ) + .define(AzureEventHubsConfigConstants.CONSUMER_OFFSET, + Type.STRING, + AzureEventHubsConfigConstants.CONSUMER_OFFSET_DEFAULT, + Importance.MEDIUM, + AzureEventHubsConfigConstants.CONSUMER_OFFSET_DOC, + CONNECTION_GROUP, + 4, + ConfigDef.Width.LONG, + AzureEventHubsConfigConstants.CONSUMER_OFFSET + ).define(AzureEventHubsConfigConstants.KCQL_CONFIG, + Type.STRING, + Importance.HIGH, + AzureEventHubsConfigConstants.KCQL_DOC, + "Mappings", + 1, + ConfigDef.Width.LONG, + AzureEventHubsConfigConstants.KCQL_CONFIG + ); } public AzureEventHubsSourceConfig(Map properties) { diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataType.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataType.java index 92722e393..d1063c1f2 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataType.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataType.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -57,6 +57,7 @@ public static SourceDataType fromName(String name) { @Getter @EqualsAndHashCode public static class KeyValueTypes { + private final SourceDataType keyType; private final SourceDataType valueType; public static final KeyValueTypes DEFAULT_TYPES = new KeyValueTypes(BYTES, BYTES); diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapper.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapper.java index 0c5f0436c..8cf4ce384 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapper.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapper.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -61,11 +61,11 @@ public static SourceRecord mapSourceRecordIncludingHeaders( * from original message. * * @param consumerRecord original consumer record - * @param partitionKey partitionKey to indicate topic and partition - * @param offsetMap AzureOffsetMarker to indicate offset - * @param outputTopic Output topic for record - * @param keySchema Schema of the key - * @param valueSchema Schema of the value + * @param partitionKey partitionKey to indicate topic and partition + * @param offsetMap AzureOffsetMarker to indicate offset + * @param outputTopic Output topic for record + * @param keySchema Schema of the key + * @param valueSchema Schema of the value * @return SourceRecord without headers */ public static SourceRecord mapSourceRecordWithoutHeaders( diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListener.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListener.java index 2b800e759..82f733340 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListener.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListener.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -41,8 +41,8 @@ public class AzureConsumerRebalancerListener implements ConsumerRebalanceListene * Constructs {@link AzureConsumerRebalancerListener} for particular Kafka Consumer. * * @param topicPartitionOffsetProvider provider of committed offsets - * @param kafkaConsumer Kafka Consumer - * @param shouldSeekToLatest informs whether we should seek to latest or earliest if no offsets found + * @param kafkaConsumer Kafka Consumer + * @param shouldSeekToLatest informs whether we should seek to latest or earliest if no offsets found */ public AzureConsumerRebalancerListener( TopicPartitionOffsetProvider topicPartitionOffsetProvider, @@ -61,8 +61,9 @@ public void onPartitionsRevoked(Collection partitions) { public void onPartitionsAssigned(Collection partitions) { List partitionsWithoutOffsets = new ArrayList<>(); partitions.forEach(partition -> { - AzureTopicPartitionKey partitionKey = new AzureTopicPartitionKey( - partition.topic(), partition.partition()); + AzureTopicPartitionKey partitionKey = + new AzureTopicPartitionKey( + partition.topic(), partition.partition()); Optional partitionOffset = topicPartitionOffsetProvider.getOffset(partitionKey); partitionOffset.ifPresentOrElse( offset -> kafkaConsumer.seek(partition, offset.getOffsetValue()), diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnector.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnector.java index d0d019f65..61a16cb66 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnector.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnector.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -87,4 +87,4 @@ private static void parseAndValidateConfigs(Map props) { String kcqlMappings = azureEventHubsSourceConfig.getString(AzureEventHubsConfigConstants.KCQL_CONFIG); KcqlConfigTopicMapper.mapInputToOutputsFromConfig(kcqlMappings); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTask.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTask.java index 8c63aa08d..41458283b 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTask.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTask.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -60,20 +60,25 @@ public String version() { @Override public void start(Map props) { - OffsetStorageReader offsetStorageReader = ofNullable(this.context).flatMap( - context -> ofNullable(context.offsetStorageReader())).orElseThrow(); + OffsetStorageReader offsetStorageReader = + ofNullable(this.context).flatMap( + context -> ofNullable(context.offsetStorageReader())).orElseThrow(); AzureEventHubsSourceConfig azureEventHubsSourceConfig = new AzureEventHubsSourceConfig(props); TopicPartitionOffsetProvider topicPartitionOffsetProvider = new TopicPartitionOffsetProvider(offsetStorageReader); - ArrayBlockingQueue> recordsQueue = new ArrayBlockingQueue<>( - RECORDS_QUEUE_DEFAULT_SIZE); - Map inputToOutputTopics = KcqlConfigTopicMapper.mapInputToOutputsFromConfig( - azureEventHubsSourceConfig.getString(AzureEventHubsConfigConstants.KCQL_CONFIG)); + ArrayBlockingQueue> recordsQueue = + new ArrayBlockingQueue<>( + RECORDS_QUEUE_DEFAULT_SIZE); + Map inputToOutputTopics = + KcqlConfigTopicMapper.mapInputToOutputsFromConfig( + azureEventHubsSourceConfig.getString(AzureEventHubsConfigConstants.KCQL_CONFIG)); blockingQueueProducerProvider = new BlockingQueueProducerProvider(topicPartitionOffsetProvider); - KafkaByteBlockingQueuedProducer producer = blockingQueueProducerProvider.createProducer( - azureEventHubsSourceConfig, recordsQueue, inputToOutputTopics); - EventHubsKafkaConsumerController kafkaConsumerController = new EventHubsKafkaConsumerController( - producer, recordsQueue, inputToOutputTopics); + KafkaByteBlockingQueuedProducer producer = + blockingQueueProducerProvider.createProducer( + azureEventHubsSourceConfig, recordsQueue, inputToOutputTopics); + EventHubsKafkaConsumerController kafkaConsumerController = + new EventHubsKafkaConsumerController( + producer, recordsQueue, inputToOutputTopics); initialize(kafkaConsumerController, azureEventHubsSourceConfig); } @@ -82,7 +87,7 @@ public void start(Map props) { * {@link EventHubsKafkaConsumerController} instance. * * @param eventHubsKafkaConsumerController {@link EventHubsKafkaConsumerController} for this task - * @param azureEventHubsSourceConfig config for task + * @param azureEventHubsSourceConfig config for task */ public void initialize(EventHubsKafkaConsumerController eventHubsKafkaConsumerController, AzureEventHubsSourceConfig azureEventHubsSourceConfig) { @@ -93,7 +98,6 @@ public void initialize(EventHubsKafkaConsumerController eventHubsKafkaConsumerCo log.info("{} initialised.", getClass().getSimpleName()); } - @Override public List poll() throws InterruptedException { List poll = diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducer.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducer.java index 3cf3e54b9..50bf98408 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducer.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducer.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProvider.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProvider.java index c9b3babec..74d5bcc8d 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProvider.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProvider.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -41,7 +41,6 @@ public class BlockingQueueProducerProvider implements ProducerProvider consumerProperties = prepareConsumerProperties(azureEventHubsSourceConfig, - clientId, connectorName, keyValueTypes); + Map consumerProperties = + prepareConsumerProperties(azureEventHubsSourceConfig, + clientId, connectorName, keyValueTypes); KafkaConsumer kafkaConsumer = new KafkaConsumer<>(consumerProperties); @@ -78,13 +78,14 @@ public KafkaByteBlockingQueuedProducer createProducer( private static Map prepareConsumerProperties( AzureEventHubsSourceConfig azureEventHubsSourceConfig, String clientId, String connectorName, KeyValueTypes keyValueTypes) { - Map consumerProperties = azureEventHubsSourceConfig.originalsWithPrefix( - AzureEventHubsConfigConstants.CONNECTOR_WITH_CONSUMER_PREFIX, STRIP_PREFIX); + Map consumerProperties = + azureEventHubsSourceConfig.originalsWithPrefix( + AzureEventHubsConfigConstants.CONNECTOR_WITH_CONSUMER_PREFIX, STRIP_PREFIX); consumerProperties.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); consumerProperties.put(ConsumerConfig.GROUP_ID_CONFIG, connectorName); consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, - keyValueTypes.getKeyType().getDeserializerClass()); + keyValueTypes.getKeyType().getDeserializerClass()); consumerProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, keyValueTypes.getValueType().getDeserializerClass()); consumerProperties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerController.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerController.java index 6ff27db9f..72851468c 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerController.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerController.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -74,8 +74,9 @@ public List poll(Duration duration) throws InterruptedException { ConsumerRecords consumerRecords = null; try { - consumerRecords = recordsQueue.poll( - duration.get(ChronoUnit.SECONDS), TimeUnit.SECONDS); + consumerRecords = + recordsQueue.poll( + duration.get(ChronoUnit.SECONDS), TimeUnit.SECONDS); } catch (InterruptedException e) { log.info("{} has been interrupted on poll", this.getClass().getSimpleName()); throw e; @@ -86,15 +87,17 @@ public List poll(Duration duration) throws InterruptedException { for (ConsumerRecord consumerRecord : consumerRecords) { String inputTopic = consumerRecord.topic(); - AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey( - inputTopic, consumerRecord.partition()); + AzureTopicPartitionKey azureTopicPartitionKey = + new AzureTopicPartitionKey( + inputTopic, consumerRecord.partition()); AzureOffsetMarker offsetMarker = new AzureOffsetMarker(consumerRecord.offset()); - SourceRecord sourceRecord = mapSourceRecordIncludingHeaders(consumerRecord, - azureTopicPartitionKey, - offsetMarker, inputToOutputTopics.get(inputTopic), - queuedKafkaProducer.getKeyValueTypes().getKeyType().getSchema(), - queuedKafkaProducer.getKeyValueTypes().getValueType().getSchema()); + SourceRecord sourceRecord = + mapSourceRecordIncludingHeaders(consumerRecord, + azureTopicPartitionKey, + offsetMarker, inputToOutputTopics.get(inputTopic), + queuedKafkaProducer.getKeyValueTypes().getKeyType().getSchema(), + queuedKafkaProducer.getKeyValueTypes().getValueType().getSchema()); sourceRecords.add(sourceRecord); diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducer.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducer.java index 712b73e8c..4e2dc90e3 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducer.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducer.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -33,7 +33,8 @@ */ @Slf4j public class KafkaByteBlockingQueuedProducer implements BlockingQueueProducer { - private static final Duration DEFAULT_POLL_DURATION = Duration.of(1, ChronoUnit.SECONDS); + + private static final Duration DEFAULT_POLL_DURATION = Duration.of(1, ChronoUnit.SECONDS); private final TopicPartitionOffsetProvider topicPartitionOffsetProvider; private final BlockingQueue> recordsQueue; private final Consumer consumer; @@ -57,7 +58,7 @@ public class KafkaByteBlockingQueuedProducer implements BlockingQueueProducer { * @param keyValueTypes {@link KeyValueTypes} instance indicating key and value * types * @param clientId consumer client id - * @param inputTopics kafka inputTopics to consume from + * @param inputTopics kafka inputTopics to consume from * @param shouldSeekToLatest informs where should consumer seek when there are no * offsets committed */ diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/ProducerProvider.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/ProducerProvider.java index 6b69640c1..d52690b40 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/ProducerProvider.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/ProducerProvider.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProvider.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProvider.java index 6b798d130..fd1d55db7 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProvider.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProvider.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -32,7 +32,6 @@ public final class TopicPartitionOffsetProvider { private final OffsetStorageReader offsetStorageReader; - public TopicPartitionOffsetProvider(OffsetStorageReader offsetStorageReader) { this.offsetStorageReader = offsetStorageReader; } @@ -43,7 +42,7 @@ public TopicPartitionOffsetProvider(OffsetStorageReader offsetStorageReader) { * @param azureTopicPartitionKey key of topic+partition combo. * * @return empty optional if topic+partition combo has not committed any offsets or - * AzureOffsetMarker if combo already did commit some. + * AzureOffsetMarker if combo already did commit some. */ public Optional getOffset(AzureTopicPartitionKey azureTopicPartitionKey) { return Optional.ofNullable(offsetStorageReader.offset(azureTopicPartitionKey)) diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapper.java b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapper.java index fc3f5b627..5946e9b89 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapper.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/main/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapper.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -38,6 +38,7 @@ public class KcqlConfigTopicMapper { /** * This method parses KCQL statements and fetches input and output topics checking against * regex for invalid topic names in input and output. + * * @param kcqlString string to parse * @return map of input to output topic names */ diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataTypeTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataTypeTest.java index 6f2f0a829..f37a91d83 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataTypeTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/config/SourceDataTypeTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -57,4 +57,4 @@ void getSchema() { //then assertEquals(OPTIONAL_BYTES_SCHEMA, schema); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapperTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapperTest.java index c5e1ecb35..d7c1a0d38 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapperTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/mapping/SourceRecordMapperTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -34,6 +34,7 @@ import org.junit.jupiter.api.Test; class SourceRecordMapperTest { + private static final String TOPIC = "topic"; private static final Integer PARTITION = 10; private static final Long OFFSET = 111L; @@ -47,7 +48,7 @@ void shouldMapSourceRecordIncludingHeaders() { AzureTopicPartitionKey topicPartitionKey = new AzureTopicPartitionKey(TOPIC, PARTITION); AzureOffsetMarker azureOffsetMarker = new AzureOffsetMarker(OFFSET); - byte[] exampleHeaderValue = new byte[] {1, 10}; + byte[] exampleHeaderValue = new byte[]{1, 10}; int headerLength = exampleHeaderValue.length; Header mockedHeader = mock(Header.class); when(mockedHeader.key()).thenReturn(HEADER_KEY); @@ -62,16 +63,17 @@ void shouldMapSourceRecordIncludingHeaders() { //when Schema stringSchema = Schema.STRING_SCHEMA; Schema optionalStringSchema = Schema.OPTIONAL_STRING_SCHEMA; - SourceRecord sourceRecord = SourceRecordMapper.mapSourceRecordIncludingHeaders( - consumerRecord, topicPartitionKey, azureOffsetMarker, - OUTPUT_TOPIC, optionalStringSchema, stringSchema); + SourceRecord sourceRecord = + SourceRecordMapper.mapSourceRecordIncludingHeaders( + consumerRecord, topicPartitionKey, azureOffsetMarker, + OUTPUT_TOPIC, optionalStringSchema, stringSchema); //then assertRecordAttributesAreMappedFromSourceConsumerRecord(sourceRecord, consumerRecord, OUTPUT_TOPIC, optionalStringSchema, stringSchema, topicPartitionKey, azureOffsetMarker); verify(consumerRecord).headers(); assertThat(sourceRecord.headers()).hasSize(1); - assertThat(((byte[])sourceRecord.headers().lastWithName(HEADER_KEY).value())).hasSize(headerLength); + assertThat(((byte[]) sourceRecord.headers().lastWithName(HEADER_KEY).value())).hasSize(headerLength); } @Test @@ -85,9 +87,10 @@ void mapSourceRecordWithoutHeaders() { //when Schema stringSchema = Schema.STRING_SCHEMA; Schema optionalStringSchema = Schema.OPTIONAL_STRING_SCHEMA; - SourceRecord sourceRecord = SourceRecordMapper.mapSourceRecordWithoutHeaders( - consumerRecord, topicPartitionKey, azureOffsetMarker, OUTPUT_TOPIC, - optionalStringSchema, stringSchema); + SourceRecord sourceRecord = + SourceRecordMapper.mapSourceRecordWithoutHeaders( + consumerRecord, topicPartitionKey, azureOffsetMarker, OUTPUT_TOPIC, + optionalStringSchema, stringSchema); //then assertRecordAttributesAreMappedFromSourceConsumerRecord(sourceRecord, consumerRecord, @@ -122,4 +125,4 @@ private void assertRecordAttributesAreMappedFromSourceConsumerRecord(SourceRecor .returns(keySchema, from(SourceRecord::keySchema)) .returns(valueSchema, from(SourceRecord::valueSchema)); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListenerTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListenerTest.java index 03a0e41e5..308679c9b 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListenerTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureConsumerRebalancerListenerTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -39,8 +39,9 @@ class AzureConsumerRebalancerListenerTest { void onPartitionsAssignedShouldSeekToBeginningIfOffsetProviderProvidesEmptyOffsetAndSeekingToEarliest() { //given Consumer stringKafkaConsumer = mock(Consumer.class); - TopicPartitionOffsetProvider offsetProvider = mock( - TopicPartitionOffsetProvider.class); + TopicPartitionOffsetProvider offsetProvider = + mock( + TopicPartitionOffsetProvider.class); AzureConsumerRebalancerListener testObj = new AzureConsumerRebalancerListener(offsetProvider, stringKafkaConsumer, SEEK_TO_EARLIEST); String topic = "topic1"; @@ -62,8 +63,9 @@ void onPartitionsAssignedShouldSeekToBeginningIfOffsetProviderProvidesEmptyOffse void onPartitionsAssignedShouldSeekToEndIfOffsetProviderProvidesEmptyOffsetAndSeekingToLatest() { //given Consumer stringKafkaConsumer = mock(Consumer.class); - TopicPartitionOffsetProvider offsetProvider = mock( - TopicPartitionOffsetProvider.class); + TopicPartitionOffsetProvider offsetProvider = + mock( + TopicPartitionOffsetProvider.class); AzureConsumerRebalancerListener testObj = new AzureConsumerRebalancerListener(offsetProvider, stringKafkaConsumer, SEEK_TO_LATEST); String topic = "topic1"; @@ -86,8 +88,9 @@ void onPartitionsAssignedShouldSeekToSpecificOffsetIfOffsetProviderProvidesIt() //given Long specificOffset = 100L; Consumer stringKafkaConsumer = mock(Consumer.class); - TopicPartitionOffsetProvider offsetProvider = mock( - TopicPartitionOffsetProvider.class); + TopicPartitionOffsetProvider offsetProvider = + mock( + TopicPartitionOffsetProvider.class); when(offsetProvider.getOffset(any(AzureTopicPartitionKey.class))) .thenReturn(Optional.of(new AzureOffsetMarker(specificOffset))); AzureConsumerRebalancerListener testObj = @@ -106,4 +109,4 @@ void onPartitionsAssignedShouldSeekToSpecificOffsetIfOffsetProviderProvidesIt() verify(topicPartition1, times(1)).partition(); verify(stringKafkaConsumer).seek(topicPartition1, specificOffset); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnectorTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnectorTest.java index 79c6957b4..4ea08bd03 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnectorTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceConnectorTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -51,14 +51,15 @@ void taskConfigsShouldMultiplyConfigs() throws NoSuchFieldException, IllegalAcce int maxTasks = 3; //when - Field configPropertiesField = testObj.getClass() - .getDeclaredField("configProperties"); + Field configPropertiesField = + testObj.getClass() + .getDeclaredField("configProperties"); configPropertiesField.setAccessible(true); configPropertiesField.set(testObj, simpleProperties); List> taskConfigs = testObj.taskConfigs(maxTasks); //then - for (Map taskConfig : taskConfigs){ + for (Map taskConfig : taskConfigs) { assertTrue(taskConfig.equals(simpleProperties)); } @@ -76,12 +77,13 @@ void exactlyOnceSupportShouldReturnSupported() { } private Map createSimplePropertiesWithKcql() { - Map properties = Map.of( - AzureEventHubsConfigConstants.CONNECTOR_NAME, CONNECTOR_NAME, - "connector.class", AzureEventHubsSourceConnector.class.getCanonicalName(), - AzureEventHubsConfigConstants.KCQL_CONFIG, KCQL - ); + Map properties = + Map.of( + AzureEventHubsConfigConstants.CONNECTOR_NAME, CONNECTOR_NAME, + "connector.class", AzureEventHubsSourceConnector.class.getCanonicalName(), + AzureEventHubsConfigConstants.KCQL_CONFIG, KCQL + ); return properties; } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTaskTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTaskTest.java index da709bfa8..81c27f362 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTaskTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/AzureEventHubsSourceTaskTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -57,7 +57,8 @@ void setup() { @AfterEach void teardown() { - ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(AzureEventHubsSourceTask.class)).detachAndStopAllAppenders(); + ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(AzureEventHubsSourceTask.class)) + .detachAndStopAllAppenders(); } @Test @@ -141,4 +142,4 @@ void getVersionShouldDelegateToJarManifestGetVersion() { assertEquals(SOME_VERSION, version); verify(mockedJarManifest, atMostOnce()).getVersion(); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProviderTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProviderTest.java index 3fe1007b7..40479058d 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProviderTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/BlockingQueueProducerProviderTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -46,40 +46,41 @@ class BlockingQueueProducerProviderTest { void setup() { logWatcher = new ListAppender<>(); logWatcher.start(); - ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(BlockingQueueProducerProvider.class)).addAppender(logWatcher); + ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(BlockingQueueProducerProvider.class)).addAppender( + logWatcher); } @AfterEach void teardown() { - ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(BlockingQueueProducerProvider.class)).detachAndStopAllAppenders(); + ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(BlockingQueueProducerProvider.class)) + .detachAndStopAllAppenders(); } @Test - void whenConstructorInvokedWithoutOffsetParameterThenConfigExceptionIsThrown(){ + void whenConstructorInvokedWithoutOffsetParameterThenConfigExceptionIsThrown() { //given AzureEventHubsSourceConfig azureConfigMock = mock(AzureEventHubsSourceConfig.class); TopicPartitionOffsetProvider mockedOffsetProvider = mock(TopicPartitionOffsetProvider.class); - //when - BlockingQueueProducerProvider testObj = new BlockingQueueProducerProvider( - mockedOffsetProvider); + BlockingQueueProducerProvider testObj = + new BlockingQueueProducerProvider( + mockedOffsetProvider); ConfigException configException; - try(MockedConstruction ignored = Mockito.mockConstruction(KafkaConsumer.class)){ + try (MockedConstruction ignored = Mockito.mockConstruction(KafkaConsumer.class)) { configException = assertThrows(ConfigException.class, () -> { testObj.createProducer(azureConfigMock, new ArrayBlockingQueue<>(1), new HashMap<>()); }); } - //then assertEquals("Invalid value null for configuration connect.eventhubs.source.default.offset: " - + "allowed values are: earliest/latest", configException.getMessage()); + + "allowed values are: earliest/latest", configException.getMessage()); } @Test - void whenConstructorInvokedWithParametersThenMockKafkaConsumerShouldBeCreatedAndLogged(){ + void whenConstructorInvokedWithParametersThenMockKafkaConsumerShouldBeCreatedAndLogged() { //given String earliestOffset = "earliest"; TopicPartitionOffsetProvider mockedOffsetProvider = mock(TopicPartitionOffsetProvider.class); @@ -91,12 +92,14 @@ void whenConstructorInvokedWithParametersThenMockKafkaConsumerShouldBeCreatedAnd .thenReturn("insert into output select * from input"); //when - BlockingQueueProducerProvider testObj = new BlockingQueueProducerProvider( - mockedOffsetProvider); + BlockingQueueProducerProvider testObj = + new BlockingQueueProducerProvider( + mockedOffsetProvider); KafkaByteBlockingQueuedProducer consumer; - try(MockedConstruction ignored = Mockito.mockConstruction(KafkaConsumer.class)){ - consumer = testObj.createProducer(azureConfigMock, new ArrayBlockingQueue<>(1), - new HashMap<>()); + try (MockedConstruction ignored = Mockito.mockConstruction(KafkaConsumer.class)) { + consumer = + testObj.createProducer(azureConfigMock, new ArrayBlockingQueue<>(1), + new HashMap<>()); } //then @@ -105,4 +108,4 @@ void whenConstructorInvokedWithParametersThenMockKafkaConsumerShouldBeCreatedAnd assertEquals(1, logWatcher.list.size()); assertTrue(logWatcher.list.get(0).getFormattedMessage().startsWith("Attempting to create Client with Id")); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerControllerTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerControllerTest.java index da82a2f29..8fb8f1969 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerControllerTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/EventHubsKafkaConsumerControllerTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -65,8 +65,9 @@ void pollShouldPollQueueAndReturnSourceRecords() throws InterruptedException { ArrayBlockingQueue> recordsQueue = mockRecordsQueue(INPUT_TOPIC); //when - testObj = new EventHubsKafkaConsumerController(mockedBlockingProducer, recordsQueue, - inputOutputMap); + testObj = + new EventHubsKafkaConsumerController(mockedBlockingProducer, recordsQueue, + inputOutputMap); List sourceRecords = testObj.poll(DURATION_2_SECONDS); //then @@ -81,8 +82,9 @@ void pollShouldPollQueueAndReturnSourceRecords() throws InterruptedException { void pollWithMultipleInputsAndOutputsShouldPollQueueAndReturnSourceRecordsToCorrectOutput() throws InterruptedException { //given - Map inputOutputMap = Map.of(INPUT_TOPIC, OUTPUT_TOPIC, INPUT_TOPIC_2, - OUTPUT_TOPIC_2); + Map inputOutputMap = + Map.of(INPUT_TOPIC, OUTPUT_TOPIC, INPUT_TOPIC_2, + OUTPUT_TOPIC_2); SourceDataType mockedKeyDataType = mockSourceDataType(); SourceDataType mockedValueDataType = mockSourceDataType(); @@ -94,8 +96,9 @@ void pollWithMultipleInputsAndOutputsShouldPollQueueAndReturnSourceRecordsToCorr ArrayBlockingQueue> recordsQueue = mockRecordsQueue(INPUT_TOPIC, INPUT_TOPIC_2); //when - testObj = new EventHubsKafkaConsumerController(mockedBlockingProducer, recordsQueue, - inputOutputMap); + testObj = + new EventHubsKafkaConsumerController(mockedBlockingProducer, recordsQueue, + inputOutputMap); List sourceRecords = testObj.poll(DURATION_2_SECONDS); //then @@ -111,15 +114,17 @@ void pollWithMultipleInputsAndOutputsShouldPollQueueAndReturnSourceRecordsToCorr @Test void closeShouldCloseTheProducer() { //given - KafkaByteBlockingQueuedProducer mockedBlockingProducer = mock( - KafkaByteBlockingQueuedProducer.class); + KafkaByteBlockingQueuedProducer mockedBlockingProducer = + mock( + KafkaByteBlockingQueuedProducer.class); Map inputOutputMap = Map.of(INPUT_TOPIC, OUTPUT_TOPIC); ArrayBlockingQueue> recordsQueue = mockRecordsQueue(); - testObj = new EventHubsKafkaConsumerController(mockedBlockingProducer, recordsQueue, - inputOutputMap); + testObj = + new EventHubsKafkaConsumerController(mockedBlockingProducer, recordsQueue, + inputOutputMap); //when testObj.close(DURATION_2_SECONDS); @@ -155,22 +160,22 @@ private static Headers mockEmptyHeaders() { return headersMock; } - private static KafkaByteBlockingQueuedProducer mockByteBlockingProducer(KeyValueTypes mockedKeyValueTypes) { - KafkaByteBlockingQueuedProducer mockedBlockingProducer = mock( - KafkaByteBlockingQueuedProducer.class); + KafkaByteBlockingQueuedProducer mockedBlockingProducer = + mock( + KafkaByteBlockingQueuedProducer.class); when(mockedBlockingProducer.getKeyValueTypes()).thenReturn(mockedKeyValueTypes); return mockedBlockingProducer; } - private static ArrayBlockingQueue> mockRecordsQueue - (String... inputTopics) { + private static ArrayBlockingQueue> mockRecordsQueue(String... inputTopics) { Headers headersMock = mockEmptyHeaders(); - List> consumerRecordList = Arrays.stream(inputTopics) - .map(it -> mockConsumerRecord(it, Optional.of(headersMock))) - .collect(Collectors.toList()); + List> consumerRecordList = + Arrays.stream(inputTopics) + .map(it -> mockConsumerRecord(it, Optional.of(headersMock))) + .collect(Collectors.toList()); ConsumerRecords mockedRecords = mock(ConsumerRecords.class); when(mockedRecords.count()).thenReturn(consumerRecordList.size()); @@ -179,5 +184,4 @@ private static KafkaByteBlockingQueuedProducer mockByteBlockingProducer(KeyValue return new ArrayBlockingQueue<>(DEFAULT_CAPACITY, false, List.of(mockedRecords)); } - -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducerTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducerTest.java index 844803dee..7d32c0b84 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducerTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/KafkaByteBlockingQueuedProducerTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -32,10 +32,11 @@ class KafkaByteBlockingQueuedProducerTest { private static final String CLIENT_ID = "clientId"; private static Consumer consumer = mock(Consumer.class); - KafkaByteBlockingQueuedProducer testObj = new KafkaByteBlockingQueuedProducer( - mock(TopicPartitionOffsetProvider.class), mock(BlockingQueue.class), - consumer, KeyValueTypes.DEFAULT_TYPES, - CLIENT_ID, Sets.newSet("topic"), false); + KafkaByteBlockingQueuedProducer testObj = + new KafkaByteBlockingQueuedProducer( + mock(TopicPartitionOffsetProvider.class), mock(BlockingQueue.class), + consumer, KeyValueTypes.DEFAULT_TYPES, + CLIENT_ID, Sets.newSet("topic"), false); @Test void closeShouldBeDelegatedToKafkaConsumer() { @@ -48,4 +49,4 @@ void closeShouldBeDelegatedToKafkaConsumer() { //then verify(consumer).close(eq(tenSeconds)); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProviderTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProviderTest.java index 33490cbc3..befc340d9 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProviderTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/source/TopicPartitionOffsetProviderTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -33,77 +33,80 @@ class TopicPartitionOffsetProviderTest { @Test - void getOffsetShouldCallOffsetStorageReader() { - //given - OffsetStorageReader offsetStorageReader = mock(OffsetStorageReader.class); - TopicPartitionOffsetProvider topicPartitionOffsetProvider = new TopicPartitionOffsetProvider( - offsetStorageReader); - String topic = "some_topic"; - Integer partition = 1; - - //when - AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); - topicPartitionOffsetProvider.getOffset(azureTopicPartitionKey); - - //then - verify(offsetStorageReader).offset(azureTopicPartitionKey); - } - - @Test - void getOffsetShouldReturnEmptyOptionalIfCommitsNotFound() { - //given - OffsetStorageReader offsetStorageReader = mock(OffsetStorageReader.class); - when(offsetStorageReader.offset(any(Map.class))).thenReturn(new HashMap()); - String topic = "some_topic"; - Integer partition = 1; - TopicPartitionOffsetProvider topicPartitionOffsetProvider = new TopicPartitionOffsetProvider( - offsetStorageReader); - - //when - AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); - Optional offset = topicPartitionOffsetProvider.getOffset(azureTopicPartitionKey); - - //then - verify(offsetStorageReader).offset(azureTopicPartitionKey); - assertTrue(offset.isEmpty()); - } - - @Test - void getOffsetShouldReturnValidAzureOffsetMarkerIfCommitsFound() { - //given - long offsetOne = 1L; - String OFFSET_KEY = "OFFSET"; - OffsetStorageReader offsetStorageReader = mock(OffsetStorageReader.class); - HashMap offsets = new HashMap<>(); - offsets.put(OFFSET_KEY, offsetOne); - when(offsetStorageReader.offset(any(Map.class))).thenReturn(offsets); - String topic = "some_topic"; - Integer partition = 1; - TopicPartitionOffsetProvider topicPartitionOffsetProvider = new TopicPartitionOffsetProvider( - offsetStorageReader); - - //when - AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); - Optional offset = topicPartitionOffsetProvider.getOffset(azureTopicPartitionKey); - - //then - verify(offsetStorageReader).offset(azureTopicPartitionKey); - assertTrue(offset.isPresent()); - assertEquals(offsetOne, offset.get().getOffsetValue()); - } - - @Test - void azureTopicPartitionKeyShouldReturnTopicAndPartitionValues() { - //given - int partition = 10; - String topic = "topic"; - - //when - final AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); - - //then - assertEquals(partition, azureTopicPartitionKey.getPartition()); - assertEquals(topic, azureTopicPartitionKey.getTopic()); - } - -} \ No newline at end of file + void getOffsetShouldCallOffsetStorageReader() { + //given + OffsetStorageReader offsetStorageReader = mock(OffsetStorageReader.class); + TopicPartitionOffsetProvider topicPartitionOffsetProvider = + new TopicPartitionOffsetProvider( + offsetStorageReader); + String topic = "some_topic"; + Integer partition = 1; + + //when + AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); + topicPartitionOffsetProvider.getOffset(azureTopicPartitionKey); + + //then + verify(offsetStorageReader).offset(azureTopicPartitionKey); + } + + @Test + void getOffsetShouldReturnEmptyOptionalIfCommitsNotFound() { + //given + OffsetStorageReader offsetStorageReader = mock(OffsetStorageReader.class); + when(offsetStorageReader.offset(any(Map.class))).thenReturn(new HashMap()); + String topic = "some_topic"; + Integer partition = 1; + TopicPartitionOffsetProvider topicPartitionOffsetProvider = + new TopicPartitionOffsetProvider( + offsetStorageReader); + + //when + AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); + Optional offset = topicPartitionOffsetProvider.getOffset(azureTopicPartitionKey); + + //then + verify(offsetStorageReader).offset(azureTopicPartitionKey); + assertTrue(offset.isEmpty()); + } + + @Test + void getOffsetShouldReturnValidAzureOffsetMarkerIfCommitsFound() { + //given + long offsetOne = 1L; + String OFFSET_KEY = "OFFSET"; + OffsetStorageReader offsetStorageReader = mock(OffsetStorageReader.class); + HashMap offsets = new HashMap<>(); + offsets.put(OFFSET_KEY, offsetOne); + when(offsetStorageReader.offset(any(Map.class))).thenReturn(offsets); + String topic = "some_topic"; + Integer partition = 1; + TopicPartitionOffsetProvider topicPartitionOffsetProvider = + new TopicPartitionOffsetProvider( + offsetStorageReader); + + //when + AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); + Optional offset = topicPartitionOffsetProvider.getOffset(azureTopicPartitionKey); + + //then + verify(offsetStorageReader).offset(azureTopicPartitionKey); + assertTrue(offset.isPresent()); + assertEquals(offsetOne, offset.get().getOffsetValue()); + } + + @Test + void azureTopicPartitionKeyShouldReturnTopicAndPartitionValues() { + //given + int partition = 10; + String topic = "topic"; + + //when + final AzureTopicPartitionKey azureTopicPartitionKey = new AzureTopicPartitionKey(topic, partition); + + //then + assertEquals(partition, azureTopicPartitionKey.getPartition()); + assertEquals(topic, azureTopicPartitionKey.getTopic()); + } + +} diff --git a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapperTest.java b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapperTest.java index 047f940f2..591287699 100644 --- a/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapperTest.java +++ b/java-connectors/kafka-connect-azure-eventhubs/src/test/java/io/lenses/streamreactor/connect/azure/eventhubs/util/KcqlConfigTopicMapperTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -45,11 +45,12 @@ void mapInputToOutputsFromConfigForMultipleKcqlStatementsShouldRetunMapOfInputTo fullKcql.append(String.format(kcqlTemplate, newOutput, newInput)); } //when - Map inputToOutputsFromConfig = KcqlConfigTopicMapper.mapInputToOutputsFromConfig( - fullKcql.toString()); + Map inputToOutputsFromConfig = + KcqlConfigTopicMapper.mapInputToOutputsFromConfig( + fullKcql.toString()); //then - for (String input : inputToOutputsFromConfig.keySet()){ + for (String input : inputToOutputsFromConfig.keySet()) { int indexOfInput = inputs.indexOf(input); assertNotEquals(-1, indexOfInput); assertEquals(inputs.get(indexOfInput), input); @@ -62,12 +63,14 @@ void mapInputToOutputsFromConfigShouldntAllowForIllegalNames() { //given String illegalInputKcql = "INSERT INTO OUTPUT SELECT * FROM 'INPUT*_'"; String illegalOutputKcql = "INSERT INTO 'OUTPUT*_' SELECT * FROM INPUT"; - String inputErrorMessage = "Input topic INPUT*_, name is not correctly specified " - + "(It can contain only letters, numbers and hyphens, underscores and " - + "dots and has to start with number or letter"; - String outputErrorMessage = "Output topic OUTPUT*_, name is not correctly specified " - + "(It can contain only letters, numbers and hyphens, underscores and " - + "dots and has to start with number or letter"; + String inputErrorMessage = + "Input topic INPUT*_, name is not correctly specified " + + "(It can contain only letters, numbers and hyphens, underscores and " + + "dots and has to start with number or letter"; + String outputErrorMessage = + "Output topic OUTPUT*_, name is not correctly specified " + + "(It can contain only letters, numbers and hyphens, underscores and " + + "dots and has to start with number or letter"; //when mapInputToOutputAddertingExceptionWithSpecificMessage(illegalInputKcql, inputErrorMessage); @@ -105,4 +108,4 @@ private static void mapInputToOutputAddertingExceptionWithSpecificMessage(String assertThrows(ConfigException.class, () -> KcqlConfigTopicMapper.mapInputToOutputsFromConfig(illegalKcql), expectedMessage); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-common/build.gradle b/java-connectors/kafka-connect-common/build.gradle index d4f372950..e5fd180cf 100644 --- a/java-connectors/kafka-connect-common/build.gradle +++ b/java-connectors/kafka-connect-common/build.gradle @@ -14,4 +14,4 @@ project(":kafka-connect-common") { // implementation group: 'io.confluent', name: 'kafka-connect-avro-data', version: apacheToConfluentVersionAxis.get(kafkaVersion) // implementation group: 'io.confluent', name: 'kafka-connect-protobuf-converter', version: apacheToConfluentVersionAxis.get(kafkaVersion) } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/BaseConfig.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/BaseConfig.java index 6259e23d5..8ee6d5420 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/BaseConfig.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/BaseConfig.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java index 47a253472..21f2c3923 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/ConfigSettings.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -25,19 +25,19 @@ */ public interface ConfigSettings { - /** - * Adds the settings defined by this interface to the provided {@code ConfigDef}. - * - * @param configDef the {@code ConfigDef} to which settings should be added - * @return the updated {@code ConfigDef} with added settings - */ - ConfigDef withSettings(ConfigDef configDef); + /** + * Adds the settings defined by this interface to the provided {@code ConfigDef}. + * + * @param configDef the {@code ConfigDef} to which settings should be added + * @return the updated {@code ConfigDef} with added settings + */ + ConfigDef withSettings(ConfigDef configDef); - /** - * Parses settings from the specified {@code ConfigSource} and materializes an object of type {@code M}. - * - * @param configSource the {@code ConfigSource} containing configuration settings - * @return an object of type {@code M} materialized from the given {@code ConfigSource} - */ - M parseFromConfig(ConfigSource configSource); + /** + * Parses settings from the specified {@code ConfigSource} and materializes an object of type {@code M}. + * + * @param configSource the {@code ConfigSource} containing configuration settings + * @return an object of type {@code M} materialized from the given {@code ConfigSource} + */ + M parseFromConfig(ConfigSource configSource); } diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java index 3058dc643..a96e64c5f 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/RetryConfig.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java index 3fb23c5e1..d9014cb9f 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectionConfig.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectorPrefixed.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectorPrefixed.java index 69d977014..63ffb1c02 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectorPrefixed.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/intf/ConnectorPrefixed.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -19,5 +19,6 @@ * Represents classes that has a Connector prefix (mostly Configurations). */ public interface ConnectorPrefixed { + String connectorPrefix(); } diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java index c87e60f43..65c3da417 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/base/model/ConnectorPrefix.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -20,10 +20,10 @@ @AllArgsConstructor public class ConnectorPrefix { - private final String prefix; + private final String prefix; - public String prefixKey(String suffix) { - return String.format("%s.%s", prefix, suffix); - } + public String prefixKey(String suffix) { + return String.format("%s.%s", prefix, suffix); + } } diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java index 25d70bd8e..64f5130df 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigSource.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -23,6 +23,7 @@ * Implementations of this interface provide methods to retrieve property values based on specific key types. */ public interface ConfigSource { + /** * Retrieves a String property value associated with the given key. * diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java index f578f57c5..2dccc6177 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSource.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -21,7 +21,8 @@ import org.apache.kafka.common.config.types.Password; /** - * A wrapper for Kafka Connect properties stored in the `AbstractConfig` that provides methods to retrieve property values. + * A wrapper for Kafka Connect properties stored in the `AbstractConfig` that provides methods to retrieve property + * values. */ @AllArgsConstructor public class ConfigWrapperSource implements ConfigSource { diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/MapConfigSource.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/MapConfigSource.java index 66ab9c316..a337fd63c 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/MapConfigSource.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/config/source/MapConfigSource.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/ConnectorStartupException.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/ConnectorStartupException.java index 01488d01e..c303a16f1 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/ConnectorStartupException.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/ConnectorStartupException.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/InputStreamExtractionException.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/InputStreamExtractionException.java index a3cf1ee4c..644b9914b 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/InputStreamExtractionException.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/exception/InputStreamExtractionException.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/AsciiArtPrinter.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/AsciiArtPrinter.java index 854da5f20..f8515c7fc 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/AsciiArtPrinter.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/AsciiArtPrinter.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -32,13 +32,14 @@ public class AsciiArtPrinter { * Method fetches ASCII art and logs it. If it cannot display ASCII art then it logs a * warning with cause. * - * @param jarManifest JarManifest of Connector + * @param jarManifest JarManifest of Connector * @param asciiArtResource URI to ASCII art */ public static void printAsciiHeader(JarManifest jarManifest, String asciiArtResource) { try { - Optional asciiArtStream = ofNullable( - AsciiArtPrinter.class.getResourceAsStream(asciiArtResource)); + Optional asciiArtStream = + ofNullable( + AsciiArtPrinter.class.getResourceAsStream(asciiArtResource)); asciiArtStream.ifPresent(inputStream -> log.info(InputStreamHandler.extractString(inputStream))); } catch (InputStreamExtractionException exception) { log.warn("Unable display ASCIIArt from input stream.", exception); diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/InputStreamHandler.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/InputStreamHandler.java index 453fed129..355821f8b 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/InputStreamHandler.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/InputStreamHandler.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -42,7 +42,7 @@ public static String extractString(InputStream inputStream) { char[] buffer = new char[bufferSize]; StringBuilder out = new StringBuilder(); try (Reader in = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { - for (int numRead; (numRead = in.read(buffer, 0, buffer.length)) > 0; ) { + for (int numRead; (numRead = in.read(buffer, 0, buffer.length)) > 0;) { out.append(buffer, 0, numRead); } } catch (IOException ioException) { diff --git a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java index d59505931..aa96cb1a4 100644 --- a/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java +++ b/java-connectors/kafka-connect-common/src/main/java/io/lenses/streamreactor/common/util/JarManifest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -51,6 +51,7 @@ public class JarManifest { /** * Creates JarManifest. + * * @param location Jar file location */ public JarManifest(URL location) { @@ -69,6 +70,7 @@ public JarManifest(URL location) { /** * Creates JarManifest. + * * @param jarFile */ public JarManifest(JarFile jarFile) { @@ -86,8 +88,8 @@ public JarManifest(JarFile jarFile) { private Map extractMainAttributes(Attributes mainAttributes) { return Collections.unmodifiableMap(Arrays.stream(ManifestAttributes.values()) .collect(Collectors.toMap(ManifestAttributes::getAttributeName, - manifestAttribute -> - ofNullable(mainAttributes.getValue(manifestAttribute.getAttributeName())).orElse(UNKNOWN)) + manifestAttribute -> ofNullable(mainAttributes.getValue(manifestAttribute.getAttributeName())).orElse( + UNKNOWN)) )); } @@ -116,6 +118,7 @@ public String buildManifestString() { * Enum that represents StreamReactor's important parameters from Manifest file. */ public enum ManifestAttributes { + REACTOR_VER("StreamReactor-Version"), KAFKA_VER("Kafka-Version"), GIT_REPO("Git-Repo"), diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java index cdaec6776..34f5f0afe 100644 --- a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigSourceTestBase.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java index ddd22ebfd..c06307d06 100644 --- a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/ConfigWrapperSourceTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java index d8452732d..200c0c8cb 100644 --- a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/config/source/MapConfigSourceTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/AsciiArtPrinterTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/AsciiArtPrinterTest.java index 188d6ab13..5a1cd4985 100644 --- a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/AsciiArtPrinterTest.java +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/AsciiArtPrinterTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -76,4 +76,4 @@ void printAsciiHeaderShouldPrintOnlyJarManifestIfCouldntMakeInpurStreamToAscii() assertEquals(1, logWatcher.list.size()); assertTrue(logWatcher.list.get(0).getFormattedMessage().equals(SOME_MANIFEST_DATA)); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java index eff71797b..6c74b0d7e 100644 --- a/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java +++ b/java-connectors/kafka-connect-common/src/test/java/io/lenses/streamreactor/common/util/JarManifestTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -92,4 +92,4 @@ void getVersionShouldReturnDefaultIfFileProvidedIsNotJar() { //then assertThat(testObj.getVersion()).isEqualTo(EMPTY_STRING); } -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java index 9e0ab6e27..7b7712164 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPConnectionConfig.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java index d94929c38..e4732e7f1 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurer.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -40,17 +40,16 @@ public class GCPServiceBuilderConfigurer { * * @param Type representing the GCP service interface (e.g., Storage, BigQuery) * @param Type representing the service options (e.g., StorageOptions, BigQueryOptions) - * @param Type representing the service options builder (e.g., StorageOptions.Builder, BigQueryOptions.Builder) - * @param config The GCP connection configuration containing settings such as host, project ID, and authentication details. + * @param Type representing the service options builder (e.g., StorageOptions.Builder, + * BigQueryOptions.Builder) + * @param config The GCP connection configuration containing settings such as host, project ID, and authentication + * details. * @param builder The builder instance of the GCP service client options. * @return The configured builder instance with updated settings. * @throws IOException if an error occurs during configuration, such as credential retrieval. */ - public static < - X extends Service, - Y extends ServiceOptions, - B extends ServiceOptions.Builder> - B configure(GCPConnectionConfig config, B builder) throws IOException { + public static , Y extends ServiceOptions, B extends ServiceOptions.Builder> B configure( + GCPConnectionConfig config, B builder) throws IOException { Optional.ofNullable(config.getHost()).ifPresent(builder::setHost); @@ -58,18 +57,19 @@ B configure(GCPConnectionConfig config, B builder) throws IOException { Optional.ofNullable(config.getQuotaProjectId()).ifPresent(builder::setQuotaProjectId); - val authMode = config.getAuthMode() - .orElseThrow(createConfigException("AuthMode has to be configured by setting x.y.z property")); + val authMode = + config.getAuthMode() + .orElseThrow(createConfigException("AuthMode has to be configured by setting x.y.z property")); builder.setCredentials(authMode.getCredentials()); builder.setRetrySettings(createRetrySettings( - config.getHttpRetryConfig() - .orElseThrow(createConfigException("RetrySettings has to be configured by setting a.b")))); + config.getHttpRetryConfig() + .orElseThrow(createConfigException("RetrySettings has to be configured by setting a.b")))); createTransportOptions(config.getTimeouts() - .orElseThrow(createConfigException("TransportOptions have to be configured by setting c.d"))) - .ifPresent(builder::setTransportOptions); + .orElseThrow(createConfigException("TransportOptions have to be configured by setting c.d"))) + .ifPresent(builder::setTransportOptions); return builder; } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java index 1b53c9b3d..cfc56c508 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/HttpTimeoutConfig.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -23,6 +23,7 @@ @Builder @AllArgsConstructor public class HttpTimeoutConfig { + private Long socketTimeoutMillis; private Long connectionTimeoutMillis; } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java index cd335f000..ad9dc790c 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/AuthMode.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java index 953632a3c..2b1df0b6d 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthMode.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -31,6 +31,7 @@ @EqualsAndHashCode @ToString public class CredentialsAuthMode implements AuthMode { + private final Password passwordCredentials; @Override diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java index 520ae8d38..d73bc0c2e 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthMode.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -23,9 +23,12 @@ /** * Default authentication mode without explicit credentials. * This mode utilizes the Application Default Credentials (ADC) chain. - * ADC is a strategy used by the Google authentication libraries to automatically find credentials based on the application environment. - * The credentials are made available to Cloud Client Libraries and Google API Client Libraries, allowing the code to run seamlessly - * in both development and production environments without altering the authentication process for Google Cloud services and APIs. + * ADC is a strategy used by the Google authentication libraries to automatically find credentials based on the + * application environment. + * The credentials are made available to Cloud Client Libraries and Google API Client Libraries, allowing the code to + * run seamlessly + * in both development and production environments without altering the authentication process for Google Cloud services + * and APIs. */ @EqualsAndHashCode public class DefaultAuthMode implements AuthMode { diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java index e6cba5775..ed1a8d29c 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthMode.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java index 916a605f6..5613ee7a2 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthMode.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java index 21603a787..9f28fe049 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettings.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -27,7 +27,8 @@ /** * Configuration settings for specifying authentication mode and related credentials for GCP connectors. - * This class provides methods to define and parse authentication mode settings based on Kafka Connect's {@link ConfigDef}. + * This class provides methods to define and parse authentication mode settings based on Kafka Connect's + * {@link ConfigDef}. * * Authentication modes supported: * - 'credentials': Use GCP credentials for authentication. @@ -105,7 +106,8 @@ public ConfigDef withSettings(ConfigDef configDef) { * @return The parsed AuthMode based on the configuration settings. * @throws ConfigException If an invalid or unsupported authentication mode is specified. */ - @Override public AuthMode parseFromConfig(ConfigSource configSource) { + @Override + public AuthMode parseFromConfig(ConfigSource configSource) { return configSource .getString(getAuthModeKey()) .map( @@ -134,9 +136,8 @@ private FileAuthMode createFileAuthMode(ConfigSource configSource) { .filter(file -> !(file.isEmpty())) .map(FileAuthMode::new) .orElseThrow( - () -> - new ConfigException( - String.format("No `%s` specified in configuration", getFileKey()))); + () -> new ConfigException( + String.format("No `%s` specified in configuration", getFileKey()))); } private CredentialsAuthMode createCredentialsAuthMode(ConfigSource configAdaptor) { @@ -145,8 +146,7 @@ private CredentialsAuthMode createCredentialsAuthMode(ConfigSource configAdaptor .filter(password -> !(password.value().isEmpty())) .map(CredentialsAuthMode::new) .orElseThrow( - () -> - new ConfigException( - String.format("No `%s` specified in configuration", getCredentialsKey()))); + () -> new ConfigException( + String.format("No `%s` specified in configuration", getCredentialsKey()))); } } diff --git a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java index 747e85987..3d9eb7ce7 100644 --- a/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java +++ b/java-connectors/kafka-connect-gcp-common/src/main/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettings.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java index e609af465..f6acbe522 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/GCPServiceBuilderConfigurerTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java index 3fe99c709..bec4777b5 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/TestService.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -23,6 +23,7 @@ import java.util.Set; class TestService extends BaseService { + protected TestService(TestSvcServiceOptions options) { super(options); } @@ -50,8 +51,7 @@ public > B toBuilder() } class TestSvcServiceOptionsBuilder - extends ServiceOptions.Builder< - TestService, TestSvcServiceOptions, TestSvcServiceOptionsBuilder> { + extends ServiceOptions.Builder { @Override protected ServiceOptions build() { diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java index 53595a389..0c6e0fb2e 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/CredentialsAuthModeTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java index cb30b697e..dbca2bcc7 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/DefaultAuthModeTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java index 2ddcb5225..3f6cab3fc 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/FileAuthModeTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java index cb112d939..a42a0ffac 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/NoAuthModeTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java index 957fe150b..1272445ea 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/auth/mode/TestFileUtil.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java index 22de40a65..48c9d0d09 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/AuthModeSettingsTest.java @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java index c713e7d5b..d543b4eec 100644 --- a/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java +++ b/java-connectors/kafka-connect-gcp-common/src/test/java/io/lenses/streamreactor/connect/gcp/common/config/GCPSettingsTest.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -52,7 +52,7 @@ void testHttpRetryConfig(String testName, Object retries, Object interval, Retry val optionalRetryConfig = gcpSettings.parseFromConfig(configMap).getHttpRetryConfig(); assertThat(optionalRetryConfig) - .isPresent() - .contains(expected); + .isPresent() + .contains(expected); } } diff --git a/java-connectors/kafka-connect-query-language/build.gradle b/java-connectors/kafka-connect-query-language/build.gradle index 780a8e5ae..48d732b31 100644 --- a/java-connectors/kafka-connect-query-language/build.gradle +++ b/java-connectors/kafka-connect-query-language/build.gradle @@ -50,8 +50,8 @@ project(":kafka-connect-query-language") { } //order should be: generateGrammarSource -> updateLicenseMain -> compileJava - checkLicenseMain.dependsOn("generateGrammarSource") + //spotlessApply.dependsOn("generateGrammarSource") task compile(dependsOn: 'compileJava') task fatJarNoTest(dependsOn: 'shadowJar') -} \ No newline at end of file +} diff --git a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Field.java b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Field.java index b36daa6db..e11cc3e22 100644 --- a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Field.java +++ b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Field.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -15,144 +15,147 @@ */ package io.lenses.kcql; - import java.util.ArrayList; import java.util.List; public class Field { - private final String name; - private final String alias; - private final FieldType fieldType; - private final List parentFields; - public Field(String name, String alias, FieldType fieldType) { - this(name, alias, fieldType, null); - } + private final String name; + private final String alias; + private final FieldType fieldType; + private final List parentFields; - public Field(String name, FieldType fieldType, List parents) { - this(name, name, fieldType, parents); - } + public Field(String name, String alias, FieldType fieldType) { + this(name, alias, fieldType, null); + } - public Field(String name, String alias, FieldType fieldType, List parents) { - if (name == null || name.trim().length() == 0) { - throw new IllegalArgumentException(String.format("field is not valid:<%s>", String.valueOf(name))); - } - if (alias == null || alias.trim().length() == 0) { - throw new IllegalArgumentException(String.format("alias is not valid:<%s>", String.valueOf(alias))); - } - this.name = name; - this.alias = alias; - this.fieldType = fieldType; - this.parentFields = parents; - } + public Field(String name, FieldType fieldType, List parents) { + this(name, name, fieldType, parents); + } - public String getName() { - return name; + public Field(String name, String alias, FieldType fieldType, List parents) { + if (name == null || name.trim().length() == 0) { + throw new IllegalArgumentException(String.format("field is not valid:<%s>", String.valueOf(name))); } - - public String getAlias() { - return alias; + if (alias == null || alias.trim().length() == 0) { + throw new IllegalArgumentException(String.format("alias is not valid:<%s>", String.valueOf(alias))); } - - public boolean hasParents() { - return parentFields != null; + this.name = name; + this.alias = alias; + this.fieldType = fieldType; + this.parentFields = parents; + } + + public String getName() { + return name; + } + + public String getAlias() { + return alias; + } + + public boolean hasParents() { + return parentFields != null; + } + + public List getParentFields() { + if (parentFields == null) + return null; + return new ArrayList<>(parentFields); + } + + public String toString() { + if (parentFields == null || parentFields.isEmpty()) + return name; + StringBuilder sb = new StringBuilder(parentFields.get(0)); + for (int i = 1; i < parentFields.size(); ++i) { + sb.append("."); + sb.append(parentFields.get(i)); } + sb.append("."); + sb.append(name); + return sb.toString(); + } + + public static Field from(String name, List parents) { + return from(name, null, parents); + } + + public static Field from(String name, String alias, List parents) { + if (parents != null) { + if (UNDERSCORE.equals(parents.get(0))) { + switch (name.toLowerCase()) { + + case TOPIC: + if (alias != null) { + return new Field(TOPIC, alias, FieldType.TOPIC); + } + return new Field(TOPIC, TOPIC, FieldType.TOPIC); - public List getParentFields() { - if (parentFields == null) return null; - return new ArrayList<>(parentFields); - } + case OFFSET: + if (alias != null) { + return new Field(OFFSET, alias, FieldType.OFFSET); + } - public String toString() { - if (parentFields == null || parentFields.isEmpty()) return name; - StringBuilder sb = new StringBuilder(parentFields.get(0)); - for (int i = 1; i < parentFields.size(); ++i) { - sb.append("."); - sb.append(parentFields.get(i)); - } - sb.append("."); - sb.append(name); - return sb.toString(); - } + return new Field(OFFSET, OFFSET, FieldType.OFFSET); - public static Field from(String name, List parents) { - return from(name, null, parents); - } + case TIMESTAMP: + if (alias != null) { + return new Field(TIMESTAMP, alias, FieldType.TIMESTAMP); + } + return new Field(TIMESTAMP, TIMESTAMP, FieldType.TIMESTAMP); + + case PARTITION: + if (alias != null) { + return new Field(PARTITION, alias, FieldType.PARTITION); + } + return new Field(PARTITION, PARTITION, FieldType.PARTITION); - public static Field from(String name, String alias, List parents) { - if (parents != null) { - if (UNDERSCORE.equals(parents.get(0))) { - switch (name.toLowerCase()) { - - case TOPIC: - if (alias != null) { - return new Field(TOPIC, alias, FieldType.TOPIC); - } - return new Field(TOPIC, TOPIC, FieldType.TOPIC); - - case OFFSET: - if (alias != null) { - return new Field(OFFSET, alias, FieldType.OFFSET); - } - - return new Field(OFFSET, OFFSET, FieldType.OFFSET); - - case TIMESTAMP: - if (alias != null) { - return new Field(TIMESTAMP, alias, FieldType.TIMESTAMP); - } - return new Field(TIMESTAMP, TIMESTAMP, FieldType.TIMESTAMP); - - case PARTITION: - if (alias != null) { - return new Field(PARTITION, alias, FieldType.PARTITION); - } - return new Field(PARTITION, PARTITION, FieldType.PARTITION); - - default: - if (parents.size() <= 1 || !"key".equals(parents.get(1).toLowerCase())) { - throw new IllegalArgumentException(String.format("Invalid syntax. '_' needs to be followed by: key,%s,%s,%s,%s", TOPIC, PARTITION, TIMESTAMP, OFFSET)); - } - - if (parents.size() <= 2) { - if (alias != null) { - if ("*".equals(name)) { - throw new IllegalArgumentException("You can't alias '*'."); - } - return new Field(name, alias, FieldType.KEY, null); - } - return new Field(name, FieldType.KEY, null); - } - - List parentsCopy = new ArrayList<>(); - for (int i = 2; i < parents.size(); ++i) { - parentsCopy.add(parents.get(i)); - } - if (alias != null) { - return new Field(name, alias, FieldType.KEY, parentsCopy); - } - return new Field(name, FieldType.KEY, parentsCopy); + default: + if (parents.size() <= 1 || !"key".equals(parents.get(1).toLowerCase())) { + throw new IllegalArgumentException(String.format( + "Invalid syntax. '_' needs to be followed by: key,%s,%s,%s,%s", TOPIC, PARTITION, TIMESTAMP, OFFSET)); + } + if (parents.size() <= 2) { + if (alias != null) { + if ("*".equals(name)) { + throw new IllegalArgumentException("You can't alias '*'."); } + return new Field(name, alias, FieldType.KEY, null); + } + return new Field(name, FieldType.KEY, null); } - } - List parentsCopy = null; - if (parents != null) { - parentsCopy = new ArrayList<>(parents); - } - if (alias != null) { - return new Field(name, alias, FieldType.VALUE, parentsCopy); - } - return new Field(name, FieldType.VALUE, parentsCopy); - } - private static final String UNDERSCORE = "_"; - private static final String OFFSET = "offset"; - private static final String TOPIC = "topic"; - private static final String PARTITION = "partition"; - private static final String TIMESTAMP = "timestamp"; + List parentsCopy = new ArrayList<>(); + for (int i = 2; i < parents.size(); ++i) { + parentsCopy.add(parents.get(i)); + } + if (alias != null) { + return new Field(name, alias, FieldType.KEY, parentsCopy); + } + return new Field(name, FieldType.KEY, parentsCopy); - public FieldType getFieldType() { - return fieldType; + } + } + } + List parentsCopy = null; + if (parents != null) { + parentsCopy = new ArrayList<>(parents); + } + if (alias != null) { + return new Field(name, alias, FieldType.VALUE, parentsCopy); } + return new Field(name, FieldType.VALUE, parentsCopy); + } + + private static final String UNDERSCORE = "_"; + private static final String OFFSET = "offset"; + private static final String TOPIC = "topic"; + private static final String PARTITION = "partition"; + private static final String TIMESTAMP = "timestamp"; + + public FieldType getFieldType() { + return fieldType; + } } diff --git a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FieldType.java b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FieldType.java index 8420f3ade..35a3a2ba1 100644 --- a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FieldType.java +++ b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FieldType.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -15,22 +15,22 @@ */ package io.lenses.kcql; - public enum FieldType { - KEY("KEY"), - OFFSET("OFFSET"), - PARTITION("PARTITION"), - TIMESTAMP("TIMESTAMP"), - TOPIC("TOPIC"), - VALUE("VALUE"); - private final String value; + KEY("KEY"), + OFFSET("OFFSET"), + PARTITION("PARTITION"), + TIMESTAMP("TIMESTAMP"), + TOPIC("TOPIC"), + VALUE("VALUE"); + + private final String value; - FieldType(String value) { - this.value = value; - } + FieldType(String value) { + this.value = value; + } - public String getValue() { - return value; - } + public String getValue() { + return value; + } } diff --git a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FormatType.java b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FormatType.java index 058a044ba..550db7d38 100644 --- a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FormatType.java +++ b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/FormatType.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -15,7 +15,6 @@ */ package io.lenses.kcql; - public enum FormatType { AVRO, PROTOBUF, diff --git a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Kcql.java b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Kcql.java index fbd1ca8ef..88a7711a1 100644 --- a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Kcql.java +++ b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Kcql.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -25,712 +25,710 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; - /** * Parsing support for Kafka Connect Query Language. */ public class Kcql { - public static final String TIMESTAMP = "sys_time()"; - private static final String MSG_ILLEGAL_FIELD_ALIAS = "Illegal fieldAlias."; - private String query; - private boolean autoCreate; - private boolean autoEvolve; - private WriteModeEnum writeMode; - private String source; - private String target; - private String docType; - private String indexSuffix; - private String incrementalMode; - private final List fields = new ArrayList<>(); - private final List keyFields = new ArrayList<>(); - private final List headerFields = new ArrayList<>(); - private final List ignoredFields = new ArrayList<>(); - private final List primaryKeys = new ArrayList<>(); - private final List partitionBy = new ArrayList<>(); - private int limit = 0; - private int batchSize; - private String timestamp; - private String storedAs; - private final Map storedAsParameters = new HashMap<>(); - private FormatType formatType = null; - private boolean unwrapping = false; - private List tags; - private boolean retainStructure = false; - private String withConverter; - private long ttl; - private String withType; - private String withJmsSelector; - private String dynamicTarget; - private List withKeys = null; - private String keyDelimiter = "."; - private TimeUnit timestampUnit = TimeUnit.MILLISECONDS; - private String pipeline; - private String subscription; - private String withRegex; - - private final Map properties = new HashMap<>(); - - public String getQuery() { - return query; - } - - public void setQuery(String query) { - this.query = query; - } - - public String getWithSubscription() { - return this.subscription; - } - - public void SetWithSubscription(String name) { - this.subscription = name; - } - - public void setTTL(long ttl) { - this.ttl = ttl; - } - - public long getTTL() { - return this.ttl; - } - - private void addField(final Field field) { - if (field == null) { - throw new IllegalArgumentException(MSG_ILLEGAL_FIELD_ALIAS); + public static final String TIMESTAMP = "sys_time()"; + private static final String MSG_ILLEGAL_FIELD_ALIAS = "Illegal fieldAlias."; + private String query; + private boolean autoCreate; + private boolean autoEvolve; + private WriteModeEnum writeMode; + private String source; + private String target; + private String docType; + private String indexSuffix; + private String incrementalMode; + private final List fields = new ArrayList<>(); + private final List keyFields = new ArrayList<>(); + private final List headerFields = new ArrayList<>(); + private final List ignoredFields = new ArrayList<>(); + private final List primaryKeys = new ArrayList<>(); + private final List partitionBy = new ArrayList<>(); + private int limit = 0; + private int batchSize; + private String timestamp; + private String storedAs; + private final Map storedAsParameters = new HashMap<>(); + private FormatType formatType = null; + private boolean unwrapping = false; + private List tags; + private boolean retainStructure = false; + private String withConverter; + private long ttl; + private String withType; + private String withJmsSelector; + private String dynamicTarget; + private List withKeys = null; + private String keyDelimiter = "."; + private TimeUnit timestampUnit = TimeUnit.MILLISECONDS; + private String pipeline; + private String subscription; + private String withRegex; + + private final Map properties = new HashMap<>(); + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public String getWithSubscription() { + return this.subscription; + } + + public void SetWithSubscription(String name) { + this.subscription = name; + } + + public void setTTL(long ttl) { + this.ttl = ttl; + } + + public long getTTL() { + return this.ttl; + } + + private void addField(final Field field) { + if (field == null) { + throw new IllegalArgumentException(MSG_ILLEGAL_FIELD_ALIAS); + } + if (fieldExists(field)) { + throw new IllegalArgumentException(String.format("Field %s has already been defined", field.getName())); + } + fields.add(field); + } + + private void addKeyField(final Field field) { + if (field == null) { + throw new IllegalArgumentException(MSG_ILLEGAL_FIELD_ALIAS); + } + if (fieldExists(field)) { + throw new IllegalArgumentException(String.format("Key field %s has already been defined", field.getName())); + } + keyFields.add(field); + } + + private void addHeaderField(final Field field) { + if (field == null) { + throw new IllegalArgumentException(MSG_ILLEGAL_FIELD_ALIAS); + } + if (fieldExists(field)) { + throw new IllegalArgumentException(String.format("Header field %s has already been defined", field.getName())); + } + headerFields.add(field); + } + + private boolean fieldExists(final Field newField) { + for (Field field : fields) { + if (!field.getName().equals(newField.getName()) || + !field.getFieldType().equals(newField.getFieldType())) { + continue; + } + if (!field.hasParents() && !newField.hasParents()) { + return true; + } + if (field.hasParents() && newField.hasParents()) { + if (field.getParentFields().equals(newField.hasParents())) { + return true; } - if (fieldExists(field)) { - throw new IllegalArgumentException(String.format("Field %s has already been defined", field.getName())); - } - fields.add(field); + } } + return false; + } - - private void addKeyField(final Field field) { - if (field == null) { - throw new IllegalArgumentException(MSG_ILLEGAL_FIELD_ALIAS); - } - if (fieldExists(field)) { - throw new IllegalArgumentException(String.format("Key field %s has already been defined", field.getName())); - } - keyFields.add(field); + private void addPartitionByField(final String field) { + if (field == null || field.trim().length() == 0) { + throw new IllegalArgumentException("Invalid partition by field"); } - - private void addHeaderField(final Field field) { - if (field == null) { - throw new IllegalArgumentException(MSG_ILLEGAL_FIELD_ALIAS); - } - if (fieldExists(field)) { - throw new IllegalArgumentException(String.format("Header field %s has already been defined", field.getName())); - } - headerFields.add(field); + for (final String f : partitionBy) { + if (f.compareToIgnoreCase(field.trim()) == 0) { + throw new IllegalArgumentException(String.format("The field %s appears twice", field)); + } } + partitionBy.add(field.trim()); + } + public String getSource() { + return source; + } - private boolean fieldExists(final Field newField) { - for (Field field : fields) { - if (!field.getName().equals(newField.getName()) || - !field.getFieldType().equals(newField.getFieldType())) { - continue; - } - if (!field.hasParents() && !newField.hasParents()) { - return true; - } - if (field.hasParents() && newField.hasParents()) { - if (field.getParentFields().equals(newField.hasParents())) { - return true; - } - } - } - return false; - } - - private void addPartitionByField(final String field) { - if (field == null || field.trim().length() == 0) { - throw new IllegalArgumentException("Invalid partition by field"); - } - for (final String f : partitionBy) { - if (f.compareToIgnoreCase(field.trim()) == 0) { - throw new IllegalArgumentException(String.format("The field %s appears twice", field)); - } - } - partitionBy.add(field.trim()); - } + public String getTarget() { + return target; + } - public String getSource() { - return source; - } + public List getFields() { + return fields; + } - public String getTarget() { - return target; - } + public List getKeyFields() { + return keyFields; + } - public List getFields() { - return fields; - } + public List getHeaderFields() { + return headerFields; + } - public List getKeyFields() { - return keyFields; - } + public List getIgnoredFields() { + return ignoredFields; + } - public List getHeaderFields() { - return headerFields; - } + public WriteModeEnum getWriteMode() { + return writeMode; + } - public List getIgnoredFields() { - return ignoredFields; - } + public List getPrimaryKeys() { + return primaryKeys; + } - public WriteModeEnum getWriteMode() { - return writeMode; - } + public String getTimestamp() { + return this.timestamp; + } - public List getPrimaryKeys() { - return primaryKeys; - } + public String getStoredAs() { + return storedAs; + } - public String getTimestamp() { - return this.timestamp; - } + public Map getStoredAsParameters() { + return storedAsParameters; + } - public String getStoredAs() { - return storedAs; - } + public Map getProperties() { + return properties; + } - public Map getStoredAsParameters() { - return storedAsParameters; - } + public FormatType getFormatType() { + return formatType; + } + + public boolean isAutoCreate() { + return autoCreate; + } + + public int getLimit() { + return limit; + } + + public boolean isAutoEvolve() { + return autoEvolve; + } + + public int getBatchSize() { + return batchSize; + } + + public Iterator getPartitionBy() { + return partitionBy.iterator(); + } + + public List getTags() { + return tags; + } + + public List getWithKeys() { + return withKeys; + } + + public String getKeyDelimeter() { + return keyDelimiter; + } + + public boolean hasRetainStructure() { + return retainStructure; + } + + public boolean isUnwrapping() { + return unwrapping; + } + + public String getWithType() { + return this.withType; + } + + public String getIncrementalMode() { + return this.incrementalMode; + } + + public String getDocType() { + return this.docType; + } + + public String getIndexSuffix() { + return this.indexSuffix; + } + + public String getWithConverter() { + return withConverter; + } + + public String getWithJmsSelector() { + return withJmsSelector; + } + + public String getPipeline() { + return pipeline; + } + + public String getWithRegex() { + return withRegex; + } + + private void setWithRegex(String withRegex) { + this.withRegex = withRegex; + } + + private void setDynamicTarget(String dynamicTarget) { + this.dynamicTarget = dynamicTarget; + } + + public String getDynamicTarget() { + return dynamicTarget; + } + + public TimeUnit getTimestampUnit() { + return timestampUnit; + } + + private void setTimestampUnit(TimeUnit timestampUnit) { + this.timestampUnit = timestampUnit; + } + + /** + * Parses (check parse method) multiple KCQL statements delimited by semicolon. + * + * @param kcqlStatements + * @return + */ + public static List parseMultiple(final String kcqlStatements) { + return Arrays.stream(kcqlStatements.split(";")).map(Kcql::parse).collect(Collectors.toList()); + } + + public static Kcql parse(final String syntax) { + final ConnectorLexer lexer = new ConnectorLexer(CharStreams.fromString(syntax)); + final CommonTokenStream tokens = new CommonTokenStream(lexer); + final ConnectorParser parser = new ConnectorParser(tokens); + final ArrayList nestedFieldsBuffer = new ArrayList<>(); + final Kcql kcql = new Kcql(); + kcql.setQuery(syntax); + parser.addErrorListener(new BaseErrorListener() { + + @Override + public void syntaxError(Recognizer recognizer, + Object offendingSymbol, + int line, + int charPositionInLine, + String msg, + RecognitionException e) { + throw new IllegalStateException("failed to parse at line " + line + " due to " + msg, e); + } + }); + + final String[] storedAsParameter = {null}; + + final boolean[] isWithinIgnore = {false}; + + final String[] tagValue = {null}; + final String[] tagKey = {null}; + + parser.addParseListener(new ConnectorParserBaseListener() { + + @Override + public void exitWith_subscription_value(ConnectorParser.With_subscription_valueContext ctx) { + kcql.subscription = unescape(ctx.getText()); + } + + @Override + public void exitColumn(ConnectorParser.ColumnContext ctx) { + for (TerminalNode tn : ctx.FIELD()) { + nestedFieldsBuffer.add(tn.getText()); + } + if (ctx.ASTERISK() != null) { + nestedFieldsBuffer.add("*"); + } + } - public Map getProperties() { - return properties; - } + @Override + public void exitWith_unwrap_clause(ConnectorParser.With_unwrap_clauseContext ctx) { + kcql.unwrapping = true; + } - public FormatType getFormatType() { - return formatType; - } + @Override + public void exitWith_type_value(ConnectorParser.With_type_valueContext ctx) { + kcql.withType = unescape(ctx.getText()); + } - public boolean isAutoCreate() { - return autoCreate; - } + @Override + public void exitWith_structure(ConnectorParser.With_structureContext ctx) { + kcql.retainStructure = true; + } - public int getLimit() { - return limit; - } + @Override + public void exitLimit_value(ConnectorParser.Limit_valueContext ctx) { + try { + int limit = Integer.parseInt(ctx.INT().getText()); + if (limit < 1) + throw new IllegalArgumentException("Invalid limit specified. Needs to be an integer greater than zero"); + kcql.limit = limit; + } catch (NumberFormatException nfe) { + throw new IllegalArgumentException("Invalid limit specified(" + ctx.INT().getText() + + "). Needs to be an integer greater than zero"); + } + } + + @Override + public void enterColumn_name(ConnectorParser.Column_nameContext ctx) { + nestedFieldsBuffer.clear(); + } + + @Override + public void exitColumn_name(ConnectorParser.Column_nameContext ctx) { + super.exitColumn_name(ctx); + if (ctx.ASTERISK() != null) { + Field field = new Field("*", FieldType.VALUE, null); + kcql.addField(field); + return; + } - public boolean isAutoEvolve() { - return autoEvolve; - } + List parentFields = null; + String name = nestedFieldsBuffer.get(nestedFieldsBuffer.size() - 1); + nestedFieldsBuffer.remove(nestedFieldsBuffer.size() - 1); - public int getBatchSize() { - return batchSize; - } + if (!nestedFieldsBuffer.isEmpty()) { + parentFields = nestedFieldsBuffer; + } - public Iterator getPartitionBy() { - return partitionBy.iterator(); - } + Field field; + if (ctx.column_name_alias() != null) { + field = Field.from(name, ctx.column_name_alias().getText(), parentFields); + } else { + field = Field.from(name, parentFields); + } - public List getTags() { - return tags; - } + if (isWithinIgnore[0]) { + kcql.ignoredFields.add(field); + } else { + List cleanedParent = null; + + if (field.toString().startsWith("_key.")) { + trimParentField(nestedFieldsBuffer); + if (!nestedFieldsBuffer.isEmpty()) { + cleanedParent = nestedFieldsBuffer; + } + kcql.addKeyField(Field.from(field.getName(), field.getAlias(), cleanedParent)); + } else if (field.toString().startsWith("_header.")) { + trimParentField(nestedFieldsBuffer); + if (!nestedFieldsBuffer.isEmpty()) { + cleanedParent = nestedFieldsBuffer; + } + kcql.addHeaderField(Field.from(field.getName(), field.getAlias(), cleanedParent)); + } else { + kcql.addField(field); + } + } + } - public List getWithKeys() { - return withKeys; - } + private void trimParentField(List parents) { + if (!parents.isEmpty()) { + parents.remove(0); + } + } + + @Override + public void exitDoc_type(ConnectorParser.Doc_typeContext ctx) { + kcql.docType = unescape(ctx.getText()); + } + + @Override + public void exitWith_converter_value(ConnectorParser.With_converter_valueContext ctx) { + kcql.withConverter = unescape(ctx.getText()); + } + + @Override + public void exitJms_selector_value(ConnectorParser.Jms_selector_valueContext ctx) { + kcql.withJmsSelector = unescape(ctx.getText()); + } + + @Override + public void exitIndex_suffix(ConnectorParser.Index_suffixContext ctx) { + kcql.indexSuffix = unescape(ctx.getText()); + } + + @Override + public void exitInc_mode(ConnectorParser.Inc_modeContext ctx) { + kcql.incrementalMode = ctx.getText(); + } + + @Override + public void exitPartition_name(ConnectorParser.Partition_nameContext ctx) { + kcql.addPartitionByField(ctx.getText()); + } + + @Override + public void exitTable_name(ConnectorParser.Table_nameContext ctx) { + kcql.target = unescape(ctx.getText()); + } + + @Override + public void enterWith_ignore(ConnectorParser.With_ignoreContext ctx) { + isWithinIgnore[0] = true; + } + + @Override + public void exitWith_ignore(ConnectorParser.With_ignoreContext ctx) { + isWithinIgnore[0] = false; + } + + @Override + public void exitTopic_name(ConnectorParser.Topic_nameContext ctx) { + kcql.source = unescape(ctx.getText()); + } + + @Override + public void exitUpsert_into(ConnectorParser.Upsert_intoContext ctx) { + kcql.writeMode = WriteModeEnum.UPSERT; + } + + @Override + public void exitInsert_into(ConnectorParser.Insert_intoContext ctx) { + kcql.writeMode = WriteModeEnum.INSERT; + } + + @Override + public void exitUpdate_into(ConnectorParser.Update_intoContext ctx) { + kcql.writeMode = WriteModeEnum.UPDATE; + } + + @Override + public void exitAutocreate(ConnectorParser.AutocreateContext ctx) { + kcql.autoCreate = true; + } + + @Override + public void enterPk_name(ConnectorParser.Pk_nameContext ctx) { + nestedFieldsBuffer.clear(); + } + + @Override + public void exitPk_name(ConnectorParser.Pk_nameContext ctx) { + List parentFields = null; + String name = nestedFieldsBuffer.get(nestedFieldsBuffer.size() - 1); + nestedFieldsBuffer.remove(nestedFieldsBuffer.size() - 1); + + if (!nestedFieldsBuffer.isEmpty()) { + parentFields = nestedFieldsBuffer; + } - public String getKeyDelimeter() { - return keyDelimiter; - } + Field field = Field.from(name, parentFields); + kcql.primaryKeys.add(field); + } - public boolean hasRetainStructure() { - return retainStructure; - } + @Override + public void exitAutoevolve(ConnectorParser.AutoevolveContext ctx) { + kcql.autoEvolve = true; + } - public boolean isUnwrapping() { - return unwrapping; - } + @Override + public void exitStoreas_type(ConnectorParser.Storeas_typeContext ctx) { + kcql.storedAs = ctx.getText().replace("`", ""); + } - public String getWithType() { - return this.withType; - } + @Override + public void exitProperty(ConnectorParser.PropertyContext ctx) { + String key = unescape(ctx.property_name().getText()); + String value = unescape(ctx.property_value().getText()); + if (value.startsWith("'") && value.endsWith("'")) { + value = value.substring(1, value.length() - 1); + } + kcql.getProperties().put(key, value); + } + + @Override + public void exitStoreas_parameter(ConnectorParser.Storeas_parameterContext ctx) { + String value = ctx.getText(); + for (String key : kcql.getStoredAsParameters().keySet()) { + if (key.compareToIgnoreCase(value) == 0) { + throw new IllegalArgumentException(value + " is a duplicated entry in the storeAs parameters list"); + } + } + storedAsParameter[0] = value; + } - public String getIncrementalMode() { - return this.incrementalMode; - } + @Override + public void exitStoreas_value(ConnectorParser.Storeas_valueContext ctx) { + kcql.getStoredAsParameters().put(storedAsParameter[0], ctx.getText()); + } - public String getDocType() { - return this.docType; - } + @Override + public void exitBatch_size(ConnectorParser.Batch_sizeContext ctx) { + final String value = ctx.getText(); + try { + int newBatchSize = Integer.parseInt(value); + if (newBatchSize <= 0) { + throw new IllegalArgumentException(value + " is not a valid number for a batch Size."); + } + kcql.batchSize = newBatchSize; + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(value + " is not a valid number for a batch Size."); + } + } - public String getIndexSuffix() { - return this.indexSuffix; - } + @Override + public void exitTtl_type(ConnectorParser.Ttl_typeContext ctx) { + final String value = ctx.getText(); + try { + long newTTL = Long.parseLong(value); + if (newTTL <= 0) { + throw new IllegalArgumentException(value + " is not a valid number for a TTL."); + } + kcql.setTTL(newTTL); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(value + " is not a valid number for a TTL."); + } + } - public String getWithConverter() { - return withConverter; - } + @Override + public void exitTimestamp_value(ConnectorParser.Timestamp_valueContext ctx) { + kcql.timestamp = ctx.getText(); + } - public String getWithJmsSelector() { - return withJmsSelector; - } + @Override + public void exitTimestamp_unit_value(ConnectorParser.Timestamp_unit_valueContext ctx) { + String value = ctx.getText().toUpperCase(); + try { + kcql.setTimestampUnit(TimeUnit.valueOf(value)); + } catch (Throwable t) { + TimeUnit[] units = TimeUnit.values(); + StringBuilder sb = new StringBuilder(); + sb.append(units[0].toString()); + for (int i = 1; i < units.length; ++i) { + sb.append(","); + sb.append(units[i].toString()); + } + throw new IllegalArgumentException(("Invalid 'TIMESTAMPUNIT'. Available values are : " + sb)); + } + } - public String getPipeline() { - return pipeline; - } + @Override + public void exitWith_format(ConnectorParser.With_formatContext ctx) { + try { + kcql.formatType = FormatType.valueOf(ctx.getText().toUpperCase()); + } catch (Throwable t) { + FormatType[] types = FormatType.values(); + StringBuilder sb = new StringBuilder(); + sb.append(types[0].toString()); + for (int i = 1; i < types.length; ++i) { + sb.append(","); + sb.append(types[i].toString()); + } + throw new IllegalArgumentException(("Invalid 'FORMAT'. Available values are : " + sb)); + } + } + + @Override + public void exitWith_target_value(ConnectorParser.With_target_valueContext ctx) { + kcql.setDynamicTarget(ctx.getText()); + } + + @Override + public void exitTag_value(ConnectorParser.Tag_valueContext ctx) { + tagValue[0] = ctx.getText(); + } + + @Override + public void exitTag_key(ConnectorParser.Tag_keyContext ctx) { + if (ctx.getText().trim().endsWith(".")) { + throw new IllegalArgumentException("Invalid syntax for tags. Field selection can not end with '.'"); + } + tagKey[0] = ctx.getText(); + } + + @Override + public void exitTag_definition(ConnectorParser.Tag_definitionContext ctx) { + String txt = ctx.getText(); + Tag.TagType type = Tag.TagType.DEFAULT; + if (tagValue[0] != null) { + String tmp = txt.replace(tagKey[0], "").trim(); + if (tmp.startsWith("=")) { + type = Tag.TagType.CONSTANT; + } else if (tmp.toLowerCase().startsWith("as")) { + type = Tag.TagType.ALIAS; + } else { + throw new IllegalArgumentException( + "Invalid syntax for tags. Needs to be 'tag1 [as x]' or 'tag1' or 'tag1 = constant'"); + } + } - public String getWithRegex() { - return withRegex; - } + if (kcql.tags == null) + kcql.tags = new ArrayList<>(); + kcql.tags.add(new Tag(tagKey[0], tagValue[0], type)); + tagKey[0] = null; + tagValue[0] = null; + } + + @Override + public void exitWith_key_value(ConnectorParser.With_key_valueContext ctx) { + String key = ctx.getText(); + if (kcql.withKeys == null) { + kcql.withKeys = new ArrayList<>(); + } + kcql.withKeys.add(unescape(key)); + } + + @Override + public void exitKey_delimiter_value(ConnectorParser.Key_delimiter_valueContext ctx) { + kcql.keyDelimiter = ctx.getText().replace("`", "").replace("'", ""); + if (kcql.keyDelimiter.trim().length() == 0) { + throw new IllegalArgumentException("Invalid key delimiter. Needs to be a non empty string."); + } + } - private void setWithRegex(String withRegex) { - this.withRegex = withRegex; - } + @Override + public void exitPipeline_value(ConnectorParser.Pipeline_valueContext ctx) { + kcql.pipeline = unescape(ctx.getText()); + } - private void setDynamicTarget(String dynamicTarget) { - this.dynamicTarget = dynamicTarget; - } + @Override + public void exitWith_regex_value(ConnectorParser.With_regex_valueContext ctx) { + kcql.withRegex = unescape(ctx.getText()); + } - public String getDynamicTarget() { - return dynamicTarget; - } + }); - public TimeUnit getTimestampUnit() { - return timestampUnit; + try { + parser.stat(); + } catch (Throwable ex) { + throw new IllegalArgumentException("Invalid syntax." + ex.getMessage(), ex); } - private void setTimestampUnit(TimeUnit timestampUnit) { - this.timestampUnit = timestampUnit; + final HashSet cols = new HashSet<>(); + for (Field alias : kcql.fields) { + cols.add(alias.getAlias()); } - /** - * Parses (check parse method) multiple KCQL statements delimited by semicolon. - * @param kcqlStatements - * @return - */ - public static List parseMultiple(final String kcqlStatements) { - return Arrays.stream(kcqlStatements.split(";")).map(Kcql::parse).collect(Collectors.toList()); + String ts = kcql.timestamp; + if (ts != null) { + if (TIMESTAMP.compareToIgnoreCase(ts) == 0) { + kcql.timestamp = ts.toLowerCase(); + } else { + kcql.timestamp = ts; + } } - public static Kcql parse(final String syntax) { - final ConnectorLexer lexer = new ConnectorLexer(CharStreams.fromString(syntax)); - final CommonTokenStream tokens = new CommonTokenStream(lexer); - final ConnectorParser parser = new ConnectorParser(tokens); - final ArrayList nestedFieldsBuffer = new ArrayList<>(); - final Kcql kcql = new Kcql(); - kcql.setQuery(syntax); - parser.addErrorListener(new BaseErrorListener() { - @Override - public void syntaxError(Recognizer recognizer, - Object offendingSymbol, - int line, - int charPositionInLine, - String msg, - RecognitionException e) { - throw new IllegalStateException("failed to parse at line " + line + " due to " + msg, e); - } - }); - - final String[] storedAsParameter = {null}; - - final boolean[] isWithinIgnore = {false}; - - final String[] tagValue = {null}; - final String[] tagKey = {null}; - - parser.addParseListener(new ConnectorParserBaseListener() { - - @Override - public void exitWith_subscription_value(ConnectorParser.With_subscription_valueContext ctx) { - kcql.subscription = unescape(ctx.getText()); - } - - @Override - public void exitColumn(ConnectorParser.ColumnContext ctx) { - for (TerminalNode tn : ctx.FIELD()) { - nestedFieldsBuffer.add(tn.getText()); - } - if (ctx.ASTERISK() != null) { - nestedFieldsBuffer.add("*"); - } - } - - @Override - public void exitWith_unwrap_clause(ConnectorParser.With_unwrap_clauseContext ctx) { - kcql.unwrapping = true; - } + return kcql; + } - @Override - public void exitWith_type_value(ConnectorParser.With_type_valueContext ctx) { - kcql.withType = unescape(ctx.getText()); - } - - @Override - public void exitWith_structure(ConnectorParser.With_structureContext ctx) { - kcql.retainStructure = true; - } - - @Override - public void exitLimit_value(ConnectorParser.Limit_valueContext ctx) { - try { - int limit = Integer.parseInt(ctx.INT().getText()); - if (limit < 1) - throw new IllegalArgumentException("Invalid limit specified. Needs to be an integer greater than zero"); - kcql.limit = limit; - } catch (NumberFormatException nfe) { - throw new IllegalArgumentException("Invalid limit specified(" + ctx.INT().getText() + "). Needs to be an integer greater than zero"); - } - } - - @Override - public void enterColumn_name(ConnectorParser.Column_nameContext ctx) { - nestedFieldsBuffer.clear(); - } - - @Override - public void exitColumn_name(ConnectorParser.Column_nameContext ctx) { - super.exitColumn_name(ctx); - if (ctx.ASTERISK() != null) { - Field field = new Field("*", FieldType.VALUE, null); - kcql.addField(field); - return; - } - - List parentFields = null; - String name = nestedFieldsBuffer.get(nestedFieldsBuffer.size() - 1); - nestedFieldsBuffer.remove(nestedFieldsBuffer.size() - 1); - - if (!nestedFieldsBuffer.isEmpty()) { - parentFields = nestedFieldsBuffer; - } - - Field field; - if (ctx.column_name_alias() != null) { - field = Field.from(name, ctx.column_name_alias().getText(), parentFields); - } else { - field = Field.from(name, parentFields); - } - - if (isWithinIgnore[0]) { - kcql.ignoredFields.add(field); - } else { - List cleanedParent = null; - - if (field.toString().startsWith("_key.")) { - trimParentField(nestedFieldsBuffer); - if (!nestedFieldsBuffer.isEmpty()) { - cleanedParent = nestedFieldsBuffer; - } - kcql.addKeyField(Field.from(field.getName(), field.getAlias(), cleanedParent)); - } else if (field.toString().startsWith("_header.")) { - trimParentField(nestedFieldsBuffer); - if (!nestedFieldsBuffer.isEmpty()) { - cleanedParent = nestedFieldsBuffer; - } - kcql.addHeaderField(Field.from(field.getName(), field.getAlias(), cleanedParent)); - } else { - kcql.addField(field); - } - } - } - - private void trimParentField(List parents) { - if (!parents.isEmpty()) { - parents.remove(0); - } - } - - @Override - public void exitDoc_type(ConnectorParser.Doc_typeContext ctx) { - kcql.docType = unescape(ctx.getText()); - } - - @Override - public void exitWith_converter_value(ConnectorParser.With_converter_valueContext ctx) { - kcql.withConverter = unescape(ctx.getText()); - } - - @Override - public void exitJms_selector_value(ConnectorParser.Jms_selector_valueContext ctx) { - kcql.withJmsSelector = unescape(ctx.getText()); - } - - @Override - public void exitIndex_suffix(ConnectorParser.Index_suffixContext ctx) { - kcql.indexSuffix = unescape(ctx.getText()); - } - - @Override - public void exitInc_mode(ConnectorParser.Inc_modeContext ctx) { - kcql.incrementalMode = ctx.getText(); - } - - @Override - public void exitPartition_name(ConnectorParser.Partition_nameContext ctx) { - kcql.addPartitionByField(ctx.getText()); - } - - @Override - public void exitTable_name(ConnectorParser.Table_nameContext ctx) { - kcql.target = unescape(ctx.getText()); - } - - @Override - public void enterWith_ignore(ConnectorParser.With_ignoreContext ctx) { - isWithinIgnore[0] = true; - } - - @Override - public void exitWith_ignore(ConnectorParser.With_ignoreContext ctx) { - isWithinIgnore[0] = false; - } - - @Override - public void exitTopic_name(ConnectorParser.Topic_nameContext ctx) { - kcql.source = unescape(ctx.getText()); - } - - @Override - public void exitUpsert_into(ConnectorParser.Upsert_intoContext ctx) { - kcql.writeMode = WriteModeEnum.UPSERT; - } - - @Override - public void exitInsert_into(ConnectorParser.Insert_intoContext ctx) { - kcql.writeMode = WriteModeEnum.INSERT; - } - - @Override - public void exitUpdate_into(ConnectorParser.Update_intoContext ctx) { - kcql.writeMode = WriteModeEnum.UPDATE; - } - - @Override - public void exitAutocreate(ConnectorParser.AutocreateContext ctx) { - kcql.autoCreate = true; - } - - @Override - public void enterPk_name(ConnectorParser.Pk_nameContext ctx) { - nestedFieldsBuffer.clear(); - } - - @Override - public void exitPk_name(ConnectorParser.Pk_nameContext ctx) { - List parentFields = null; - String name = nestedFieldsBuffer.get(nestedFieldsBuffer.size() - 1); - nestedFieldsBuffer.remove(nestedFieldsBuffer.size() - 1); - - if (!nestedFieldsBuffer.isEmpty()) { - parentFields = nestedFieldsBuffer; - } - - Field field = Field.from(name, parentFields); - kcql.primaryKeys.add(field); - } - - @Override - public void exitAutoevolve(ConnectorParser.AutoevolveContext ctx) { - kcql.autoEvolve = true; - } - - - @Override - public void exitStoreas_type(ConnectorParser.Storeas_typeContext ctx) { - kcql.storedAs = ctx.getText().replace("`", ""); - } - - - @Override - public void exitProperty(ConnectorParser.PropertyContext ctx) { - String key = unescape(ctx.property_name().getText()); - String value = unescape(ctx.property_value().getText()); - if (value.startsWith("'") && value.endsWith("'")) { - value = value.substring(1, value.length() - 1); - } - kcql.getProperties().put(key, value); - } - - @Override - public void exitStoreas_parameter(ConnectorParser.Storeas_parameterContext ctx) { - String value = ctx.getText(); - for (String key : kcql.getStoredAsParameters().keySet()) { - if (key.compareToIgnoreCase(value) == 0) { - throw new IllegalArgumentException(value + " is a duplicated entry in the storeAs parameters list"); - } - } - storedAsParameter[0] = value; - } - - @Override - public void exitStoreas_value(ConnectorParser.Storeas_valueContext ctx) { - kcql.getStoredAsParameters().put(storedAsParameter[0], ctx.getText()); - } - - @Override - public void exitBatch_size(ConnectorParser.Batch_sizeContext ctx) { - final String value = ctx.getText(); - try { - int newBatchSize = Integer.parseInt(value); - if (newBatchSize <= 0) { - throw new IllegalArgumentException(value + " is not a valid number for a batch Size."); - } - kcql.batchSize = newBatchSize; - } catch (NumberFormatException ex) { - throw new IllegalArgumentException(value + " is not a valid number for a batch Size."); - } - } - - @Override - public void exitTtl_type(ConnectorParser.Ttl_typeContext ctx) { - final String value = ctx.getText(); - try { - long newTTL = Long.parseLong(value); - if (newTTL <= 0) { - throw new IllegalArgumentException(value + " is not a valid number for a TTL."); - } - kcql.setTTL(newTTL); - } catch (NumberFormatException ex) { - throw new IllegalArgumentException(value + " is not a valid number for a TTL."); - } - } - - @Override - public void exitTimestamp_value(ConnectorParser.Timestamp_valueContext ctx) { - kcql.timestamp = ctx.getText(); - } - - @Override - public void exitTimestamp_unit_value(ConnectorParser.Timestamp_unit_valueContext ctx) { - String value = ctx.getText().toUpperCase(); - try { - kcql.setTimestampUnit(TimeUnit.valueOf(value)); - } catch (Throwable t) { - TimeUnit[] units = TimeUnit.values(); - StringBuilder sb = new StringBuilder(); - sb.append(units[0].toString()); - for (int i = 1; i < units.length; ++i) { - sb.append(","); - sb.append(units[i].toString()); - } - throw new IllegalArgumentException(("Invalid 'TIMESTAMPUNIT'. Available values are : " + sb)); - } - } - - @Override - public void exitWith_format(ConnectorParser.With_formatContext ctx) { - try { - kcql.formatType = FormatType.valueOf(ctx.getText().toUpperCase()); - } catch (Throwable t) { - FormatType[] types = FormatType.values(); - StringBuilder sb = new StringBuilder(); - sb.append(types[0].toString()); - for (int i = 1; i < types.length; ++i) { - sb.append(","); - sb.append(types[i].toString()); - } - throw new IllegalArgumentException(("Invalid 'FORMAT'. Available values are : " + sb)); - } - } - - @Override - public void exitWith_target_value(ConnectorParser.With_target_valueContext ctx) { - kcql.setDynamicTarget(ctx.getText()); - } - - @Override - public void exitTag_value(ConnectorParser.Tag_valueContext ctx) { - tagValue[0] = ctx.getText(); - } - - @Override - public void exitTag_key(ConnectorParser.Tag_keyContext ctx) { - if (ctx.getText().trim().endsWith(".")) { - throw new IllegalArgumentException("Invalid syntax for tags. Field selection can not end with '.'"); - } - tagKey[0] = ctx.getText(); - } - - @Override - public void exitTag_definition(ConnectorParser.Tag_definitionContext ctx) { - String txt = ctx.getText(); - Tag.TagType type = Tag.TagType.DEFAULT; - if (tagValue[0] != null) { - String tmp = txt.replace(tagKey[0], "").trim(); - if (tmp.startsWith("=")) { - type = Tag.TagType.CONSTANT; - } else if (tmp.toLowerCase().startsWith("as")) { - type = Tag.TagType.ALIAS; - } else { - throw new IllegalArgumentException("Invalid syntax for tags. Needs to be 'tag1 [as x]' or 'tag1' or 'tag1 = constant'"); - } - } - - if (kcql.tags == null) kcql.tags = new ArrayList<>(); - kcql.tags.add(new Tag(tagKey[0], tagValue[0], type)); - tagKey[0] = null; - tagValue[0] = null; - } - - @Override - public void exitWith_key_value(ConnectorParser.With_key_valueContext ctx) { - String key = ctx.getText(); - if (kcql.withKeys == null) { - kcql.withKeys = new ArrayList<>(); - } - kcql.withKeys.add(unescape(key)); - } - - @Override - public void exitKey_delimiter_value(ConnectorParser.Key_delimiter_valueContext ctx) { - kcql.keyDelimiter = ctx.getText().replace("`", "").replace("'", ""); - if (kcql.keyDelimiter.trim().length() == 0) { - throw new IllegalArgumentException("Invalid key delimiter. Needs to be a non empty string."); - } - } - - @Override - public void exitPipeline_value(ConnectorParser.Pipeline_valueContext ctx) { - kcql.pipeline = unescape(ctx.getText()); - } - - @Override - public void exitWith_regex_value(ConnectorParser.With_regex_valueContext ctx) { - kcql.withRegex = unescape(ctx.getText()); - } - - }); - - - try { - parser.stat(); - } catch (Throwable ex) { - throw new IllegalArgumentException("Invalid syntax." + ex.getMessage(), ex); - } - - final HashSet cols = new HashSet<>(); - for (Field alias : kcql.fields) { - cols.add(alias.getAlias()); - } - - - String ts = kcql.timestamp; - if (ts != null) { - if (TIMESTAMP.compareToIgnoreCase(ts) == 0) { - kcql.timestamp = ts.toLowerCase(); - } else { - kcql.timestamp = ts; - } - } - - return kcql; + private static String unescape(String value) { + if (value.startsWith("`") && value.endsWith("`")) { + value = value.substring(1, value.length() - 1); } - - private static String unescape(String value) { - if (value.startsWith("`") && value.endsWith("`")) { - value = value.substring(1, value.length() - 1); - } - if (value.startsWith("'") && value.endsWith("'")) { - value = value.substring(1, value.length() - 1); - } - return value; + if (value.startsWith("'") && value.endsWith("'")) { + value = value.substring(1, value.length() - 1); } + return value; + } } diff --git a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Tag.java b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Tag.java index 658ae4f25..c0f858dc6 100644 --- a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Tag.java +++ b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/Tag.java @@ -5,7 +5,7 @@ * 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 + * 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, @@ -16,6 +16,7 @@ package io.lenses.kcql; public class Tag { + private final String key; private final String value; private final TagType type; diff --git a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/WriteModeEnum.java b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/WriteModeEnum.java index a2f32f460..af76f3128 100644 --- a/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/WriteModeEnum.java +++ b/java-connectors/kafka-connect-query-language/src/main/java/io/lenses/kcql/WriteModeEnum.java @@ -5,7 +5,7 @@ * 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 + * 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, From 4867420c3a8adbda88fc251498458c44252525fc Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Thu, 9 May 2024 20:42:05 +0200 Subject: [PATCH 26/30] Update cassandra, elasticsearch, kafka, ... to 1.19.8 (#1213) --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index da7e33c0c..ecbb9e430 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -100,7 +100,7 @@ object Dependencies { val azureDocumentDbVersion = "2.6.5" val testcontainersScalaVersion = "0.41.3" - val testcontainersVersion = "1.19.7" + val testcontainersVersion = "1.19.8" val influxVersion = "7.0.0" From ccb79b15a962004fadc6732552860b7c48c4b263 Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Thu, 9 May 2024 20:50:38 +0200 Subject: [PATCH 27/30] Update sbt-pack to 0.20 (#1214) --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 5c9dfccc5..46d22810c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -5,7 +5,7 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.12") addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.10.0") -addSbtPlugin("org.xerial.sbt" % "sbt-pack" % "0.19") +addSbtPlugin("org.xerial.sbt" % "sbt-pack" % "0.20") addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") From 370a072a9fbad9a438e80eaf6af68e6a5ec177f8 Mon Sep 17 00:00:00 2001 From: Scala Steward <43047562+scala-steward@users.noreply.github.com> Date: Fri, 10 May 2024 09:52:49 +0200 Subject: [PATCH 28/30] Update s3, sts to 2.25.48 (#1215) --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ecbb9e430..6ee0da6bb 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -75,7 +75,7 @@ object Dependencies { val jerseyCommonVersion = "3.1.6" val calciteVersion = "1.34.0" - val awsSdkVersion = "2.25.47" + val awsSdkVersion = "2.25.48" val azureDataLakeVersion = "12.18.4" val azureIdentityVersion = "1.12.1" From 08a217741cffd11a36910fca8a7ac53c549e2308 Mon Sep 17 00:00:00 2001 From: Mati Urban <157909548+GoMati-MU@users.noreply.github.com> Date: Fri, 10 May 2024 11:36:27 +0000 Subject: [PATCH 29/30] changing to use gradlew for spotless (#1216) --- build.sbt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index 5ddda45c0..4bb3a152c 100644 --- a/build.sbt +++ b/build.sbt @@ -476,16 +476,16 @@ addCommandAlias( "headerCheck;test:headerCheck;it:headerCheck;fun:headerCheck;scalafmtCheckAll;test-common/scalafmtCheck;test-common/headerCheck", ) -lazy val gradleSpotlessApply = taskKey[Unit]("Run 'gradle spotlessApply' via external process") +lazy val gradleSpotlessApply = taskKey[Unit]("Run 'gradlew spotlessApply' via external process") gradleSpotlessApply := { // Specify the desired working directory for the external process val targetDirectory = baseDirectory.value / "java-connectors" // Execute 'gradle spotlessApply' in the specified directory - val exitCode = Process("gradle spotlessApply", targetDirectory).! + val exitCode = Process("gradlew spotlessApply", targetDirectory).! if (exitCode != 0) { - throw new RuntimeException("gradle spotlessApply command failed") + throw new RuntimeException("gradlew spotlessApply command failed") } } From 90d76a33880622f164a7a12c4fa9dc6bba589829 Mon Sep 17 00:00:00 2001 From: Stefan Bocutiu Date: Fri, 10 May 2024 15:07:42 +0100 Subject: [PATCH 30/30] LC-200 Skip Glacier stored objects (#1217) At times, customers might already have archived files in the current bucket [and prefix]. This would impact processing and would stop the connector. With this change the AWS storage implementation will automatically skip the glacier storage class and logs it. Co-authored-by: stheppi --- .../xml/employeedata0000-glacier.xml | 70003 ++++++++++++++++ ...eTaskXmlReaderWithGlacierStorageTest.scala | 124 + .../s3/storage/AwsS3StorageInterface.scala | 29 +- .../config/GCPStorageSourceConfigTest.scala | 3 +- 4 files changed, 70151 insertions(+), 8 deletions(-) create mode 100644 kafka-connect-aws-s3/src/it/resources/xml/employeedata0000-glacier.xml create mode 100644 kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderWithGlacierStorageTest.scala diff --git a/kafka-connect-aws-s3/src/it/resources/xml/employeedata0000-glacier.xml b/kafka-connect-aws-s3/src/it/resources/xml/employeedata0000-glacier.xml new file mode 100644 index 000000000..4793536b4 --- /dev/null +++ b/kafka-connect-aws-s3/src/it/resources/xml/employeedata0000-glacier.xml @@ -0,0 +1,70003 @@ + + + + 1 + 3-1991 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 2 + 2-1997 + C2 + Slave Labour + Sports Mascot of Parties + + + 3 + 10-1998 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 4 + 12-2017 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 5 + 1-1997 + C2 + Slave Labour + Author Laureate + + + 6 + 1-2012 + A1 + Massively Overpaid + Software Developer of Parties + + + 7 + 2-1990 + B2 + Underpaid + Philosopher for the Environment + + + 8 + 12-2008 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 9 + 4-2005 + C1 + Massively Underpaid + Author Laureate + + + 10 + 8-2001 + A2 + Overpaid + Author of Doom + + + 11 + 4-2020 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 12 + 11-2023 + A1 + Massively Overpaid + Author of Cattle + + + 13 + 2-2003 + C2 + Slave Labour + Builder of Parties + + + 14 + 6-2014 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 15 + 12-2011 + C1 + Massively Underpaid + Historian (Trainee) + + + 16 + 11-2008 + C1 + Massively Underpaid + Philosopher for Eternity + + + 17 + 10-2017 + C2 + Slave Labour + Builder Trainer + + + 18 + 6-1992 + C2 + Slave Labour + Author for the Environment + + + 19 + 3-2007 + A2 + Overpaid + Software Developer Laureate + + + 20 + 10-2013 + C2 + Slave Labour + Sports Mascot of Doom + + + 21 + 2-2015 + A2 + Overpaid + Software Developer (Trainee) + + + 22 + 8-2012 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 23 + 5-2003 + A2 + Overpaid + Historian Laureate + + + 24 + 11-1993 + B1 + Fairly Paid + Historian for Schools + + + 25 + 3-2003 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 26 + 2-2001 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 27 + 1-1992 + B1 + Fairly Paid + Historian of Doom + + + 28 + 8-2001 + A2 + Overpaid + Philosopher for the Environment + + + 29 + 10-1994 + A1 + Massively Overpaid + Assassin of Cattle + + + 30 + 3-2020 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 31 + 8-2004 + A2 + Overpaid + Skydiving Instructor for Schools + + + 32 + 7-2013 + C1 + Massively Underpaid + Vigilante for the Environment + + + 33 + 5-2012 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 34 + 7-1996 + A2 + Overpaid + Sports Mascot (Trainee) + + + 35 + 9-1993 + A1 + Massively Overpaid + Philosopher in Chief + + + 36 + 12-2020 + C2 + Slave Labour + Builder for Schools + + + 37 + 8-1997 + A2 + Overpaid + Skydiving Instructor of Parties + + + 38 + 6-1997 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 39 + 5-2017 + B2 + Underpaid + Historian for the Environment + + + 40 + 4-2018 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 41 + 11-1992 + B1 + Fairly Paid + Food Taster for Schools + + + 42 + 3-2004 + A2 + Overpaid + Sports Mascot for Eternity + + + 43 + 7-2008 + C1 + Massively Underpaid + Builder for Eternity + + + 44 + 8-2022 + B2 + Underpaid + Philosopher for Eternity + + + 45 + 6-2019 + B2 + Underpaid + Skydiving Instructor in Chief + + + 46 + 1-2014 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 47 + 3-2020 + B2 + Underpaid + Sports Mascot Laureate + + + 48 + 1-2007 + B2 + Underpaid + Author Trainer + + + 49 + 7-2011 + B1 + Fairly Paid + Vigilante (Trainee) + + + 50 + 3-1990 + A1 + Massively Overpaid + Historian of Doom + + + 51 + 9-2002 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 52 + 12-2014 + A1 + Massively Overpaid + Food Taster Laureate + + + 53 + 12-2018 + A1 + Massively Overpaid + Historian (Trainee) + + + 54 + 9-2010 + A2 + Overpaid + Vigilante for the Environment + + + 55 + 9-2012 + B1 + Fairly Paid + Sports Mascot of Doom + + + 56 + 7-2003 + C2 + Slave Labour + Sports Mascot for the Environment + + + 57 + 5-2005 + A1 + Massively Overpaid + Assassin for the Environment + + + 58 + 12-1992 + A2 + Overpaid + Builder Trainer + + + 59 + 10-2021 + B1 + Fairly Paid + Historian Laureate + + + 60 + 10-1993 + B2 + Underpaid + Builder of Parties + + + 61 + 9-2012 + B2 + Underpaid + Food Taster (Trainee) + + + 62 + 8-1996 + B1 + Fairly Paid + Author of Doom + + + 63 + 8-2018 + C1 + Massively Underpaid + Historian of Doom + + + 64 + 4-1995 + B2 + Underpaid + Food Taster in Chief + + + 65 + 2-1993 + B1 + Fairly Paid + Builder of Doom + + + 66 + 2-2016 + A1 + Massively Overpaid + Food Taster for Schools + + + 67 + 2-2005 + B1 + Fairly Paid + Food Taster for Eternity + + + 68 + 1-2023 + A2 + Overpaid + Skydiving Instructor Laureate + + + 69 + 2-2002 + A2 + Overpaid + Sports Mascot of Parties + + + 70 + 12-1999 + B1 + Fairly Paid + Author for Eternity + + + 71 + 10-2007 + A2 + Overpaid + Historian Trainer + + + 72 + 5-1999 + B1 + Fairly Paid + Software Developer for Eternity + + + 73 + 1-2023 + B2 + Underpaid + Assassin for Schools + + + 74 + 1-2004 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 75 + 4-2004 + A1 + Massively Overpaid + Historian Extraordinaire + + + 76 + 11-1995 + A2 + Overpaid + Software Developer in Chief + + + 77 + 9-2006 + C2 + Slave Labour + Sports Mascot for the Environment + + + 78 + 1-1995 + A2 + Overpaid + Vigilante of Cattle + + + 79 + 5-1995 + A1 + Massively Overpaid + Software Developer for Eternity + + + 80 + 1-2006 + A2 + Overpaid + Vigilante for the Environment + + + 81 + 5-2007 + C1 + Massively Underpaid + Software Developer for Schools + + + 82 + 6-2023 + C2 + Slave Labour + Historian for Eternity + + + 83 + 2-1993 + B2 + Underpaid + Assassin for Eternity + + + 84 + 5-2003 + A2 + Overpaid + Sports Mascot for Schools + + + 85 + 10-2002 + B1 + Fairly Paid + Assassin of Cattle + + + 86 + 9-2013 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 87 + 8-1997 + A1 + Massively Overpaid + Author Laureate + + + 88 + 12-1998 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 89 + 4-2016 + B1 + Fairly Paid + Historian in Chief + + + 90 + 8-2007 + B2 + Underpaid + Builder (Trainee) + + + 91 + 8-2023 + C2 + Slave Labour + Vigilante for the Environment + + + 92 + 4-2004 + B2 + Underpaid + Historian for Schools + + + 93 + 9-2001 + A1 + Massively Overpaid + Builder in Chief + + + 94 + 1-2013 + A1 + Massively Overpaid + Philosopher for the Environment + + + 95 + 1-2017 + C2 + Slave Labour + Vigilante (Trainee) + + + 96 + 11-2010 + A2 + Overpaid + Food Taster of Doom + + + 97 + 5-2004 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 98 + 4-1993 + B2 + Underpaid + Builder for the Environment + + + 99 + 5-2001 + C1 + Massively Underpaid + Vigilante for Schools + + + 100 + 7-2000 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 101 + 10-2017 + C1 + Massively Underpaid + Builder in Chief + + + 102 + 11-2021 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 103 + 1-1998 + C2 + Slave Labour + Food Taster Extraordinaire + + + 104 + 8-2013 + C2 + Slave Labour + Author for Eternity + + + 105 + 10-1999 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 106 + 11-2009 + C1 + Massively Underpaid + Philosopher for the Environment + + + 107 + 8-2001 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 108 + 5-1999 + B1 + Fairly Paid + Vigilante of Parties + + + 109 + 4-2019 + B1 + Fairly Paid + Sports Mascot of Doom + + + 110 + 8-1994 + C2 + Slave Labour + Vigilante (Trainee) + + + 111 + 7-2004 + A2 + Overpaid + Historian (Trainee) + + + 112 + 12-1999 + C2 + Slave Labour + Assassin for Eternity + + + 113 + 7-1997 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 114 + 5-1990 + A2 + Overpaid + Builder Laureate + + + 115 + 7-2021 + C2 + Slave Labour + Food Taster of Doom + + + 116 + 9-2022 + C1 + Massively Underpaid + Assassin Trainer + + + 117 + 6-1996 + B2 + Underpaid + Builder (Trainee) + + + 118 + 1-2009 + A1 + Massively Overpaid + Builder in Chief + + + 119 + 8-2017 + A2 + Overpaid + Philosopher Laureate + + + 120 + 7-1996 + B1 + Fairly Paid + Assassin for Eternity + + + 121 + 1-1997 + B1 + Fairly Paid + Assassin of Parties + + + 122 + 1-2019 + B1 + Fairly Paid + Author Trainer + + + 123 + 7-2004 + A2 + Overpaid + Software Developer of Parties + + + 124 + 6-2010 + A1 + Massively Overpaid + Vigilante in Chief + + + 125 + 8-2010 + B2 + Underpaid + Author Laureate + + + 126 + 3-2002 + B2 + Underpaid + Historian of Cattle + + + 127 + 7-2011 + C1 + Massively Underpaid + Builder of Doom + + + 128 + 4-1998 + C2 + Slave Labour + Historian for the Environment + + + 129 + 11-2016 + A2 + Overpaid + Food Taster for Eternity + + + 130 + 5-2022 + B1 + Fairly Paid + Vigilante for Schools + + + 131 + 2-2011 + C2 + Slave Labour + Philosopher for Schools + + + 132 + 10-2018 + C1 + Massively Underpaid + Author in Chief + + + 133 + 6-1996 + A2 + Overpaid + Food Taster for Eternity + + + 134 + 4-1997 + A2 + Overpaid + Software Developer for the Environment + + + 135 + 6-2005 + A2 + Overpaid + Vigilante of Cattle + + + 136 + 8-2000 + A2 + Overpaid + Philosopher of Parties + + + 137 + 3-2003 + C1 + Massively Underpaid + Food Taster for Schools + + + 138 + 12-2007 + B1 + Fairly Paid + Philosopher Laureate + + + 139 + 6-2010 + B2 + Underpaid + Skydiving Instructor Laureate + + + 140 + 4-2002 + B2 + Underpaid + Builder of Cattle + + + 141 + 7-2010 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 142 + 9-2001 + A2 + Overpaid + Builder for Schools + + + 143 + 11-2005 + B1 + Fairly Paid + Assassin Laureate + + + 144 + 9-2007 + C1 + Massively Underpaid + Food Taster for Eternity + + + 145 + 5-2013 + C1 + Massively Underpaid + Philosopher for the Environment + + + 146 + 12-1991 + B1 + Fairly Paid + Food Taster for Eternity + + + 147 + 12-1999 + B2 + Underpaid + Assassin in Chief + + + 148 + 4-1993 + B2 + Underpaid + Historian for Eternity + + + 149 + 7-2016 + C1 + Massively Underpaid + Food Taster of Doom + + + 150 + 12-2014 + C2 + Slave Labour + Vigilante in Chief + + + 151 + 12-2016 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 152 + 8-1997 + A1 + Massively Overpaid + Builder in Chief + + + 153 + 7-1997 + C1 + Massively Underpaid + Philosopher in Chief + + + 154 + 4-2014 + A1 + Massively Overpaid + Builder of Cattle + + + 155 + 6-2003 + C1 + Massively Underpaid + Author for Schools + + + 156 + 11-1998 + C2 + Slave Labour + Philosopher Trainer + + + 157 + 8-2014 + B1 + Fairly Paid + Software Developer for Eternity + + + 158 + 9-1994 + B1 + Fairly Paid + Food Taster (Trainee) + + + 159 + 3-2002 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 160 + 4-2019 + B2 + Underpaid + Software Developer of Doom + + + 161 + 11-2022 + A2 + Overpaid + Builder for Eternity + + + 162 + 5-1999 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 163 + 4-2000 + C2 + Slave Labour + Software Developer for Eternity + + + 164 + 2-2006 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 165 + 12-1997 + A2 + Overpaid + Software Developer of Cattle + + + 166 + 10-2014 + C1 + Massively Underpaid + Assassin Trainer + + + 167 + 10-2018 + C1 + Massively Underpaid + Philosopher for Schools + + + 168 + 7-2008 + B2 + Underpaid + Philosopher (Trainee) + + + 169 + 8-2013 + A2 + Overpaid + Assassin Trainer + + + 170 + 6-2001 + B1 + Fairly Paid + Sports Mascot Trainer + + + 171 + 7-2014 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 172 + 10-2013 + A2 + Overpaid + Author of Parties + + + 173 + 5-2002 + B2 + Underpaid + Software Developer Extraordinaire + + + 174 + 4-2001 + A1 + Massively Overpaid + Author in Chief + + + 175 + 11-2012 + C1 + Massively Underpaid + Vigilante Laureate + + + 176 + 6-1994 + A2 + Overpaid + Software Developer of Doom + + + 177 + 4-2022 + B2 + Underpaid + Software Developer in Chief + + + 178 + 5-2020 + C1 + Massively Underpaid + Builder Trainer + + + 179 + 5-2006 + C2 + Slave Labour + Vigilante of Cattle + + + 180 + 10-2003 + B2 + Underpaid + Sports Mascot of Cattle + + + 181 + 12-1998 + B2 + Underpaid + Vigilante for Schools + + + 182 + 2-2016 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 183 + 12-1996 + C1 + Massively Underpaid + Food Taster of Parties + + + 184 + 6-1997 + C1 + Massively Underpaid + Builder for Eternity + + + 185 + 10-2009 + B2 + Underpaid + Sports Mascot (Trainee) + + + 186 + 5-2000 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 187 + 6-2022 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 188 + 1-1999 + C2 + Slave Labour + Software Developer of Doom + + + 189 + 3-2005 + B1 + Fairly Paid + Food Taster in Chief + + + 190 + 10-2003 + B2 + Underpaid + Food Taster of Parties + + + 191 + 8-2006 + A2 + Overpaid + Sports Mascot Laureate + + + 192 + 7-1996 + A2 + Overpaid + Vigilante Trainer + + + 193 + 10-2022 + A1 + Massively Overpaid + Builder for Schools + + + 194 + 11-2000 + C2 + Slave Labour + Software Developer Trainer + + + 195 + 7-2012 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 196 + 7-1993 + A1 + Massively Overpaid + Builder Extraordinaire + + + 197 + 10-2008 + C2 + Slave Labour + Food Taster of Parties + + + 198 + 5-2009 + A1 + Massively Overpaid + Builder of Doom + + + 199 + 11-2022 + B2 + Underpaid + Philosopher (Trainee) + + + 200 + 5-2000 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 201 + 3-2001 + B1 + Fairly Paid + Software Developer for the Environment + + + 202 + 7-2020 + A1 + Massively Overpaid + Philosopher for Eternity + + + 203 + 3-1996 + C2 + Slave Labour + Food Taster for Schools + + + 204 + 1-1996 + C2 + Slave Labour + Assassin Laureate + + + 205 + 8-2019 + B2 + Underpaid + Philosopher Trainer + + + 206 + 10-1991 + A2 + Overpaid + Software Developer for Eternity + + + 207 + 10-2001 + C1 + Massively Underpaid + Author for Schools + + + 208 + 11-1993 + C1 + Massively Underpaid + Vigilante for Eternity + + + 209 + 12-2003 + C2 + Slave Labour + Philosopher for Schools + + + 210 + 6-1994 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 211 + 1-2001 + A2 + Overpaid + Vigilante Extraordinaire + + + 212 + 3-1990 + C2 + Slave Labour + Vigilante for Schools + + + 213 + 10-2015 + B2 + Underpaid + Philosopher for Schools + + + 214 + 3-2001 + B1 + Fairly Paid + Author Trainer + + + 215 + 8-2020 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 216 + 6-2021 + C1 + Massively Underpaid + Philosopher Trainer + + + 217 + 11-2002 + C2 + Slave Labour + Philosopher Extraordinaire + + + 218 + 7-1994 + A2 + Overpaid + Philosopher Trainer + + + 219 + 10-1990 + A2 + Overpaid + Food Taster of Parties + + + 220 + 11-2018 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 221 + 7-2015 + B2 + Underpaid + Builder for the Environment + + + 222 + 5-2018 + B2 + Underpaid + Author of Cattle + + + 223 + 6-1991 + A2 + Overpaid + Vigilante for the Environment + + + 224 + 12-1997 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 225 + 7-1993 + B2 + Underpaid + Skydiving Instructor of Parties + + + 226 + 12-1996 + B2 + Underpaid + Builder for the Environment + + + 227 + 5-2020 + B2 + Underpaid + Vigilante of Doom + + + 228 + 5-2022 + C2 + Slave Labour + Philosopher of Cattle + + + 229 + 10-2015 + C2 + Slave Labour + Vigilante of Doom + + + 230 + 2-2012 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 231 + 3-1991 + A1 + Massively Overpaid + Author in Chief + + + 232 + 3-1998 + C1 + Massively Underpaid + Philosopher of Parties + + + 233 + 11-2013 + A1 + Massively Overpaid + Assassin of Doom + + + 234 + 4-2020 + C2 + Slave Labour + Assassin of Cattle + + + 235 + 5-2015 + C2 + Slave Labour + Author Extraordinaire + + + 236 + 11-1994 + C2 + Slave Labour + Software Developer Laureate + + + 237 + 3-2011 + A1 + Massively Overpaid + Assassin for Eternity + + + 238 + 6-2023 + C1 + Massively Underpaid + Software Developer of Doom + + + 239 + 4-2018 + A1 + Massively Overpaid + Assassin for Schools + + + 240 + 5-2015 + A1 + Massively Overpaid + Builder Extraordinaire + + + 241 + 11-2012 + B1 + Fairly Paid + Software Developer of Cattle + + + 242 + 12-2015 + A2 + Overpaid + Sports Mascot of Doom + + + 243 + 12-2013 + C2 + Slave Labour + Sports Mascot for Eternity + + + 244 + 7-2018 + C2 + Slave Labour + Vigilante for the Environment + + + 245 + 1-1992 + C2 + Slave Labour + Sports Mascot of Cattle + + + 246 + 12-1995 + A1 + Massively Overpaid + Food Taster of Doom + + + 247 + 5-2020 + B2 + Underpaid + Software Developer of Parties + + + 248 + 3-2011 + A1 + Massively Overpaid + Food Taster Laureate + + + 249 + 10-1997 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 250 + 4-2005 + C2 + Slave Labour + Software Developer of Parties + + + 251 + 4-1998 + A2 + Overpaid + Skydiving Instructor Laureate + + + 252 + 1-2004 + A1 + Massively Overpaid + Builder in Chief + + + 253 + 9-1994 + C2 + Slave Labour + Builder (Trainee) + + + 254 + 7-1996 + B2 + Underpaid + Assassin of Parties + + + 255 + 5-2000 + B2 + Underpaid + Philosopher Trainer + + + 256 + 8-1994 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 257 + 6-2012 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 258 + 8-2017 + A2 + Overpaid + Philosopher for Eternity + + + 259 + 8-2000 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 260 + 4-2015 + B2 + Underpaid + Food Taster for Eternity + + + 261 + 9-2016 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 262 + 3-2022 + B1 + Fairly Paid + Sports Mascot Laureate + + + 263 + 1-2010 + B2 + Underpaid + Skydiving Instructor Laureate + + + 264 + 8-2011 + B1 + Fairly Paid + Food Taster for Eternity + + + 265 + 9-2013 + C2 + Slave Labour + Software Developer Laureate + + + 266 + 5-2020 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 267 + 10-2018 + C2 + Slave Labour + Historian Laureate + + + 268 + 2-2021 + A2 + Overpaid + Assassin for Schools + + + 269 + 9-2015 + A1 + Massively Overpaid + Builder of Cattle + + + 270 + 8-1996 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 271 + 1-2016 + C1 + Massively Underpaid + Software Developer for Schools + + + 272 + 7-2004 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 273 + 3-1999 + C1 + Massively Underpaid + Philosopher Laureate + + + 274 + 7-1999 + B1 + Fairly Paid + Builder Trainer + + + 275 + 8-1993 + A1 + Massively Overpaid + Philosopher for Eternity + + + 276 + 2-2022 + C2 + Slave Labour + Author for Schools + + + 277 + 6-2017 + A1 + Massively Overpaid + Food Taster of Cattle + + + 278 + 7-2004 + A2 + Overpaid + Historian for Eternity + + + 279 + 9-2013 + C1 + Massively Underpaid + Author for the Environment + + + 280 + 2-2014 + B1 + Fairly Paid + Vigilante Laureate + + + 281 + 2-2023 + A2 + Overpaid + Food Taster Extraordinaire + + + 282 + 10-2008 + A2 + Overpaid + Food Taster for Schools + + + 283 + 10-2003 + C1 + Massively Underpaid + Historian Extraordinaire + + + 284 + 11-2017 + A2 + Overpaid + Software Developer Laureate + + + 285 + 9-2014 + C2 + Slave Labour + Vigilante Laureate + + + 286 + 2-1991 + C1 + Massively Underpaid + Vigilante of Cattle + + + 287 + 9-2001 + A1 + Massively Overpaid + Philosopher of Doom + + + 288 + 6-2023 + C2 + Slave Labour + Assassin for the Environment + + + 289 + 8-2017 + A1 + Massively Overpaid + Software Developer of Doom + + + 290 + 3-2019 + C2 + Slave Labour + Author of Parties + + + 291 + 12-1990 + C1 + Massively Underpaid + Vigilante for the Environment + + + 292 + 4-2013 + B1 + Fairly Paid + Builder for the Environment + + + 293 + 7-1999 + B1 + Fairly Paid + Software Developer of Doom + + + 294 + 2-2016 + B2 + Underpaid + Philosopher of Parties + + + 295 + 5-1991 + C1 + Massively Underpaid + Philosopher of Doom + + + 296 + 1-2006 + A2 + Overpaid + Vigilante for Eternity + + + 297 + 11-2009 + A1 + Massively Overpaid + Author for Schools + + + 298 + 2-1995 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 299 + 10-2018 + C2 + Slave Labour + Vigilante Trainer + + + 300 + 4-2017 + C2 + Slave Labour + Philosopher of Cattle + + + 301 + 12-2014 + B1 + Fairly Paid + Philosopher for the Environment + + + 302 + 8-1999 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 303 + 5-1995 + A2 + Overpaid + Skydiving Instructor of Parties + + + 304 + 6-2010 + B1 + Fairly Paid + Software Developer in Chief + + + 305 + 8-1993 + A2 + Overpaid + Builder Extraordinaire + + + 306 + 7-2020 + C1 + Massively Underpaid + Builder for Eternity + + + 307 + 7-2016 + B1 + Fairly Paid + Historian (Trainee) + + + 308 + 9-2021 + C2 + Slave Labour + Author Laureate + + + 309 + 11-2016 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 310 + 3-2011 + C1 + Massively Underpaid + Historian in Chief + + + 311 + 2-2000 + A2 + Overpaid + Builder Trainer + + + 312 + 6-2009 + B2 + Underpaid + Historian Laureate + + + 313 + 3-1990 + A1 + Massively Overpaid + Builder of Cattle + + + 314 + 10-2018 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 315 + 12-2009 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 316 + 5-1990 + A2 + Overpaid + Software Developer for Schools + + + 317 + 3-2022 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 318 + 9-2008 + B2 + Underpaid + Author in Chief + + + 319 + 10-2015 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 320 + 3-1998 + C2 + Slave Labour + Vigilante of Cattle + + + 321 + 8-1998 + C2 + Slave Labour + Builder for Schools + + + 322 + 9-1993 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 323 + 3-2021 + C1 + Massively Underpaid + Builder in Chief + + + 324 + 2-2018 + B2 + Underpaid + Author in Chief + + + 325 + 8-2012 + A1 + Massively Overpaid + Author of Doom + + + 326 + 3-2005 + C2 + Slave Labour + Historian for Eternity + + + 327 + 4-2006 + B1 + Fairly Paid + Philosopher of Doom + + + 328 + 10-1997 + C1 + Massively Underpaid + Food Taster for Eternity + + + 329 + 9-2018 + B2 + Underpaid + Author of Parties + + + 330 + 2-2012 + A1 + Massively Overpaid + Historian of Parties + + + 331 + 4-1990 + C2 + Slave Labour + Builder for Eternity + + + 332 + 11-2013 + B1 + Fairly Paid + Builder Laureate + + + 333 + 6-1996 + C1 + Massively Underpaid + Vigilante Laureate + + + 334 + 11-2009 + B2 + Underpaid + Food Taster Trainer + + + 335 + 5-2020 + B2 + Underpaid + Skydiving Instructor Trainer + + + 336 + 6-2002 + B1 + Fairly Paid + Author of Parties + + + 337 + 1-1991 + C2 + Slave Labour + Software Developer Trainer + + + 338 + 11-2003 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 339 + 1-1997 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 340 + 2-2019 + A2 + Overpaid + Author for Schools + + + 341 + 1-2014 + A2 + Overpaid + Philosopher for Eternity + + + 342 + 9-1992 + C1 + Massively Underpaid + Software Developer Trainer + + + 343 + 7-2009 + B1 + Fairly Paid + Philosopher of Cattle + + + 344 + 12-2015 + B1 + Fairly Paid + Philosopher Laureate + + + 345 + 1-1996 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 346 + 12-2008 + A2 + Overpaid + Sports Mascot for Schools + + + 347 + 11-2000 + C1 + Massively Underpaid + Assassin for Schools + + + 348 + 4-1992 + C2 + Slave Labour + Sports Mascot of Cattle + + + 349 + 10-2008 + B2 + Underpaid + Author of Cattle + + + 350 + 9-2000 + C1 + Massively Underpaid + Vigilante Laureate + + + 351 + 5-2008 + B2 + Underpaid + Philosopher Extraordinaire + + + 352 + 10-2012 + B2 + Underpaid + Philosopher Extraordinaire + + + 353 + 10-2020 + A2 + Overpaid + Assassin of Doom + + + 354 + 1-1997 + C2 + Slave Labour + Food Taster of Cattle + + + 355 + 7-2019 + C1 + Massively Underpaid + Philosopher Laureate + + + 356 + 8-2016 + A1 + Massively Overpaid + Builder for Schools + + + 357 + 12-2008 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 358 + 8-2011 + A1 + Massively Overpaid + Historian for Eternity + + + 359 + 4-1999 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 360 + 10-2018 + C2 + Slave Labour + Vigilante of Parties + + + 361 + 5-2005 + B1 + Fairly Paid + Builder of Doom + + + 362 + 10-1991 + B2 + Underpaid + Assassin in Chief + + + 363 + 9-2022 + B1 + Fairly Paid + Philosopher Trainer + + + 364 + 5-2009 + A2 + Overpaid + Vigilante Trainer + + + 365 + 4-2015 + A2 + Overpaid + Skydiving Instructor for Schools + + + 366 + 8-2000 + C1 + Massively Underpaid + Author in Chief + + + 367 + 7-2018 + C1 + Massively Underpaid + Philosopher Laureate + + + 368 + 10-2002 + A2 + Overpaid + Food Taster Extraordinaire + + + 369 + 10-1994 + A1 + Massively Overpaid + Software Developer of Cattle + + + 370 + 12-2006 + B1 + Fairly Paid + Builder Laureate + + + 371 + 3-2005 + C2 + Slave Labour + Software Developer of Doom + + + 372 + 10-2008 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 373 + 3-2017 + A2 + Overpaid + Skydiving Instructor in Chief + + + 374 + 3-1995 + B2 + Underpaid + Builder for Eternity + + + 375 + 12-2016 + B1 + Fairly Paid + Author (Trainee) + + + 376 + 3-1997 + C1 + Massively Underpaid + Historian (Trainee) + + + 377 + 5-2007 + C2 + Slave Labour + Philosopher of Cattle + + + 378 + 11-1997 + B2 + Underpaid + Builder of Cattle + + + 379 + 3-2017 + A1 + Massively Overpaid + Builder Laureate + + + 380 + 2-2008 + B2 + Underpaid + Builder of Cattle + + + 381 + 4-2022 + A2 + Overpaid + Philosopher (Trainee) + + + 382 + 4-2015 + A1 + Massively Overpaid + Philosopher of Cattle + + + 383 + 9-2007 + B1 + Fairly Paid + Builder in Chief + + + 384 + 12-2015 + C1 + Massively Underpaid + Philosopher for Schools + + + 385 + 7-2012 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 386 + 4-2015 + C1 + Massively Underpaid + Food Taster of Cattle + + + 387 + 12-2020 + A2 + Overpaid + Software Developer Extraordinaire + + + 388 + 11-2019 + A2 + Overpaid + Vigilante (Trainee) + + + 389 + 11-2022 + A2 + Overpaid + Historian of Doom + + + 390 + 11-2004 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 391 + 3-2023 + A1 + Massively Overpaid + Assassin in Chief + + + 392 + 5-2016 + B1 + Fairly Paid + Builder of Doom + + + 393 + 2-2008 + B2 + Underpaid + Vigilante (Trainee) + + + 394 + 6-2000 + B2 + Underpaid + Historian Extraordinaire + + + 395 + 9-2007 + B2 + Underpaid + Builder for Eternity + + + 396 + 8-2002 + A2 + Overpaid + Builder for the Environment + + + 397 + 6-2004 + B2 + Underpaid + Builder of Parties + + + 398 + 3-1998 + C1 + Massively Underpaid + Historian for the Environment + + + 399 + 9-1991 + A2 + Overpaid + Assassin for the Environment + + + 400 + 10-1994 + C2 + Slave Labour + Author for Schools + + + 401 + 3-1994 + C1 + Massively Underpaid + Vigilante of Parties + + + 402 + 10-2005 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 403 + 5-1992 + C2 + Slave Labour + Philosopher Extraordinaire + + + 404 + 9-2012 + A1 + Massively Overpaid + Software Developer Trainer + + + 405 + 7-1998 + C1 + Massively Underpaid + Assassin in Chief + + + 406 + 12-2021 + C1 + Massively Underpaid + Builder in Chief + + + 407 + 9-1995 + A2 + Overpaid + Builder Trainer + + + 408 + 12-2015 + C2 + Slave Labour + Software Developer in Chief + + + 409 + 11-1994 + C2 + Slave Labour + Builder Extraordinaire + + + 410 + 5-2023 + C2 + Slave Labour + Food Taster in Chief + + + 411 + 4-2019 + B2 + Underpaid + Sports Mascot of Doom + + + 412 + 8-2011 + B2 + Underpaid + Assassin for Schools + + + 413 + 4-1999 + C1 + Massively Underpaid + Vigilante Laureate + + + 414 + 8-2004 + B1 + Fairly Paid + Author for the Environment + + + 415 + 6-2008 + A2 + Overpaid + Assassin for Schools + + + 416 + 12-1993 + B2 + Underpaid + Skydiving Instructor for Schools + + + 417 + 3-1994 + A1 + Massively Overpaid + Vigilante in Chief + + + 418 + 6-1990 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 419 + 4-1997 + C2 + Slave Labour + Historian for Eternity + + + 420 + 11-2007 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 421 + 5-1999 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 422 + 3-2010 + A1 + Massively Overpaid + Vigilante for Eternity + + + 423 + 4-1997 + B2 + Underpaid + Assassin of Doom + + + 424 + 8-2013 + C2 + Slave Labour + Philosopher of Parties + + + 425 + 9-2017 + B1 + Fairly Paid + Food Taster in Chief + + + 426 + 6-2005 + C1 + Massively Underpaid + Historian (Trainee) + + + 427 + 3-2023 + B1 + Fairly Paid + Food Taster for Eternity + + + 428 + 6-2008 + C1 + Massively Underpaid + Vigilante Trainer + + + 429 + 5-2021 + A2 + Overpaid + Software Developer (Trainee) + + + 430 + 1-2016 + C1 + Massively Underpaid + Assassin for the Environment + + + 431 + 2-2002 + B1 + Fairly Paid + Assassin of Cattle + + + 432 + 7-2022 + A1 + Massively Overpaid + Assassin of Doom + + + 433 + 10-2015 + B2 + Underpaid + Assassin Laureate + + + 434 + 1-1999 + A2 + Overpaid + Philosopher Extraordinaire + + + 435 + 11-2011 + C1 + Massively Underpaid + Vigilante for the Environment + + + 436 + 12-1990 + B2 + Underpaid + Assassin of Parties + + + 437 + 12-1991 + C2 + Slave Labour + Builder of Doom + + + 438 + 4-2017 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 439 + 2-2020 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 440 + 2-2012 + C1 + Massively Underpaid + Builder for the Environment + + + 441 + 1-1994 + A1 + Massively Overpaid + Historian Trainer + + + 442 + 4-2022 + B1 + Fairly Paid + Assassin Laureate + + + 443 + 9-2003 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 444 + 2-2001 + B1 + Fairly Paid + Software Developer Laureate + + + 445 + 1-2004 + C1 + Massively Underpaid + Assassin Laureate + + + 446 + 4-2002 + A2 + Overpaid + Skydiving Instructor Trainer + + + 447 + 8-2005 + B1 + Fairly Paid + Historian for Schools + + + 448 + 3-1994 + C1 + Massively Underpaid + Historian of Parties + + + 449 + 1-2023 + C1 + Massively Underpaid + Author for Schools + + + 450 + 3-2008 + C2 + Slave Labour + Historian for Eternity + + + 451 + 8-2021 + C2 + Slave Labour + Software Developer in Chief + + + 452 + 9-2013 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 453 + 5-1996 + C1 + Massively Underpaid + Food Taster in Chief + + + 454 + 5-1990 + C1 + Massively Underpaid + Vigilante Trainer + + + 455 + 2-1999 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 456 + 11-2007 + C2 + Slave Labour + Software Developer of Parties + + + 457 + 8-2010 + A2 + Overpaid + Skydiving Instructor of Parties + + + 458 + 5-2019 + C2 + Slave Labour + Software Developer for Eternity + + + 459 + 12-2011 + B1 + Fairly Paid + Food Taster for Eternity + + + 460 + 10-2014 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 461 + 10-2021 + A1 + Massively Overpaid + Food Taster of Parties + + + 462 + 1-2012 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 463 + 6-1990 + B1 + Fairly Paid + Builder of Parties + + + 464 + 5-2002 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 465 + 8-1990 + B2 + Underpaid + Author for Eternity + + + 466 + 1-1994 + C2 + Slave Labour + Builder Laureate + + + 467 + 12-2017 + B2 + Underpaid + Philosopher Extraordinaire + + + 468 + 5-2015 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 469 + 9-2008 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 470 + 8-2015 + A1 + Massively Overpaid + Food Taster of Cattle + + + 471 + 12-1993 + B1 + Fairly Paid + Software Developer in Chief + + + 472 + 6-2010 + B1 + Fairly Paid + Builder Laureate + + + 473 + 6-1998 + A1 + Massively Overpaid + Assassin Trainer + + + 474 + 9-2015 + A1 + Massively Overpaid + Vigilante of Parties + + + 475 + 3-2000 + B2 + Underpaid + Assassin for the Environment + + + 476 + 4-1991 + B1 + Fairly Paid + Historian of Parties + + + 477 + 9-2011 + B1 + Fairly Paid + Sports Mascot for Schools + + + 478 + 1-2010 + A1 + Massively Overpaid + Vigilante of Doom + + + 479 + 11-2010 + B1 + Fairly Paid + Philosopher (Trainee) + + + 480 + 4-2008 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 481 + 1-2022 + A1 + Massively Overpaid + Historian for Eternity + + + 482 + 10-1997 + B1 + Fairly Paid + Sports Mascot in Chief + + + 483 + 7-2018 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 484 + 11-2017 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 485 + 9-2020 + C2 + Slave Labour + Vigilante for the Environment + + + 486 + 11-2004 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 487 + 12-2005 + A1 + Massively Overpaid + Vigilante of Doom + + + 488 + 1-1994 + B1 + Fairly Paid + Author for the Environment + + + 489 + 7-2007 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 490 + 8-2000 + A1 + Massively Overpaid + Vigilante for Eternity + + + 491 + 4-2013 + B1 + Fairly Paid + Philosopher (Trainee) + + + 492 + 4-1993 + B2 + Underpaid + Food Taster for Schools + + + 493 + 12-2004 + C2 + Slave Labour + Historian for the Environment + + + 494 + 3-2022 + A2 + Overpaid + Food Taster Extraordinaire + + + 495 + 10-2005 + B1 + Fairly Paid + Food Taster of Parties + + + 496 + 3-1998 + A2 + Overpaid + Software Developer Laureate + + + 497 + 7-2010 + B1 + Fairly Paid + Builder in Chief + + + 498 + 5-2009 + B1 + Fairly Paid + Philosopher for the Environment + + + 499 + 9-2011 + B2 + Underpaid + Assassin in Chief + + + 500 + 1-2019 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 501 + 8-2000 + A1 + Massively Overpaid + Software Developer of Doom + + + 502 + 2-2022 + A2 + Overpaid + Skydiving Instructor Laureate + + + 503 + 10-1996 + B1 + Fairly Paid + Author of Cattle + + + 504 + 7-1993 + A2 + Overpaid + Builder Laureate + + + 505 + 7-2020 + B2 + Underpaid + Philosopher Laureate + + + 506 + 7-1994 + B2 + Underpaid + Author Laureate + + + 507 + 1-1998 + A1 + Massively Overpaid + Philosopher Laureate + + + 508 + 6-1991 + B1 + Fairly Paid + Philosopher (Trainee) + + + 509 + 6-2012 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 510 + 2-1992 + C2 + Slave Labour + Builder of Doom + + + 511 + 3-2019 + B1 + Fairly Paid + Assassin Trainer + + + 512 + 4-1992 + A1 + Massively Overpaid + Vigilante in Chief + + + 513 + 3-2007 + C2 + Slave Labour + Builder Extraordinaire + + + 514 + 12-2015 + A1 + Massively Overpaid + Historian of Parties + + + 515 + 7-2016 + C1 + Massively Underpaid + Food Taster for the Environment + + + 516 + 6-2022 + C2 + Slave Labour + Assassin Laureate + + + 517 + 7-2015 + C2 + Slave Labour + Food Taster of Cattle + + + 518 + 7-2014 + A2 + Overpaid + Builder Extraordinaire + + + 519 + 11-1990 + C2 + Slave Labour + Software Developer of Cattle + + + 520 + 3-2006 + A1 + Massively Overpaid + Vigilante for Eternity + + + 521 + 8-2018 + C2 + Slave Labour + Food Taster for the Environment + + + 522 + 8-2014 + A2 + Overpaid + Food Taster for the Environment + + + 523 + 10-2006 + A1 + Massively Overpaid + Philosopher Trainer + + + 524 + 3-2009 + A2 + Overpaid + Historian for Schools + + + 525 + 10-2000 + C2 + Slave Labour + Philosopher for Schools + + + 526 + 4-2014 + A1 + Massively Overpaid + Food Taster of Doom + + + 527 + 4-2005 + B2 + Underpaid + Assassin for Eternity + + + 528 + 2-1996 + B2 + Underpaid + Skydiving Instructor for Schools + + + 529 + 10-2002 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 530 + 10-2002 + A1 + Massively Overpaid + Builder Trainer + + + 531 + 2-1995 + A1 + Massively Overpaid + Software Developer Trainer + + + 532 + 9-2022 + B2 + Underpaid + Philosopher (Trainee) + + + 533 + 7-2021 + C2 + Slave Labour + Software Developer of Doom + + + 534 + 5-2015 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 535 + 11-2019 + C2 + Slave Labour + Builder for Schools + + + 536 + 6-1994 + A1 + Massively Overpaid + Food Taster for Eternity + + + 537 + 2-1990 + C2 + Slave Labour + Philosopher of Parties + + + 538 + 12-1998 + C2 + Slave Labour + Vigilante Extraordinaire + + + 539 + 12-2003 + C1 + Massively Underpaid + Historian of Cattle + + + 540 + 5-1997 + B1 + Fairly Paid + Author in Chief + + + 541 + 9-2016 + A1 + Massively Overpaid + Author (Trainee) + + + 542 + 11-2009 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 543 + 1-2020 + B1 + Fairly Paid + Software Developer of Parties + + + 544 + 11-1990 + B1 + Fairly Paid + Author of Doom + + + 545 + 12-2014 + C2 + Slave Labour + Builder (Trainee) + + + 546 + 9-1999 + C2 + Slave Labour + Historian (Trainee) + + + 547 + 5-2006 + B2 + Underpaid + Sports Mascot of Parties + + + 548 + 7-1993 + C1 + Massively Underpaid + Builder Trainer + + + 549 + 5-2000 + B2 + Underpaid + Assassin of Doom + + + 550 + 9-2003 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 551 + 4-2007 + C1 + Massively Underpaid + Author Trainer + + + 552 + 8-1993 + C1 + Massively Underpaid + Food Taster for Eternity + + + 553 + 6-2000 + B1 + Fairly Paid + Philosopher for the Environment + + + 554 + 6-1990 + B2 + Underpaid + Philosopher for Eternity + + + 555 + 3-2017 + B1 + Fairly Paid + Software Developer of Cattle + + + 556 + 1-2007 + A2 + Overpaid + Historian (Trainee) + + + 557 + 4-2004 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 558 + 2-2011 + C2 + Slave Labour + Vigilante of Parties + + + 559 + 5-1996 + A1 + Massively Overpaid + Philosopher for the Environment + + + 560 + 1-2005 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 561 + 2-1990 + B1 + Fairly Paid + Philosopher for Schools + + + 562 + 4-2021 + C2 + Slave Labour + Philosopher for Eternity + + + 563 + 2-1998 + C2 + Slave Labour + Author (Trainee) + + + 564 + 11-2007 + C1 + Massively Underpaid + Builder of Doom + + + 565 + 4-2000 + A1 + Massively Overpaid + Historian (Trainee) + + + 566 + 6-2003 + A2 + Overpaid + Vigilante in Chief + + + 567 + 5-2018 + C1 + Massively Underpaid + Software Developer for Schools + + + 568 + 11-2019 + C1 + Massively Underpaid + Philosopher for Eternity + + + 569 + 7-1992 + B1 + Fairly Paid + Sports Mascot for Schools + + + 570 + 6-2021 + B1 + Fairly Paid + Author Laureate + + + 571 + 4-1996 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 572 + 7-1995 + B1 + Fairly Paid + Software Developer of Cattle + + + 573 + 2-2023 + C2 + Slave Labour + Vigilante of Cattle + + + 574 + 8-2022 + C2 + Slave Labour + Historian for Schools + + + 575 + 3-2003 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 576 + 12-2022 + B2 + Underpaid + Food Taster of Doom + + + 577 + 12-1990 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 578 + 5-2001 + C2 + Slave Labour + Builder for Eternity + + + 579 + 5-2008 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 580 + 12-2023 + C1 + Massively Underpaid + Author Trainer + + + 581 + 5-2011 + A1 + Massively Overpaid + Assassin of Cattle + + + 582 + 3-2004 + B2 + Underpaid + Historian (Trainee) + + + 583 + 12-1992 + C1 + Massively Underpaid + Philosopher in Chief + + + 584 + 9-2022 + A2 + Overpaid + Food Taster for the Environment + + + 585 + 6-2001 + A2 + Overpaid + Software Developer of Cattle + + + 586 + 9-2006 + C1 + Massively Underpaid + Assassin for Eternity + + + 587 + 11-2023 + A2 + Overpaid + Vigilante for Schools + + + 588 + 9-2010 + A2 + Overpaid + Food Taster Extraordinaire + + + 589 + 5-2021 + B1 + Fairly Paid + Author for Eternity + + + 590 + 7-2008 + B2 + Underpaid + Assassin Extraordinaire + + + 591 + 2-2004 + C1 + Massively Underpaid + Vigilante for Schools + + + 592 + 5-2015 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 593 + 10-1990 + B1 + Fairly Paid + Philosopher of Parties + + + 594 + 3-2008 + A2 + Overpaid + Historian for the Environment + + + 595 + 6-2000 + B2 + Underpaid + Food Taster of Doom + + + 596 + 5-2002 + B1 + Fairly Paid + Vigilante Trainer + + + 597 + 9-2014 + C2 + Slave Labour + Software Developer of Cattle + + + 598 + 7-1997 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 599 + 8-2022 + A2 + Overpaid + Builder for Eternity + + + 600 + 1-2003 + C2 + Slave Labour + Sports Mascot in Chief + + + 601 + 5-1990 + A1 + Massively Overpaid + Vigilante for Schools + + + 602 + 10-2013 + B2 + Underpaid + Food Taster for Schools + + + 603 + 4-2016 + C2 + Slave Labour + Assassin of Doom + + + 604 + 9-2021 + B2 + Underpaid + Vigilante Trainer + + + 605 + 1-1993 + C2 + Slave Labour + Software Developer of Parties + + + 606 + 2-2011 + B1 + Fairly Paid + Historian (Trainee) + + + 607 + 7-2002 + C2 + Slave Labour + Builder Extraordinaire + + + 608 + 11-2014 + B1 + Fairly Paid + Author for the Environment + + + 609 + 2-1990 + C2 + Slave Labour + Vigilante in Chief + + + 610 + 12-1997 + B1 + Fairly Paid + Software Developer of Doom + + + 611 + 6-1992 + B1 + Fairly Paid + Author Trainer + + + 612 + 3-2019 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 613 + 4-2016 + A2 + Overpaid + Historian (Trainee) + + + 614 + 3-2018 + C1 + Massively Underpaid + Food Taster of Cattle + + + 615 + 9-2010 + B2 + Underpaid + Author (Trainee) + + + 616 + 5-2021 + A1 + Massively Overpaid + Author (Trainee) + + + 617 + 2-2008 + B1 + Fairly Paid + Assassin (Trainee) + + + 618 + 3-2016 + C2 + Slave Labour + Assassin for Eternity + + + 619 + 5-2001 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 620 + 3-2021 + A2 + Overpaid + Skydiving Instructor for Schools + + + 621 + 7-2013 + A2 + Overpaid + Software Developer of Doom + + + 622 + 2-1994 + B1 + Fairly Paid + Sports Mascot of Parties + + + 623 + 2-1991 + C2 + Slave Labour + Author Laureate + + + 624 + 9-2016 + B1 + Fairly Paid + Vigilante for Schools + + + 625 + 7-1993 + A2 + Overpaid + Vigilante of Doom + + + 626 + 6-2013 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 627 + 9-2008 + B1 + Fairly Paid + Philosopher Trainer + + + 628 + 1-2003 + B1 + Fairly Paid + Sports Mascot of Parties + + + 629 + 11-2015 + C2 + Slave Labour + Sports Mascot of Cattle + + + 630 + 11-2005 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 631 + 10-2014 + C1 + Massively Underpaid + Philosopher Trainer + + + 632 + 8-2003 + B1 + Fairly Paid + Food Taster for the Environment + + + 633 + 8-2004 + B2 + Underpaid + Software Developer of Doom + + + 634 + 4-1995 + B1 + Fairly Paid + Assassin Trainer + + + 635 + 9-1998 + C2 + Slave Labour + Philosopher Trainer + + + 636 + 4-2012 + B1 + Fairly Paid + Author for the Environment + + + 637 + 10-2017 + C2 + Slave Labour + Builder for the Environment + + + 638 + 12-2019 + B2 + Underpaid + Skydiving Instructor of Doom + + + 639 + 4-1993 + B2 + Underpaid + Software Developer of Cattle + + + 640 + 1-2006 + C1 + Massively Underpaid + Builder Extraordinaire + + + 641 + 10-2010 + C1 + Massively Underpaid + Software Developer for Schools + + + 642 + 1-2012 + C1 + Massively Underpaid + Historian for the Environment + + + 643 + 1-2007 + A2 + Overpaid + Historian of Cattle + + + 644 + 10-1991 + B2 + Underpaid + Builder Laureate + + + 645 + 2-1990 + B2 + Underpaid + Author Laureate + + + 646 + 4-1998 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 647 + 9-2006 + A2 + Overpaid + Philosopher Extraordinaire + + + 648 + 5-2008 + B1 + Fairly Paid + Sports Mascot Trainer + + + 649 + 6-2010 + A2 + Overpaid + Philosopher for Eternity + + + 650 + 6-2013 + C2 + Slave Labour + Historian Trainer + + + 651 + 6-1996 + B2 + Underpaid + Builder Trainer + + + 652 + 3-1999 + A1 + Massively Overpaid + Historian in Chief + + + 653 + 12-2016 + C2 + Slave Labour + Author for Schools + + + 654 + 8-2009 + C2 + Slave Labour + Assassin for Schools + + + 655 + 1-1991 + C2 + Slave Labour + Historian for the Environment + + + 656 + 10-2019 + C1 + Massively Underpaid + Builder Trainer + + + 657 + 11-1997 + A1 + Massively Overpaid + Philosopher for Eternity + + + 658 + 3-2019 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 659 + 9-2001 + B1 + Fairly Paid + Food Taster for the Environment + + + 660 + 1-2004 + C1 + Massively Underpaid + Historian Trainer + + + 661 + 2-1993 + C1 + Massively Underpaid + Philosopher of Doom + + + 662 + 2-2016 + A2 + Overpaid + Builder of Parties + + + 663 + 4-1992 + B2 + Underpaid + Sports Mascot Trainer + + + 664 + 1-2004 + A1 + Massively Overpaid + Food Taster of Parties + + + 665 + 12-2001 + C1 + Massively Underpaid + Historian of Parties + + + 666 + 11-2007 + B2 + Underpaid + Vigilante for Eternity + + + 667 + 11-1998 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 668 + 6-2014 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 669 + 9-1996 + C1 + Massively Underpaid + Philosopher for the Environment + + + 670 + 2-2008 + C2 + Slave Labour + Philosopher Laureate + + + 671 + 6-2016 + A2 + Overpaid + Food Taster for Schools + + + 672 + 2-2020 + A2 + Overpaid + Vigilante for Eternity + + + 673 + 8-2015 + A1 + Massively Overpaid + Software Developer of Parties + + + 674 + 1-1992 + B1 + Fairly Paid + Assassin for Eternity + + + 675 + 1-2013 + C1 + Massively Underpaid + Historian of Cattle + + + 676 + 11-1990 + C1 + Massively Underpaid + Builder for the Environment + + + 677 + 7-2012 + B1 + Fairly Paid + Assassin Laureate + + + 678 + 6-1996 + A2 + Overpaid + Assassin for the Environment + + + 679 + 11-2006 + A2 + Overpaid + Sports Mascot of Parties + + + 680 + 8-2013 + C2 + Slave Labour + Vigilante of Doom + + + 681 + 9-2007 + A2 + Overpaid + Assassin of Parties + + + 682 + 3-2017 + A1 + Massively Overpaid + Author of Doom + + + 683 + 2-2005 + B2 + Underpaid + Sports Mascot in Chief + + + 684 + 9-2015 + B2 + Underpaid + Food Taster for Schools + + + 685 + 10-2011 + C2 + Slave Labour + Software Developer of Parties + + + 686 + 4-2004 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 687 + 11-2012 + A2 + Overpaid + Builder Trainer + + + 688 + 3-2003 + C1 + Massively Underpaid + Vigilante of Doom + + + 689 + 9-2009 + B2 + Underpaid + Sports Mascot Laureate + + + 690 + 12-2022 + A1 + Massively Overpaid + Vigilante in Chief + + + 691 + 8-2000 + B1 + Fairly Paid + Food Taster for Eternity + + + 692 + 11-1990 + C1 + Massively Underpaid + Assassin in Chief + + + 693 + 10-2012 + C2 + Slave Labour + Author (Trainee) + + + 694 + 8-2002 + B1 + Fairly Paid + Author Laureate + + + 695 + 11-2015 + B2 + Underpaid + Software Developer of Parties + + + 696 + 12-2013 + B2 + Underpaid + Food Taster for Eternity + + + 697 + 3-2001 + C2 + Slave Labour + Philosopher in Chief + + + 698 + 5-2015 + A1 + Massively Overpaid + Author of Parties + + + 699 + 2-2023 + C2 + Slave Labour + Software Developer Extraordinaire + + + 700 + 1-2022 + B1 + Fairly Paid + Philosopher of Parties + + + 701 + 10-2016 + C2 + Slave Labour + Historian for Eternity + + + 702 + 12-1999 + A2 + Overpaid + Builder Trainer + + + 703 + 1-2017 + B2 + Underpaid + Vigilante Extraordinaire + + + 704 + 9-1990 + B2 + Underpaid + Skydiving Instructor Trainer + + + 705 + 9-2014 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 706 + 4-1994 + B1 + Fairly Paid + Historian Extraordinaire + + + 707 + 4-2013 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 708 + 8-2005 + B1 + Fairly Paid + Food Taster of Cattle + + + 709 + 3-1992 + B1 + Fairly Paid + Food Taster for Schools + + + 710 + 2-2008 + C2 + Slave Labour + Sports Mascot for Schools + + + 711 + 12-2003 + B1 + Fairly Paid + Software Developer Trainer + + + 712 + 3-1992 + A2 + Overpaid + Assassin Laureate + + + 713 + 11-2008 + B1 + Fairly Paid + Food Taster of Parties + + + 714 + 1-2004 + B1 + Fairly Paid + Philosopher Laureate + + + 715 + 12-2019 + C1 + Massively Underpaid + Assassin of Cattle + + + 716 + 5-2017 + A1 + Massively Overpaid + Builder Trainer + + + 717 + 7-2016 + C1 + Massively Underpaid + Software Developer Laureate + + + 718 + 6-2000 + A2 + Overpaid + Philosopher Trainer + + + 719 + 9-2002 + B1 + Fairly Paid + Philosopher Laureate + + + 720 + 9-1992 + C2 + Slave Labour + Sports Mascot for Eternity + + + 721 + 11-2006 + A2 + Overpaid + Historian Laureate + + + 722 + 9-2015 + A2 + Overpaid + Assassin Trainer + + + 723 + 2-2014 + A2 + Overpaid + Food Taster of Doom + + + 724 + 1-2019 + C1 + Massively Underpaid + Philosopher of Doom + + + 725 + 7-2023 + A1 + Massively Overpaid + Assassin of Cattle + + + 726 + 2-2000 + B2 + Underpaid + Author for Schools + + + 727 + 10-2011 + C2 + Slave Labour + Sports Mascot Laureate + + + 728 + 8-2006 + B1 + Fairly Paid + Builder Laureate + + + 729 + 10-1995 + C2 + Slave Labour + Philosopher of Cattle + + + 730 + 12-1993 + C2 + Slave Labour + Assassin Laureate + + + 731 + 9-2021 + C2 + Slave Labour + Historian of Cattle + + + 732 + 3-2011 + C2 + Slave Labour + Software Developer of Cattle + + + 733 + 5-2013 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 734 + 11-2007 + B2 + Underpaid + Philosopher of Cattle + + + 735 + 6-1998 + A2 + Overpaid + Builder Laureate + + + 736 + 6-2017 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 737 + 5-2021 + B1 + Fairly Paid + Builder of Parties + + + 738 + 10-2004 + C1 + Massively Underpaid + Assassin for the Environment + + + 739 + 3-2011 + A2 + Overpaid + Sports Mascot of Parties + + + 740 + 7-2002 + A2 + Overpaid + Skydiving Instructor Trainer + + + 741 + 6-1992 + C1 + Massively Underpaid + Builder Trainer + + + 742 + 3-2005 + A2 + Overpaid + Author Trainer + + + 743 + 6-2013 + B1 + Fairly Paid + Historian of Cattle + + + 744 + 1-1998 + A2 + Overpaid + Sports Mascot for the Environment + + + 745 + 1-2022 + B2 + Underpaid + Historian Laureate + + + 746 + 3-2013 + B1 + Fairly Paid + Philosopher Laureate + + + 747 + 5-2009 + A1 + Massively Overpaid + Historian for Eternity + + + 748 + 3-2000 + C1 + Massively Underpaid + Assassin for Eternity + + + 749 + 12-2023 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 750 + 10-2015 + A2 + Overpaid + Food Taster for the Environment + + + 751 + 9-2000 + C2 + Slave Labour + Philosopher of Parties + + + 752 + 7-2021 + C1 + Massively Underpaid + Author for Schools + + + 753 + 5-2010 + A2 + Overpaid + Author of Doom + + + 754 + 12-1990 + B1 + Fairly Paid + Sports Mascot in Chief + + + 755 + 12-1999 + A2 + Overpaid + Food Taster Extraordinaire + + + 756 + 5-2020 + C1 + Massively Underpaid + Builder Extraordinaire + + + 757 + 4-2001 + A1 + Massively Overpaid + Vigilante for Schools + + + 758 + 7-2023 + A2 + Overpaid + Software Developer of Cattle + + + 759 + 9-2015 + B2 + Underpaid + Skydiving Instructor Laureate + + + 760 + 2-1996 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 761 + 3-2010 + B1 + Fairly Paid + Author for Schools + + + 762 + 9-1990 + A2 + Overpaid + Assassin of Doom + + + 763 + 4-2014 + C1 + Massively Underpaid + Historian Trainer + + + 764 + 10-2019 + B1 + Fairly Paid + Software Developer of Cattle + + + 765 + 9-1996 + B2 + Underpaid + Skydiving Instructor of Parties + + + 766 + 6-2011 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 767 + 7-2013 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 768 + 3-2017 + B2 + Underpaid + Sports Mascot for the Environment + + + 769 + 11-1996 + C1 + Massively Underpaid + Assassin for Schools + + + 770 + 10-1996 + C2 + Slave Labour + Historian in Chief + + + 771 + 5-1994 + B2 + Underpaid + Vigilante of Parties + + + 772 + 7-2010 + C1 + Massively Underpaid + Software Developer in Chief + + + 773 + 3-1997 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 774 + 1-2021 + C2 + Slave Labour + Food Taster Trainer + + + 775 + 2-2003 + C2 + Slave Labour + Assassin (Trainee) + + + 776 + 11-1999 + C1 + Massively Underpaid + Philosopher for Schools + + + 777 + 2-1995 + B2 + Underpaid + Food Taster of Parties + + + 778 + 4-2016 + C1 + Massively Underpaid + Historian in Chief + + + 779 + 4-1998 + A2 + Overpaid + Assassin for the Environment + + + 780 + 3-2005 + C1 + Massively Underpaid + Builder of Parties + + + 781 + 1-2001 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 782 + 5-2004 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 783 + 5-2023 + C2 + Slave Labour + Philosopher of Parties + + + 784 + 6-2002 + A2 + Overpaid + Skydiving Instructor in Chief + + + 785 + 3-2006 + A2 + Overpaid + Software Developer Extraordinaire + + + 786 + 11-1991 + A2 + Overpaid + Software Developer of Parties + + + 787 + 10-2001 + B2 + Underpaid + Food Taster Extraordinaire + + + 788 + 5-2020 + B2 + Underpaid + Sports Mascot Trainer + + + 789 + 3-2018 + B1 + Fairly Paid + Assassin in Chief + + + 790 + 12-1991 + B2 + Underpaid + Sports Mascot (Trainee) + + + 791 + 8-2021 + C1 + Massively Underpaid + Historian Extraordinaire + + + 792 + 1-2007 + C2 + Slave Labour + Philosopher Extraordinaire + + + 793 + 5-2008 + A2 + Overpaid + Author of Doom + + + 794 + 3-2022 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 795 + 9-1991 + B1 + Fairly Paid + Software Developer of Cattle + + + 796 + 12-2003 + A2 + Overpaid + Food Taster for Schools + + + 797 + 8-1999 + B2 + Underpaid + Builder Laureate + + + 798 + 2-2016 + A2 + Overpaid + Skydiving Instructor Trainer + + + 799 + 10-2003 + A2 + Overpaid + Builder of Parties + + + 800 + 1-2002 + A1 + Massively Overpaid + Food Taster in Chief + + + 801 + 3-2016 + C1 + Massively Underpaid + Vigilante for Schools + + + 802 + 10-1990 + B2 + Underpaid + Philosopher of Cattle + + + 803 + 8-2010 + A2 + Overpaid + Builder Laureate + + + 804 + 3-2009 + C2 + Slave Labour + Philosopher of Cattle + + + 805 + 4-2013 + C2 + Slave Labour + Assassin Extraordinaire + + + 806 + 9-1994 + A2 + Overpaid + Author for the Environment + + + 807 + 9-2005 + C1 + Massively Underpaid + Assassin Laureate + + + 808 + 5-2019 + A2 + Overpaid + Food Taster Laureate + + + 809 + 6-2013 + A1 + Massively Overpaid + Vigilante for Eternity + + + 810 + 5-2011 + B1 + Fairly Paid + Historian of Doom + + + 811 + 6-1993 + C2 + Slave Labour + Assassin for Schools + + + 812 + 9-2019 + C2 + Slave Labour + Software Developer for the Environment + + + 813 + 10-2017 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 814 + 10-1990 + C2 + Slave Labour + Vigilante (Trainee) + + + 815 + 8-1997 + A2 + Overpaid + Skydiving Instructor Laureate + + + 816 + 6-2012 + A2 + Overpaid + Historian in Chief + + + 817 + 10-2006 + C2 + Slave Labour + Historian of Cattle + + + 818 + 7-1997 + C2 + Slave Labour + Software Developer for the Environment + + + 819 + 12-2015 + B1 + Fairly Paid + Food Taster for the Environment + + + 820 + 12-1994 + C2 + Slave Labour + Historian (Trainee) + + + 821 + 6-2020 + B2 + Underpaid + Assassin in Chief + + + 822 + 11-2021 + B1 + Fairly Paid + Vigilante in Chief + + + 823 + 11-2008 + C1 + Massively Underpaid + Historian (Trainee) + + + 824 + 9-2017 + C1 + Massively Underpaid + Food Taster of Cattle + + + 825 + 5-2022 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 826 + 8-2003 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 827 + 10-2020 + A2 + Overpaid + Sports Mascot (Trainee) + + + 828 + 3-2022 + B1 + Fairly Paid + Food Taster (Trainee) + + + 829 + 12-2003 + C2 + Slave Labour + Vigilante of Parties + + + 830 + 1-1990 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 831 + 2-2018 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 832 + 2-2007 + B2 + Underpaid + Historian (Trainee) + + + 833 + 2-1992 + C2 + Slave Labour + Builder Extraordinaire + + + 834 + 4-1992 + C2 + Slave Labour + Software Developer for the Environment + + + 835 + 5-2014 + B1 + Fairly Paid + Builder for the Environment + + + 836 + 5-1994 + B1 + Fairly Paid + Vigilante for the Environment + + + 837 + 3-1990 + B2 + Underpaid + Software Developer of Cattle + + + 838 + 6-2014 + B1 + Fairly Paid + Software Developer of Doom + + + 839 + 2-1996 + C2 + Slave Labour + Assassin (Trainee) + + + 840 + 6-2006 + C2 + Slave Labour + Philosopher for Schools + + + 841 + 10-1999 + B2 + Underpaid + Vigilante Laureate + + + 842 + 6-2008 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 843 + 11-2017 + A1 + Massively Overpaid + Food Taster for Schools + + + 844 + 1-1995 + C2 + Slave Labour + Food Taster in Chief + + + 845 + 11-1995 + A2 + Overpaid + Author of Doom + + + 846 + 9-1991 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 847 + 2-2011 + A2 + Overpaid + Author of Doom + + + 848 + 6-2015 + A2 + Overpaid + Software Developer of Cattle + + + 849 + 6-2003 + A2 + Overpaid + Vigilante for the Environment + + + 850 + 12-2006 + B1 + Fairly Paid + Food Taster (Trainee) + + + 851 + 1-1997 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 852 + 2-2018 + A1 + Massively Overpaid + Philosopher of Doom + + + 853 + 7-1995 + B1 + Fairly Paid + Historian Laureate + + + 854 + 10-1998 + A1 + Massively Overpaid + Assassin of Doom + + + 855 + 6-1998 + A2 + Overpaid + Builder Laureate + + + 856 + 12-1990 + B1 + Fairly Paid + Software Developer for the Environment + + + 857 + 2-1998 + C2 + Slave Labour + Assassin in Chief + + + 858 + 2-2018 + C2 + Slave Labour + Builder Extraordinaire + + + 859 + 11-2021 + A2 + Overpaid + Author (Trainee) + + + 860 + 5-2009 + A1 + Massively Overpaid + Software Developer for Eternity + + + 861 + 6-1994 + B1 + Fairly Paid + Builder (Trainee) + + + 862 + 1-2014 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 863 + 3-2013 + A2 + Overpaid + Software Developer for Eternity + + + 864 + 2-2021 + C2 + Slave Labour + Vigilante Trainer + + + 865 + 3-2002 + C1 + Massively Underpaid + Vigilante of Parties + + + 866 + 8-2021 + B2 + Underpaid + Builder (Trainee) + + + 867 + 3-2015 + C1 + Massively Underpaid + Software Developer of Doom + + + 868 + 8-2021 + B1 + Fairly Paid + Software Developer for the Environment + + + 869 + 10-2015 + A2 + Overpaid + Skydiving Instructor of Doom + + + 870 + 4-2020 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 871 + 5-2010 + C2 + Slave Labour + Philosopher of Parties + + + 872 + 2-2016 + C1 + Massively Underpaid + Vigilante for Eternity + + + 873 + 5-1993 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 874 + 11-2020 + C1 + Massively Underpaid + Author of Cattle + + + 875 + 7-2019 + C1 + Massively Underpaid + Food Taster Trainer + + + 876 + 7-1997 + C1 + Massively Underpaid + Software Developer Trainer + + + 877 + 3-2017 + C2 + Slave Labour + Food Taster (Trainee) + + + 878 + 3-1996 + A2 + Overpaid + Sports Mascot for the Environment + + + 879 + 3-2005 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 880 + 6-1994 + C1 + Massively Underpaid + Historian for the Environment + + + 881 + 9-2014 + A2 + Overpaid + Vigilante of Doom + + + 882 + 10-2004 + C1 + Massively Underpaid + Assassin in Chief + + + 883 + 2-2021 + B2 + Underpaid + Software Developer Extraordinaire + + + 884 + 2-2009 + C2 + Slave Labour + Vigilante for Eternity + + + 885 + 8-2008 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 886 + 1-1991 + B1 + Fairly Paid + Historian of Cattle + + + 887 + 2-1993 + C1 + Massively Underpaid + Vigilante of Cattle + + + 888 + 8-2022 + A2 + Overpaid + Author for the Environment + + + 889 + 12-1992 + B1 + Fairly Paid + Historian Extraordinaire + + + 890 + 10-2011 + A2 + Overpaid + Sports Mascot in Chief + + + 891 + 10-2017 + A1 + Massively Overpaid + Builder for the Environment + + + 892 + 8-2019 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 893 + 7-2020 + C2 + Slave Labour + Sports Mascot Trainer + + + 894 + 9-2002 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 895 + 1-2012 + C2 + Slave Labour + Sports Mascot for Schools + + + 896 + 9-2020 + A2 + Overpaid + Philosopher for Eternity + + + 897 + 10-2004 + C1 + Massively Underpaid + Philosopher of Parties + + + 898 + 4-2014 + B2 + Underpaid + Historian for Eternity + + + 899 + 4-2000 + A1 + Massively Overpaid + Assassin (Trainee) + + + 900 + 4-2004 + B2 + Underpaid + Historian for Eternity + + + 901 + 3-2005 + C2 + Slave Labour + Sports Mascot Trainer + + + 902 + 11-2007 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 903 + 6-2003 + B1 + Fairly Paid + Historian of Doom + + + 904 + 6-1990 + A2 + Overpaid + Author Trainer + + + 905 + 4-1990 + A1 + Massively Overpaid + Historian (Trainee) + + + 906 + 7-2023 + A1 + Massively Overpaid + Assassin for the Environment + + + 907 + 5-2015 + C2 + Slave Labour + Software Developer of Parties + + + 908 + 11-2022 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 909 + 4-2016 + C2 + Slave Labour + Philosopher for the Environment + + + 910 + 3-2008 + C2 + Slave Labour + Philosopher Laureate + + + 911 + 7-2011 + C1 + Massively Underpaid + Software Developer of Doom + + + 912 + 3-1992 + A1 + Massively Overpaid + Author in Chief + + + 913 + 5-1991 + A2 + Overpaid + Author in Chief + + + 914 + 5-2003 + C2 + Slave Labour + Software Developer Trainer + + + 915 + 6-2002 + C1 + Massively Underpaid + Assassin Laureate + + + 916 + 2-2021 + C2 + Slave Labour + Software Developer Laureate + + + 917 + 11-2000 + C1 + Massively Underpaid + Builder for Schools + + + 918 + 6-2004 + C2 + Slave Labour + Software Developer for Schools + + + 919 + 9-1999 + C2 + Slave Labour + Philosopher for Schools + + + 920 + 5-2014 + A1 + Massively Overpaid + Builder Trainer + + + 921 + 8-2020 + C2 + Slave Labour + Historian of Cattle + + + 922 + 12-2010 + B2 + Underpaid + Author Extraordinaire + + + 923 + 5-1999 + B2 + Underpaid + Assassin Laureate + + + 924 + 3-2021 + A1 + Massively Overpaid + Historian of Doom + + + 925 + 1-1994 + C1 + Massively Underpaid + Assassin for the Environment + + + 926 + 2-2013 + B2 + Underpaid + Philosopher for Schools + + + 927 + 1-1994 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 928 + 1-2013 + A1 + Massively Overpaid + Historian in Chief + + + 929 + 8-1993 + A1 + Massively Overpaid + Software Developer of Doom + + + 930 + 10-1995 + A2 + Overpaid + Food Taster (Trainee) + + + 931 + 9-1996 + B2 + Underpaid + Author for Schools + + + 932 + 4-2016 + A2 + Overpaid + Author (Trainee) + + + 933 + 11-2018 + A1 + Massively Overpaid + Historian (Trainee) + + + 934 + 8-2018 + B2 + Underpaid + Vigilante in Chief + + + 935 + 9-2009 + A1 + Massively Overpaid + Assassin Trainer + + + 936 + 10-1994 + B2 + Underpaid + Historian of Doom + + + 937 + 10-1997 + A1 + Massively Overpaid + Builder for Schools + + + 938 + 7-1999 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 939 + 6-2020 + B2 + Underpaid + Skydiving Instructor Trainer + + + 940 + 1-2003 + A1 + Massively Overpaid + Food Taster Laureate + + + 941 + 9-1994 + B1 + Fairly Paid + Software Developer in Chief + + + 942 + 9-2008 + B1 + Fairly Paid + Assassin in Chief + + + 943 + 6-2019 + A2 + Overpaid + Sports Mascot of Cattle + + + 944 + 9-2017 + C1 + Massively Underpaid + Food Taster for Schools + + + 945 + 10-2022 + A2 + Overpaid + Software Developer for Schools + + + 946 + 12-2006 + A2 + Overpaid + Software Developer in Chief + + + 947 + 2-2000 + C1 + Massively Underpaid + Vigilante for the Environment + + + 948 + 5-2008 + C1 + Massively Underpaid + Philosopher in Chief + + + 949 + 2-1995 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 950 + 11-1998 + A1 + Massively Overpaid + Builder Extraordinaire + + + 951 + 5-1990 + C2 + Slave Labour + Builder of Cattle + + + 952 + 12-1996 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 953 + 6-1994 + C1 + Massively Underpaid + Philosopher of Cattle + + + 954 + 4-1995 + B2 + Underpaid + Food Taster of Parties + + + 955 + 12-2000 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 956 + 1-2018 + C2 + Slave Labour + Food Taster (Trainee) + + + 957 + 1-2011 + A2 + Overpaid + Philosopher Trainer + + + 958 + 10-2010 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 959 + 1-1995 + B1 + Fairly Paid + Sports Mascot for Schools + + + 960 + 1-1996 + B1 + Fairly Paid + Food Taster for Schools + + + 961 + 8-2010 + B1 + Fairly Paid + Historian for Schools + + + 962 + 4-2022 + B1 + Fairly Paid + Software Developer for Schools + + + 963 + 5-1990 + C2 + Slave Labour + Vigilante of Cattle + + + 964 + 2-2005 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 965 + 6-2006 + B1 + Fairly Paid + Builder Extraordinaire + + + 966 + 6-2011 + A2 + Overpaid + Vigilante Trainer + + + 967 + 11-1998 + B1 + Fairly Paid + Assassin in Chief + + + 968 + 9-2018 + B1 + Fairly Paid + Software Developer in Chief + + + 969 + 8-2016 + C2 + Slave Labour + Vigilante Trainer + + + 970 + 3-2007 + A2 + Overpaid + Skydiving Instructor for Schools + + + 971 + 5-2013 + A2 + Overpaid + Assassin Laureate + + + 972 + 9-2011 + B1 + Fairly Paid + Author Laureate + + + 973 + 5-2009 + B2 + Underpaid + Philosopher (Trainee) + + + 974 + 5-2008 + C2 + Slave Labour + Historian Trainer + + + 975 + 9-2008 + A1 + Massively Overpaid + Vigilante Laureate + + + 976 + 7-1995 + B2 + Underpaid + Author Trainer + + + 977 + 11-2006 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 978 + 8-2009 + B2 + Underpaid + Assassin Extraordinaire + + + 979 + 6-1990 + A2 + Overpaid + Software Developer of Doom + + + 980 + 11-2013 + A2 + Overpaid + Philosopher Trainer + + + 981 + 2-2012 + C2 + Slave Labour + Philosopher of Doom + + + 982 + 3-1998 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 983 + 1-1996 + A1 + Massively Overpaid + Historian of Doom + + + 984 + 12-1998 + C1 + Massively Underpaid + Historian (Trainee) + + + 985 + 12-2010 + B2 + Underpaid + Philosopher (Trainee) + + + 986 + 1-2003 + A2 + Overpaid + Skydiving Instructor in Chief + + + 987 + 1-2015 + B1 + Fairly Paid + Software Developer Trainer + + + 988 + 4-2006 + B1 + Fairly Paid + Vigilante (Trainee) + + + 989 + 11-2001 + A2 + Overpaid + Philosopher of Doom + + + 990 + 3-1999 + A1 + Massively Overpaid + Historian for Schools + + + 991 + 7-2001 + B1 + Fairly Paid + Historian Extraordinaire + + + 992 + 7-2000 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 993 + 3-2021 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 994 + 4-2019 + A1 + Massively Overpaid + Food Taster for the Environment + + + 995 + 2-2015 + A1 + Massively Overpaid + Assassin of Doom + + + 996 + 1-2004 + C2 + Slave Labour + Philosopher (Trainee) + + + 997 + 8-2005 + C2 + Slave Labour + Author of Parties + + + 998 + 5-2007 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 999 + 6-2020 + A2 + Overpaid + Builder of Cattle + + + 1,000 + 4-2002 + B2 + Underpaid + Vigilante (Trainee) + + + 1,001 + 4-1992 + C2 + Slave Labour + Builder of Doom + + + 1,002 + 3-2015 + C2 + Slave Labour + Software Developer in Chief + + + 1,003 + 11-1999 + B2 + Underpaid + Food Taster Laureate + + + 1,004 + 8-2013 + B1 + Fairly Paid + Historian for Schools + + + 1,005 + 6-2004 + B1 + Fairly Paid + Philosopher in Chief + + + 1,006 + 12-2020 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 1,007 + 7-2008 + A2 + Overpaid + Author Extraordinaire + + + 1,008 + 2-1995 + C2 + Slave Labour + Sports Mascot of Doom + + + 1,009 + 8-2016 + C2 + Slave Labour + Author Laureate + + + 1,010 + 1-2006 + B2 + Underpaid + Assassin in Chief + + + 1,011 + 5-2008 + B1 + Fairly Paid + Builder for Schools + + + 1,012 + 8-1993 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 1,013 + 11-1993 + B2 + Underpaid + Author (Trainee) + + + 1,014 + 10-2010 + A2 + Overpaid + Assassin (Trainee) + + + 1,015 + 2-1991 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 1,016 + 10-1994 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 1,017 + 4-2015 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 1,018 + 8-2005 + A2 + Overpaid + Vigilante of Parties + + + 1,019 + 10-2015 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 1,020 + 7-2006 + B1 + Fairly Paid + Author in Chief + + + 1,021 + 10-1990 + C2 + Slave Labour + Sports Mascot for Eternity + + + 1,022 + 1-2011 + B1 + Fairly Paid + Historian of Doom + + + 1,023 + 2-2020 + A2 + Overpaid + Author for the Environment + + + 1,024 + 11-2009 + B2 + Underpaid + Builder of Parties + + + 1,025 + 4-2003 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 1,026 + 2-2020 + A1 + Massively Overpaid + Historian for Schools + + + 1,027 + 1-2016 + A2 + Overpaid + Author for Eternity + + + 1,028 + 5-1997 + A2 + Overpaid + Historian Extraordinaire + + + 1,029 + 9-2006 + A1 + Massively Overpaid + Historian for the Environment + + + 1,030 + 5-1991 + C2 + Slave Labour + Software Developer in Chief + + + 1,031 + 4-2007 + B2 + Underpaid + Assassin Trainer + + + 1,032 + 10-2018 + C2 + Slave Labour + Food Taster of Parties + + + 1,033 + 5-2015 + B1 + Fairly Paid + Builder of Doom + + + 1,034 + 11-2023 + B1 + Fairly Paid + Vigilante for the Environment + + + 1,035 + 8-2010 + A1 + Massively Overpaid + Builder in Chief + + + 1,036 + 4-2004 + C2 + Slave Labour + Builder for Schools + + + 1,037 + 2-1992 + A2 + Overpaid + Philosopher in Chief + + + 1,038 + 5-1994 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 1,039 + 8-2012 + C1 + Massively Underpaid + Software Developer Laureate + + + 1,040 + 5-1992 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 1,041 + 2-2016 + C1 + Massively Underpaid + Historian of Doom + + + 1,042 + 6-2000 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 1,043 + 8-2000 + B1 + Fairly Paid + Historian of Parties + + + 1,044 + 7-2022 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 1,045 + 9-1992 + B2 + Underpaid + Skydiving Instructor in Chief + + + 1,046 + 5-2015 + B1 + Fairly Paid + Food Taster Trainer + + + 1,047 + 10-2017 + A2 + Overpaid + Food Taster of Cattle + + + 1,048 + 1-1998 + B2 + Underpaid + Sports Mascot of Cattle + + + 1,049 + 10-2004 + B2 + Underpaid + Author of Cattle + + + 1,050 + 5-2020 + A2 + Overpaid + Philosopher Extraordinaire + + + 1,051 + 7-2023 + A1 + Massively Overpaid + Author Laureate + + + 1,052 + 10-1999 + B2 + Underpaid + Sports Mascot of Doom + + + 1,053 + 4-2013 + B1 + Fairly Paid + Assassin in Chief + + + 1,054 + 10-2017 + B1 + Fairly Paid + Author for Eternity + + + 1,055 + 2-2018 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 1,056 + 2-2010 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 1,057 + 12-2023 + A2 + Overpaid + Philosopher of Parties + + + 1,058 + 4-2016 + B1 + Fairly Paid + Historian for the Environment + + + 1,059 + 1-2018 + B2 + Underpaid + Vigilante of Cattle + + + 1,060 + 2-2007 + A1 + Massively Overpaid + Builder of Doom + + + 1,061 + 5-2008 + A1 + Massively Overpaid + Food Taster for the Environment + + + 1,062 + 7-2011 + B2 + Underpaid + Vigilante of Parties + + + 1,063 + 4-2013 + C1 + Massively Underpaid + Software Developer of Cattle + + + 1,064 + 11-2001 + C1 + Massively Underpaid + Author Laureate + + + 1,065 + 5-2018 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 1,066 + 6-1990 + C2 + Slave Labour + Historian (Trainee) + + + 1,067 + 12-1993 + C2 + Slave Labour + Sports Mascot for the Environment + + + 1,068 + 2-1990 + A2 + Overpaid + Philosopher Trainer + + + 1,069 + 6-1995 + C2 + Slave Labour + Sports Mascot of Parties + + + 1,070 + 4-2001 + C1 + Massively Underpaid + Author Extraordinaire + + + 1,071 + 12-1990 + B1 + Fairly Paid + Author for the Environment + + + 1,072 + 5-2020 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 1,073 + 8-1999 + A1 + Massively Overpaid + Historian Extraordinaire + + + 1,074 + 9-2009 + B2 + Underpaid + Assassin of Doom + + + 1,075 + 11-2023 + A2 + Overpaid + Sports Mascot for Schools + + + 1,076 + 10-2015 + B2 + Underpaid + Food Taster Laureate + + + 1,077 + 7-2018 + C2 + Slave Labour + Sports Mascot for Schools + + + 1,078 + 11-2004 + C2 + Slave Labour + Software Developer Extraordinaire + + + 1,079 + 2-2014 + B2 + Underpaid + Food Taster for Eternity + + + 1,080 + 2-2023 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 1,081 + 9-2004 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 1,082 + 8-2010 + C2 + Slave Labour + Software Developer Trainer + + + 1,083 + 2-1999 + B2 + Underpaid + Builder (Trainee) + + + 1,084 + 8-1996 + B2 + Underpaid + Software Developer in Chief + + + 1,085 + 12-2012 + B1 + Fairly Paid + Vigilante in Chief + + + 1,086 + 12-2008 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 1,087 + 3-2017 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 1,088 + 7-2002 + A2 + Overpaid + Philosopher of Cattle + + + 1,089 + 9-2009 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 1,090 + 8-2021 + A1 + Massively Overpaid + Philosopher for the Environment + + + 1,091 + 11-1999 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 1,092 + 8-2003 + A1 + Massively Overpaid + Food Taster of Doom + + + 1,093 + 12-2017 + A1 + Massively Overpaid + Assassin (Trainee) + + + 1,094 + 10-2006 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 1,095 + 6-2021 + A2 + Overpaid + Philosopher of Doom + + + 1,096 + 5-2006 + A2 + Overpaid + Sports Mascot Extraordinaire + + + 1,097 + 12-2002 + B2 + Underpaid + Assassin for Schools + + + 1,098 + 5-1992 + B2 + Underpaid + Builder Trainer + + + 1,099 + 2-2010 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 1,100 + 6-2022 + C1 + Massively Underpaid + Philosopher Laureate + + + 1,101 + 2-1997 + B2 + Underpaid + Builder of Cattle + + + 1,102 + 5-2021 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 1,103 + 11-2006 + C1 + Massively Underpaid + Software Developer for Eternity + + + 1,104 + 9-1996 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 1,105 + 7-2015 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 1,106 + 4-2003 + B2 + Underpaid + Historian of Cattle + + + 1,107 + 9-2007 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 1,108 + 12-1991 + B2 + Underpaid + Author Trainer + + + 1,109 + 11-2017 + B1 + Fairly Paid + Assassin of Cattle + + + 1,110 + 4-2003 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 1,111 + 7-1990 + B2 + Underpaid + Philosopher for Schools + + + 1,112 + 9-1999 + C2 + Slave Labour + Assassin (Trainee) + + + 1,113 + 1-1999 + C2 + Slave Labour + Author Trainer + + + 1,114 + 4-1991 + B2 + Underpaid + Assassin Laureate + + + 1,115 + 12-2002 + B1 + Fairly Paid + Software Developer (Trainee) + + + 1,116 + 3-1998 + C1 + Massively Underpaid + Assassin for Schools + + + 1,117 + 1-1998 + B1 + Fairly Paid + Software Developer of Cattle + + + 1,118 + 6-1992 + A1 + Massively Overpaid + Assassin Laureate + + + 1,119 + 5-2005 + A2 + Overpaid + Historian Trainer + + + 1,120 + 11-1993 + A1 + Massively Overpaid + Philosopher for Eternity + + + 1,121 + 6-2012 + A2 + Overpaid + Food Taster in Chief + + + 1,122 + 3-2020 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 1,123 + 3-1994 + C1 + Massively Underpaid + Philosopher of Doom + + + 1,124 + 3-2009 + B2 + Underpaid + Vigilante (Trainee) + + + 1,125 + 9-2013 + C2 + Slave Labour + Historian (Trainee) + + + 1,126 + 11-2009 + C2 + Slave Labour + Vigilante in Chief + + + 1,127 + 6-2005 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 1,128 + 1-2023 + C2 + Slave Labour + Vigilante Trainer + + + 1,129 + 10-2009 + A2 + Overpaid + Historian Laureate + + + 1,130 + 8-2013 + B1 + Fairly Paid + Historian Trainer + + + 1,131 + 2-2020 + A2 + Overpaid + Builder in Chief + + + 1,132 + 9-2011 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 1,133 + 2-1997 + B1 + Fairly Paid + Food Taster (Trainee) + + + 1,134 + 1-1994 + B2 + Underpaid + Historian Trainer + + + 1,135 + 9-2004 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 1,136 + 2-1992 + C1 + Massively Underpaid + Food Taster for Schools + + + 1,137 + 2-2017 + B1 + Fairly Paid + Vigilante Laureate + + + 1,138 + 12-1997 + B1 + Fairly Paid + Historian of Parties + + + 1,139 + 4-2004 + A2 + Overpaid + Builder of Parties + + + 1,140 + 1-1991 + A2 + Overpaid + Vigilante in Chief + + + 1,141 + 12-2010 + A1 + Massively Overpaid + Food Taster Laureate + + + 1,142 + 4-2021 + A2 + Overpaid + Builder in Chief + + + 1,143 + 12-2016 + B1 + Fairly Paid + Food Taster of Parties + + + 1,144 + 1-2008 + A2 + Overpaid + Assassin in Chief + + + 1,145 + 11-2021 + A1 + Massively Overpaid + Vigilante of Doom + + + 1,146 + 11-2005 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 1,147 + 4-1992 + A2 + Overpaid + Vigilante of Cattle + + + 1,148 + 10-2005 + C2 + Slave Labour + Food Taster Laureate + + + 1,149 + 9-2015 + A2 + Overpaid + Sports Mascot Trainer + + + 1,150 + 1-2022 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 1,151 + 8-1990 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 1,152 + 2-1991 + A2 + Overpaid + Assassin for Schools + + + 1,153 + 4-2020 + C2 + Slave Labour + Builder Extraordinaire + + + 1,154 + 5-1997 + B2 + Underpaid + Assassin Laureate + + + 1,155 + 8-2019 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 1,156 + 7-2002 + B1 + Fairly Paid + Assassin Trainer + + + 1,157 + 10-2021 + C2 + Slave Labour + Builder of Doom + + + 1,158 + 4-1994 + A2 + Overpaid + Sports Mascot Trainer + + + 1,159 + 12-2000 + C2 + Slave Labour + Assassin of Doom + + + 1,160 + 10-1992 + A1 + Massively Overpaid + Vigilante of Parties + + + 1,161 + 12-2015 + B2 + Underpaid + Food Taster of Doom + + + 1,162 + 3-2004 + B2 + Underpaid + Builder for the Environment + + + 1,163 + 2-1996 + A2 + Overpaid + Sports Mascot of Cattle + + + 1,164 + 1-1990 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 1,165 + 10-2007 + B2 + Underpaid + Builder for Eternity + + + 1,166 + 11-2013 + C1 + Massively Underpaid + Software Developer Laureate + + + 1,167 + 3-2005 + C1 + Massively Underpaid + Builder Trainer + + + 1,168 + 7-1993 + B2 + Underpaid + Sports Mascot for Schools + + + 1,169 + 9-1994 + B2 + Underpaid + Author in Chief + + + 1,170 + 1-2021 + B2 + Underpaid + Assassin in Chief + + + 1,171 + 9-1993 + C1 + Massively Underpaid + Philosopher for Schools + + + 1,172 + 5-2020 + A1 + Massively Overpaid + Food Taster of Cattle + + + 1,173 + 5-2013 + C1 + Massively Underpaid + Builder Trainer + + + 1,174 + 12-1997 + C1 + Massively Underpaid + Assassin for the Environment + + + 1,175 + 1-1992 + B2 + Underpaid + Vigilante Extraordinaire + + + 1,176 + 2-2014 + A2 + Overpaid + Author (Trainee) + + + 1,177 + 2-2004 + B2 + Underpaid + Assassin for Schools + + + 1,178 + 3-1991 + B1 + Fairly Paid + Food Taster of Cattle + + + 1,179 + 12-1990 + C1 + Massively Underpaid + Historian for the Environment + + + 1,180 + 1-2020 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 1,181 + 2-2019 + A1 + Massively Overpaid + Historian for Eternity + + + 1,182 + 11-1991 + A1 + Massively Overpaid + Author for Schools + + + 1,183 + 1-1990 + A2 + Overpaid + Assassin Laureate + + + 1,184 + 3-2003 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 1,185 + 6-2003 + A1 + Massively Overpaid + Vigilante for Schools + + + 1,186 + 6-1990 + B1 + Fairly Paid + Philosopher for Eternity + + + 1,187 + 3-1993 + B2 + Underpaid + Philosopher of Parties + + + 1,188 + 8-1993 + A2 + Overpaid + Food Taster in Chief + + + 1,189 + 6-2018 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 1,190 + 11-2020 + C2 + Slave Labour + Assassin for Schools + + + 1,191 + 5-2020 + B2 + Underpaid + Assassin of Parties + + + 1,192 + 2-2021 + B2 + Underpaid + Food Taster (Trainee) + + + 1,193 + 9-2021 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 1,194 + 9-2005 + A1 + Massively Overpaid + Vigilante for Eternity + + + 1,195 + 8-2021 + B2 + Underpaid + Software Developer in Chief + + + 1,196 + 12-2022 + C2 + Slave Labour + Software Developer of Doom + + + 1,197 + 10-2022 + A1 + Massively Overpaid + Historian for Eternity + + + 1,198 + 12-1995 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 1,199 + 12-2015 + A2 + Overpaid + Vigilante of Doom + + + 1,200 + 5-2017 + A2 + Overpaid + Skydiving Instructor in Chief + + + 1,201 + 5-2012 + A2 + Overpaid + Vigilante in Chief + + + 1,202 + 2-2007 + C1 + Massively Underpaid + Software Developer for the Environment + + + 1,203 + 3-2009 + C1 + Massively Underpaid + Historian Trainer + + + 1,204 + 12-2001 + C1 + Massively Underpaid + Food Taster of Parties + + + 1,205 + 8-2014 + C1 + Massively Underpaid + Builder Laureate + + + 1,206 + 3-2001 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 1,207 + 6-1999 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 1,208 + 10-2003 + B2 + Underpaid + Vigilante for Eternity + + + 1,209 + 7-2001 + A2 + Overpaid + Software Developer of Doom + + + 1,210 + 9-2018 + C2 + Slave Labour + Food Taster of Cattle + + + 1,211 + 9-1998 + C1 + Massively Underpaid + Vigilante for the Environment + + + 1,212 + 12-2022 + A2 + Overpaid + Philosopher of Cattle + + + 1,213 + 5-2009 + C2 + Slave Labour + Assassin of Doom + + + 1,214 + 2-2013 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 1,215 + 11-2017 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 1,216 + 7-2011 + C2 + Slave Labour + Sports Mascot in Chief + + + 1,217 + 5-2009 + A1 + Massively Overpaid + Builder for the Environment + + + 1,218 + 1-2019 + B1 + Fairly Paid + Vigilante Laureate + + + 1,219 + 4-1992 + C1 + Massively Underpaid + Philosopher of Doom + + + 1,220 + 10-2009 + A1 + Massively Overpaid + Author for the Environment + + + 1,221 + 4-1994 + A2 + Overpaid + Philosopher for Eternity + + + 1,222 + 3-2014 + C1 + Massively Underpaid + Builder (Trainee) + + + 1,223 + 12-2013 + A2 + Overpaid + Vigilante of Cattle + + + 1,224 + 5-2011 + A2 + Overpaid + Vigilante of Cattle + + + 1,225 + 10-2006 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 1,226 + 3-2010 + C1 + Massively Underpaid + Philosopher of Cattle + + + 1,227 + 5-1990 + B2 + Underpaid + Assassin for the Environment + + + 1,228 + 8-2005 + C1 + Massively Underpaid + Software Developer of Cattle + + + 1,229 + 10-2017 + C2 + Slave Labour + Builder Trainer + + + 1,230 + 10-1995 + C1 + Massively Underpaid + Assassin for the Environment + + + 1,231 + 7-2017 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 1,232 + 7-2019 + A2 + Overpaid + Historian (Trainee) + + + 1,233 + 10-2020 + A1 + Massively Overpaid + Software Developer of Cattle + + + 1,234 + 1-2014 + C1 + Massively Underpaid + Builder Laureate + + + 1,235 + 12-2012 + A2 + Overpaid + Vigilante of Cattle + + + 1,236 + 11-2020 + C2 + Slave Labour + Historian for Eternity + + + 1,237 + 3-2004 + C2 + Slave Labour + Software Developer of Doom + + + 1,238 + 6-2016 + A2 + Overpaid + Philosopher for Schools + + + 1,239 + 3-1999 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 1,240 + 6-1999 + A1 + Massively Overpaid + Software Developer for Schools + + + 1,241 + 9-2008 + C1 + Massively Underpaid + Builder Trainer + + + 1,242 + 4-2015 + B1 + Fairly Paid + Builder of Parties + + + 1,243 + 3-1995 + C1 + Massively Underpaid + Assassin Trainer + + + 1,244 + 11-1990 + A2 + Overpaid + Historian Trainer + + + 1,245 + 7-2002 + B1 + Fairly Paid + Author of Parties + + + 1,246 + 6-2017 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 1,247 + 2-1997 + A2 + Overpaid + Vigilante for Eternity + + + 1,248 + 1-2023 + C2 + Slave Labour + Software Developer of Parties + + + 1,249 + 11-2004 + A2 + Overpaid + Builder Laureate + + + 1,250 + 11-2004 + C2 + Slave Labour + Philosopher Extraordinaire + + + 1,251 + 10-2019 + C2 + Slave Labour + Food Taster of Cattle + + + 1,252 + 1-2023 + A2 + Overpaid + Vigilante for the Environment + + + 1,253 + 5-1995 + A2 + Overpaid + Builder (Trainee) + + + 1,254 + 5-1994 + C2 + Slave Labour + Assassin Trainer + + + 1,255 + 8-1996 + B2 + Underpaid + Food Taster Extraordinaire + + + 1,256 + 4-1992 + B1 + Fairly Paid + Food Taster of Parties + + + 1,257 + 10-2008 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 1,258 + 5-2014 + A2 + Overpaid + Software Developer for the Environment + + + 1,259 + 8-2020 + B1 + Fairly Paid + Historian Extraordinaire + + + 1,260 + 11-2000 + C2 + Slave Labour + Builder of Doom + + + 1,261 + 1-2000 + B2 + Underpaid + Assassin (Trainee) + + + 1,262 + 7-1992 + A2 + Overpaid + Vigilante of Cattle + + + 1,263 + 10-2019 + C2 + Slave Labour + Author Trainer + + + 1,264 + 6-2000 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 1,265 + 6-2000 + B1 + Fairly Paid + Assassin for Eternity + + + 1,266 + 4-2020 + B1 + Fairly Paid + Software Developer of Cattle + + + 1,267 + 12-2010 + C1 + Massively Underpaid + Philosopher for Eternity + + + 1,268 + 4-2004 + A1 + Massively Overpaid + Software Developer of Parties + + + 1,269 + 11-1990 + B1 + Fairly Paid + Historian for Schools + + + 1,270 + 7-2019 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 1,271 + 8-2009 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 1,272 + 4-2023 + A2 + Overpaid + Vigilante in Chief + + + 1,273 + 8-1995 + A2 + Overpaid + Builder Extraordinaire + + + 1,274 + 10-2022 + C2 + Slave Labour + Historian Trainer + + + 1,275 + 9-2003 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 1,276 + 2-2015 + C1 + Massively Underpaid + Historian Laureate + + + 1,277 + 6-2010 + C1 + Massively Underpaid + Author of Doom + + + 1,278 + 1-2008 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 1,279 + 6-2013 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 1,280 + 9-2009 + A1 + Massively Overpaid + Author of Doom + + + 1,281 + 11-2012 + C2 + Slave Labour + Vigilante (Trainee) + + + 1,282 + 12-2014 + A2 + Overpaid + Assassin of Cattle + + + 1,283 + 6-2005 + C1 + Massively Underpaid + Food Taster of Doom + + + 1,284 + 1-2023 + A2 + Overpaid + Author for Eternity + + + 1,285 + 9-1995 + B2 + Underpaid + Skydiving Instructor Laureate + + + 1,286 + 11-2015 + B2 + Underpaid + Author of Cattle + + + 1,287 + 3-1991 + B2 + Underpaid + Builder Extraordinaire + + + 1,288 + 2-1991 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 1,289 + 10-1996 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 1,290 + 3-1997 + A1 + Massively Overpaid + Assassin of Parties + + + 1,291 + 10-2010 + B2 + Underpaid + Skydiving Instructor Trainer + + + 1,292 + 7-1996 + C2 + Slave Labour + Vigilante of Doom + + + 1,293 + 5-1991 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 1,294 + 2-1990 + C1 + Massively Underpaid + Author Laureate + + + 1,295 + 4-2005 + C1 + Massively Underpaid + Historian Laureate + + + 1,296 + 9-2000 + A2 + Overpaid + Software Developer of Parties + + + 1,297 + 11-2006 + C2 + Slave Labour + Vigilante Trainer + + + 1,298 + 4-2009 + C1 + Massively Underpaid + Philosopher for the Environment + + + 1,299 + 9-2022 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 1,300 + 4-2008 + C2 + Slave Labour + Food Taster for Eternity + + + 1,301 + 12-1996 + B1 + Fairly Paid + Philosopher of Parties + + + 1,302 + 8-2016 + C1 + Massively Underpaid + Food Taster of Doom + + + 1,303 + 6-2019 + B2 + Underpaid + Food Taster Trainer + + + 1,304 + 12-2011 + B1 + Fairly Paid + Author for Schools + + + 1,305 + 5-1996 + C1 + Massively Underpaid + Philosopher for the Environment + + + 1,306 + 1-1993 + A2 + Overpaid + Food Taster (Trainee) + + + 1,307 + 8-2004 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 1,308 + 1-2013 + C1 + Massively Underpaid + Assassin of Cattle + + + 1,309 + 11-2019 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 1,310 + 1-2004 + A1 + Massively Overpaid + Food Taster for Eternity + + + 1,311 + 11-2007 + C2 + Slave Labour + Philosopher of Cattle + + + 1,312 + 5-2011 + C1 + Massively Underpaid + Author of Doom + + + 1,313 + 6-1992 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 1,314 + 12-1992 + A1 + Massively Overpaid + Historian in Chief + + + 1,315 + 4-1995 + C1 + Massively Underpaid + Food Taster of Cattle + + + 1,316 + 10-1992 + C1 + Massively Underpaid + Philosopher of Parties + + + 1,317 + 4-2015 + A2 + Overpaid + Food Taster in Chief + + + 1,318 + 7-2010 + B1 + Fairly Paid + Software Developer of Doom + + + 1,319 + 5-2003 + A1 + Massively Overpaid + Philosopher for Eternity + + + 1,320 + 12-2004 + B2 + Underpaid + Author for Eternity + + + 1,321 + 9-2009 + A1 + Massively Overpaid + Vigilante for Eternity + + + 1,322 + 5-2017 + B1 + Fairly Paid + Author of Doom + + + 1,323 + 3-1994 + A1 + Massively Overpaid + Software Developer for Schools + + + 1,324 + 10-2000 + C2 + Slave Labour + Sports Mascot of Cattle + + + 1,325 + 12-2008 + B2 + Underpaid + Skydiving Instructor in Chief + + + 1,326 + 12-2013 + B2 + Underpaid + Historian Trainer + + + 1,327 + 10-2021 + C1 + Massively Underpaid + Builder in Chief + + + 1,328 + 5-2001 + B1 + Fairly Paid + Builder for Schools + + + 1,329 + 9-1997 + B1 + Fairly Paid + Builder for Eternity + + + 1,330 + 8-2009 + C2 + Slave Labour + Software Developer Laureate + + + 1,331 + 9-2022 + C2 + Slave Labour + Philosopher of Parties + + + 1,332 + 11-2017 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 1,333 + 2-2020 + B1 + Fairly Paid + Assassin Extraordinaire + + + 1,334 + 4-2018 + C1 + Massively Underpaid + Historian of Parties + + + 1,335 + 8-2023 + B1 + Fairly Paid + Assassin of Doom + + + 1,336 + 1-1999 + C1 + Massively Underpaid + Author of Doom + + + 1,337 + 3-1990 + C1 + Massively Underpaid + Vigilante of Parties + + + 1,338 + 2-2008 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 1,339 + 7-1993 + A2 + Overpaid + Software Developer for Eternity + + + 1,340 + 12-2011 + B1 + Fairly Paid + Historian Trainer + + + 1,341 + 6-1998 + B2 + Underpaid + Philosopher of Doom + + + 1,342 + 1-1992 + B1 + Fairly Paid + Assassin for Eternity + + + 1,343 + 5-2012 + B1 + Fairly Paid + Historian for the Environment + + + 1,344 + 10-1999 + B2 + Underpaid + Food Taster Laureate + + + 1,345 + 7-2001 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 1,346 + 11-2006 + B2 + Underpaid + Vigilante Extraordinaire + + + 1,347 + 3-2023 + A1 + Massively Overpaid + Food Taster Laureate + + + 1,348 + 8-2002 + A1 + Massively Overpaid + Philosopher for Schools + + + 1,349 + 2-2020 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 1,350 + 1-2002 + C2 + Slave Labour + Philosopher (Trainee) + + + 1,351 + 8-2005 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 1,352 + 10-2005 + B1 + Fairly Paid + Software Developer for the Environment + + + 1,353 + 7-2023 + A1 + Massively Overpaid + Assassin (Trainee) + + + 1,354 + 10-2013 + C2 + Slave Labour + Sports Mascot Trainer + + + 1,355 + 6-2011 + B2 + Underpaid + Software Developer Trainer + + + 1,356 + 10-2013 + A2 + Overpaid + Philosopher Trainer + + + 1,357 + 3-2004 + A1 + Massively Overpaid + Builder Laureate + + + 1,358 + 3-2004 + B2 + Underpaid + Philosopher Laureate + + + 1,359 + 2-1990 + A2 + Overpaid + Historian for the Environment + + + 1,360 + 6-2016 + A1 + Massively Overpaid + Builder for Schools + + + 1,361 + 7-1998 + A2 + Overpaid + Philosopher (Trainee) + + + 1,362 + 2-1995 + B1 + Fairly Paid + Assassin (Trainee) + + + 1,363 + 4-1992 + B1 + Fairly Paid + Sports Mascot in Chief + + + 1,364 + 5-2018 + B2 + Underpaid + Skydiving Instructor for Schools + + + 1,365 + 1-2021 + A2 + Overpaid + Food Taster Extraordinaire + + + 1,366 + 8-2008 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 1,367 + 6-2021 + C1 + Massively Underpaid + Software Developer for Eternity + + + 1,368 + 8-2020 + C1 + Massively Underpaid + Software Developer in Chief + + + 1,369 + 11-2015 + A2 + Overpaid + Food Taster for the Environment + + + 1,370 + 8-2009 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 1,371 + 8-2012 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 1,372 + 3-1994 + C1 + Massively Underpaid + Historian in Chief + + + 1,373 + 6-2013 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 1,374 + 5-2001 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 1,375 + 9-2004 + B2 + Underpaid + Assassin (Trainee) + + + 1,376 + 11-2023 + B1 + Fairly Paid + Assassin for the Environment + + + 1,377 + 11-2014 + B2 + Underpaid + Skydiving Instructor in Chief + + + 1,378 + 12-1996 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 1,379 + 12-2017 + B2 + Underpaid + Software Developer of Parties + + + 1,380 + 8-1999 + C1 + Massively Underpaid + Food Taster of Cattle + + + 1,381 + 3-2002 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 1,382 + 3-2023 + C2 + Slave Labour + Software Developer for Schools + + + 1,383 + 9-2017 + A2 + Overpaid + Software Developer of Cattle + + + 1,384 + 7-2011 + B2 + Underpaid + Author Extraordinaire + + + 1,385 + 5-2022 + A2 + Overpaid + Vigilante (Trainee) + + + 1,386 + 7-2013 + C2 + Slave Labour + Vigilante for Eternity + + + 1,387 + 8-2015 + C2 + Slave Labour + Philosopher for Schools + + + 1,388 + 5-1993 + A2 + Overpaid + Software Developer in Chief + + + 1,389 + 3-2011 + A1 + Massively Overpaid + Author of Parties + + + 1,390 + 2-2004 + A2 + Overpaid + Historian for Schools + + + 1,391 + 10-2018 + C2 + Slave Labour + Historian for Schools + + + 1,392 + 3-2021 + A1 + Massively Overpaid + Builder for the Environment + + + 1,393 + 9-1999 + B1 + Fairly Paid + Software Developer of Parties + + + 1,394 + 4-2001 + C2 + Slave Labour + Software Developer Extraordinaire + + + 1,395 + 4-1995 + A1 + Massively Overpaid + Assassin of Doom + + + 1,396 + 2-2011 + A2 + Overpaid + Sports Mascot in Chief + + + 1,397 + 5-2003 + B1 + Fairly Paid + Historian Trainer + + + 1,398 + 2-2013 + A2 + Overpaid + Assassin Trainer + + + 1,399 + 12-2008 + C1 + Massively Underpaid + Software Developer of Cattle + + + 1,400 + 8-2019 + B2 + Underpaid + Author of Cattle + + + 1,401 + 12-2023 + A2 + Overpaid + Author for the Environment + + + 1,402 + 9-2003 + C2 + Slave Labour + Vigilante in Chief + + + 1,403 + 12-1995 + B1 + Fairly Paid + Historian for the Environment + + + 1,404 + 10-1995 + B1 + Fairly Paid + Historian of Cattle + + + 1,405 + 5-1990 + A1 + Massively Overpaid + Food Taster of Cattle + + + 1,406 + 10-2010 + B2 + Underpaid + Assassin Laureate + + + 1,407 + 10-2006 + A1 + Massively Overpaid + Builder in Chief + + + 1,408 + 1-1992 + B1 + Fairly Paid + Historian of Doom + + + 1,409 + 5-2014 + C2 + Slave Labour + Historian Laureate + + + 1,410 + 12-2020 + C2 + Slave Labour + Assassin Trainer + + + 1,411 + 7-2016 + B2 + Underpaid + Author Trainer + + + 1,412 + 4-2013 + A1 + Massively Overpaid + Builder Extraordinaire + + + 1,413 + 3-2023 + B1 + Fairly Paid + Software Developer in Chief + + + 1,414 + 11-1999 + B2 + Underpaid + Skydiving Instructor of Doom + + + 1,415 + 5-2005 + B2 + Underpaid + Food Taster in Chief + + + 1,416 + 3-1993 + C2 + Slave Labour + Software Developer (Trainee) + + + 1,417 + 6-2009 + C2 + Slave Labour + Software Developer of Doom + + + 1,418 + 12-2013 + C2 + Slave Labour + Historian in Chief + + + 1,419 + 5-1991 + A2 + Overpaid + Food Taster of Parties + + + 1,420 + 10-2023 + A1 + Massively Overpaid + Software Developer Laureate + + + 1,421 + 6-2011 + C2 + Slave Labour + Software Developer in Chief + + + 1,422 + 6-2000 + A2 + Overpaid + Food Taster Extraordinaire + + + 1,423 + 5-2002 + C2 + Slave Labour + Food Taster for Schools + + + 1,424 + 8-1998 + B1 + Fairly Paid + Food Taster of Doom + + + 1,425 + 7-1993 + B2 + Underpaid + Philosopher Laureate + + + 1,426 + 7-2015 + A1 + Massively Overpaid + Food Taster Laureate + + + 1,427 + 2-2005 + A2 + Overpaid + Philosopher for Eternity + + + 1,428 + 3-2021 + C2 + Slave Labour + Philosopher for the Environment + + + 1,429 + 6-2009 + B1 + Fairly Paid + Builder for Schools + + + 1,430 + 4-2015 + A2 + Overpaid + Author in Chief + + + 1,431 + 10-2017 + A2 + Overpaid + Software Developer of Doom + + + 1,432 + 2-2000 + C2 + Slave Labour + Historian of Cattle + + + 1,433 + 6-2011 + A1 + Massively Overpaid + Food Taster for Eternity + + + 1,434 + 11-2017 + A1 + Massively Overpaid + Author Trainer + + + 1,435 + 2-2003 + B2 + Underpaid + Sports Mascot for the Environment + + + 1,436 + 1-1999 + B2 + Underpaid + Software Developer Laureate + + + 1,437 + 10-1996 + A1 + Massively Overpaid + Food Taster Trainer + + + 1,438 + 3-2019 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 1,439 + 4-1990 + C2 + Slave Labour + Builder for the Environment + + + 1,440 + 7-2004 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 1,441 + 1-2010 + B1 + Fairly Paid + Software Developer Trainer + + + 1,442 + 11-2020 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 1,443 + 7-2013 + B1 + Fairly Paid + Builder for Eternity + + + 1,444 + 9-1998 + A2 + Overpaid + Author (Trainee) + + + 1,445 + 10-1990 + C1 + Massively Underpaid + Philosopher of Cattle + + + 1,446 + 8-1994 + C2 + Slave Labour + Author of Parties + + + 1,447 + 3-2009 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 1,448 + 7-2003 + A2 + Overpaid + Assassin for Schools + + + 1,449 + 7-2023 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 1,450 + 9-2005 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 1,451 + 12-2023 + A2 + Overpaid + Assassin for Eternity + + + 1,452 + 4-2014 + C2 + Slave Labour + Historian of Doom + + + 1,453 + 9-2023 + B1 + Fairly Paid + Vigilante for Schools + + + 1,454 + 12-1997 + C1 + Massively Underpaid + Builder of Cattle + + + 1,455 + 7-2015 + C1 + Massively Underpaid + Software Developer for the Environment + + + 1,456 + 11-2005 + C2 + Slave Labour + Builder of Doom + + + 1,457 + 3-1995 + C2 + Slave Labour + Assassin in Chief + + + 1,458 + 7-2012 + C1 + Massively Underpaid + Historian Extraordinaire + + + 1,459 + 1-2013 + C1 + Massively Underpaid + Builder Laureate + + + 1,460 + 10-1993 + A1 + Massively Overpaid + Vigilante of Cattle + + + 1,461 + 7-1995 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 1,462 + 10-1990 + C1 + Massively Underpaid + Assassin in Chief + + + 1,463 + 12-1990 + B1 + Fairly Paid + Author for Schools + + + 1,464 + 9-1990 + C2 + Slave Labour + Vigilante Trainer + + + 1,465 + 2-2002 + C1 + Massively Underpaid + Philosopher Trainer + + + 1,466 + 11-2004 + C2 + Slave Labour + Software Developer for Eternity + + + 1,467 + 8-2017 + C2 + Slave Labour + Software Developer Trainer + + + 1,468 + 1-2014 + C2 + Slave Labour + Software Developer for the Environment + + + 1,469 + 4-1992 + A1 + Massively Overpaid + Author Laureate + + + 1,470 + 12-1996 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 1,471 + 7-2000 + C2 + Slave Labour + Philosopher of Doom + + + 1,472 + 3-2011 + B1 + Fairly Paid + Philosopher in Chief + + + 1,473 + 4-2001 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 1,474 + 5-2011 + B2 + Underpaid + Vigilante Trainer + + + 1,475 + 9-2007 + B1 + Fairly Paid + Vigilante of Parties + + + 1,476 + 8-1999 + C2 + Slave Labour + Assassin of Doom + + + 1,477 + 7-2004 + A2 + Overpaid + Builder of Doom + + + 1,478 + 11-2019 + B1 + Fairly Paid + Food Taster for Eternity + + + 1,479 + 3-2019 + B1 + Fairly Paid + Builder Trainer + + + 1,480 + 3-2010 + C2 + Slave Labour + Assassin Laureate + + + 1,481 + 11-2018 + C2 + Slave Labour + Philosopher for the Environment + + + 1,482 + 1-1993 + A1 + Massively Overpaid + Author of Parties + + + 1,483 + 6-1990 + C1 + Massively Underpaid + Historian of Doom + + + 1,484 + 5-1999 + C2 + Slave Labour + Food Taster Extraordinaire + + + 1,485 + 4-1994 + C1 + Massively Underpaid + Vigilante for Eternity + + + 1,486 + 9-2015 + A1 + Massively Overpaid + Author for the Environment + + + 1,487 + 12-1991 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 1,488 + 5-1996 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 1,489 + 6-2007 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 1,490 + 7-2010 + B1 + Fairly Paid + Assassin Laureate + + + 1,491 + 7-2005 + B2 + Underpaid + Philosopher of Doom + + + 1,492 + 8-1992 + C1 + Massively Underpaid + Vigilante of Parties + + + 1,493 + 2-2015 + B1 + Fairly Paid + Sports Mascot in Chief + + + 1,494 + 6-2020 + A1 + Massively Overpaid + Historian in Chief + + + 1,495 + 3-2013 + A2 + Overpaid + Food Taster for Eternity + + + 1,496 + 10-2007 + B1 + Fairly Paid + Author in Chief + + + 1,497 + 7-2009 + B2 + Underpaid + Builder in Chief + + + 1,498 + 1-1991 + C2 + Slave Labour + Author for the Environment + + + 1,499 + 7-2019 + C2 + Slave Labour + Author (Trainee) + + + 1,500 + 4-1997 + B1 + Fairly Paid + Assassin Laureate + + + 1,501 + 5-2016 + B1 + Fairly Paid + Vigilante for Schools + + + 1,502 + 10-2017 + C1 + Massively Underpaid + Assassin Trainer + + + 1,503 + 11-2012 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 1,504 + 1-2013 + C2 + Slave Labour + Author for Schools + + + 1,505 + 5-2017 + B2 + Underpaid + Historian of Doom + + + 1,506 + 9-2021 + A2 + Overpaid + Builder Trainer + + + 1,507 + 1-2013 + B1 + Fairly Paid + Software Developer in Chief + + + 1,508 + 11-2022 + C1 + Massively Underpaid + Vigilante of Cattle + + + 1,509 + 4-2013 + A1 + Massively Overpaid + Food Taster of Cattle + + + 1,510 + 2-2010 + B1 + Fairly Paid + Historian (Trainee) + + + 1,511 + 9-1999 + A2 + Overpaid + Sports Mascot in Chief + + + 1,512 + 10-2012 + B2 + Underpaid + Software Developer Trainer + + + 1,513 + 2-1999 + B2 + Underpaid + Software Developer for Eternity + + + 1,514 + 6-2004 + B1 + Fairly Paid + Philosopher for the Environment + + + 1,515 + 7-2008 + B2 + Underpaid + Builder in Chief + + + 1,516 + 12-2021 + B2 + Underpaid + Author (Trainee) + + + 1,517 + 11-1997 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 1,518 + 8-1997 + B2 + Underpaid + Sports Mascot in Chief + + + 1,519 + 4-2004 + C2 + Slave Labour + Historian in Chief + + + 1,520 + 8-2015 + A2 + Overpaid + Software Developer Laureate + + + 1,521 + 10-2010 + B1 + Fairly Paid + Author Laureate + + + 1,522 + 1-1999 + B1 + Fairly Paid + Author for Schools + + + 1,523 + 9-1990 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 1,524 + 10-2017 + A2 + Overpaid + Sports Mascot Trainer + + + 1,525 + 2-2012 + B2 + Underpaid + Author (Trainee) + + + 1,526 + 10-2018 + C1 + Massively Underpaid + Author of Doom + + + 1,527 + 1-2002 + B2 + Underpaid + Skydiving Instructor Trainer + + + 1,528 + 5-2007 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 1,529 + 2-2018 + A2 + Overpaid + Author (Trainee) + + + 1,530 + 9-2015 + C1 + Massively Underpaid + Food Taster Trainer + + + 1,531 + 2-1991 + A1 + Massively Overpaid + Software Developer Trainer + + + 1,532 + 11-2021 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 1,533 + 11-2014 + B2 + Underpaid + Builder of Cattle + + + 1,534 + 8-2021 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 1,535 + 4-2022 + C1 + Massively Underpaid + Author for Schools + + + 1,536 + 6-2018 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 1,537 + 9-1993 + A2 + Overpaid + Historian for Schools + + + 1,538 + 8-1997 + B1 + Fairly Paid + Assassin of Cattle + + + 1,539 + 6-1997 + B2 + Underpaid + Philosopher in Chief + + + 1,540 + 3-2014 + C1 + Massively Underpaid + Philosopher Trainer + + + 1,541 + 3-2023 + A1 + Massively Overpaid + Assassin of Doom + + + 1,542 + 7-2019 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 1,543 + 5-2021 + B2 + Underpaid + Skydiving Instructor Laureate + + + 1,544 + 5-2004 + A2 + Overpaid + Philosopher Laureate + + + 1,545 + 1-2009 + A2 + Overpaid + Sports Mascot of Doom + + + 1,546 + 2-1997 + B2 + Underpaid + Builder for the Environment + + + 1,547 + 10-2002 + C1 + Massively Underpaid + Software Developer Trainer + + + 1,548 + 12-2020 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 1,549 + 2-2001 + A1 + Massively Overpaid + Software Developer of Doom + + + 1,550 + 6-1996 + A2 + Overpaid + Software Developer Trainer + + + 1,551 + 7-1990 + B2 + Underpaid + Builder of Parties + + + 1,552 + 12-1994 + A2 + Overpaid + Software Developer Extraordinaire + + + 1,553 + 2-2006 + C2 + Slave Labour + Vigilante for the Environment + + + 1,554 + 11-1990 + A2 + Overpaid + Sports Mascot in Chief + + + 1,555 + 7-2008 + C2 + Slave Labour + Vigilante Extraordinaire + + + 1,556 + 6-2002 + C2 + Slave Labour + Historian for the Environment + + + 1,557 + 10-2006 + B2 + Underpaid + Vigilante Extraordinaire + + + 1,558 + 9-2020 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 1,559 + 8-2003 + C1 + Massively Underpaid + Vigilante Laureate + + + 1,560 + 1-1997 + A1 + Massively Overpaid + Vigilante in Chief + + + 1,561 + 2-1999 + A1 + Massively Overpaid + Builder of Parties + + + 1,562 + 6-2007 + A1 + Massively Overpaid + Philosopher for Schools + + + 1,563 + 2-2022 + B2 + Underpaid + Software Developer of Parties + + + 1,564 + 10-1991 + B1 + Fairly Paid + Author (Trainee) + + + 1,565 + 7-2006 + B2 + Underpaid + Skydiving Instructor Laureate + + + 1,566 + 2-2000 + B2 + Underpaid + Builder of Doom + + + 1,567 + 2-2014 + A1 + Massively Overpaid + Software Developer of Cattle + + + 1,568 + 5-1991 + C2 + Slave Labour + Sports Mascot Trainer + + + 1,569 + 11-1998 + B1 + Fairly Paid + Historian of Parties + + + 1,570 + 2-2011 + A1 + Massively Overpaid + Builder for Eternity + + + 1,571 + 5-2013 + C1 + Massively Underpaid + Vigilante of Cattle + + + 1,572 + 9-2022 + B2 + Underpaid + Builder of Cattle + + + 1,573 + 6-1994 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 1,574 + 10-2017 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 1,575 + 11-2002 + A1 + Massively Overpaid + Vigilante Trainer + + + 1,576 + 11-1990 + B1 + Fairly Paid + Philosopher (Trainee) + + + 1,577 + 3-2012 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 1,578 + 6-1990 + A2 + Overpaid + Builder for Schools + + + 1,579 + 12-1992 + A2 + Overpaid + Skydiving Instructor Laureate + + + 1,580 + 3-1999 + B2 + Underpaid + Author of Cattle + + + 1,581 + 4-1997 + B1 + Fairly Paid + Food Taster for Schools + + + 1,582 + 2-2020 + A1 + Massively Overpaid + Author of Cattle + + + 1,583 + 10-1993 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 1,584 + 4-2008 + A2 + Overpaid + Builder (Trainee) + + + 1,585 + 11-1990 + B2 + Underpaid + Sports Mascot (Trainee) + + + 1,586 + 6-2017 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 1,587 + 4-2020 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 1,588 + 2-2002 + A2 + Overpaid + Builder of Cattle + + + 1,589 + 3-2020 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 1,590 + 12-2006 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 1,591 + 3-1993 + C1 + Massively Underpaid + Author Extraordinaire + + + 1,592 + 2-2011 + A2 + Overpaid + Assassin for Schools + + + 1,593 + 3-2006 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 1,594 + 2-1994 + A1 + Massively Overpaid + Builder for the Environment + + + 1,595 + 3-2020 + A1 + Massively Overpaid + Food Taster for Schools + + + 1,596 + 2-2006 + B1 + Fairly Paid + Assassin of Cattle + + + 1,597 + 5-2013 + C2 + Slave Labour + Assassin of Parties + + + 1,598 + 4-2002 + B1 + Fairly Paid + Philosopher of Doom + + + 1,599 + 10-2000 + A1 + Massively Overpaid + Author in Chief + + + 1,600 + 7-1992 + C2 + Slave Labour + Author of Doom + + + 1,601 + 3-2000 + C2 + Slave Labour + Author for Schools + + + 1,602 + 7-2023 + C2 + Slave Labour + Author of Parties + + + 1,603 + 1-2016 + B1 + Fairly Paid + Philosopher for the Environment + + + 1,604 + 3-2009 + B1 + Fairly Paid + Philosopher of Parties + + + 1,605 + 7-1995 + B2 + Underpaid + Philosopher for Eternity + + + 1,606 + 5-1993 + A2 + Overpaid + Philosopher of Cattle + + + 1,607 + 4-1999 + C1 + Massively Underpaid + Builder for the Environment + + + 1,608 + 5-2007 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 1,609 + 9-1990 + C1 + Massively Underpaid + Software Developer Trainer + + + 1,610 + 9-1991 + B2 + Underpaid + Philosopher for Schools + + + 1,611 + 9-2005 + A2 + Overpaid + Philosopher (Trainee) + + + 1,612 + 8-2018 + C1 + Massively Underpaid + Software Developer of Doom + + + 1,613 + 7-1994 + A2 + Overpaid + Builder in Chief + + + 1,614 + 4-2016 + A2 + Overpaid + Author of Cattle + + + 1,615 + 12-2013 + A1 + Massively Overpaid + Vigilante of Doom + + + 1,616 + 5-2011 + C2 + Slave Labour + Philosopher (Trainee) + + + 1,617 + 12-1993 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 1,618 + 5-1990 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 1,619 + 9-1995 + B2 + Underpaid + Food Taster in Chief + + + 1,620 + 12-1990 + B1 + Fairly Paid + Assassin of Parties + + + 1,621 + 5-2001 + B2 + Underpaid + Assassin Laureate + + + 1,622 + 5-2023 + B2 + Underpaid + Software Developer for the Environment + + + 1,623 + 10-2021 + B1 + Fairly Paid + Author (Trainee) + + + 1,624 + 5-2002 + B2 + Underpaid + Philosopher of Cattle + + + 1,625 + 6-2023 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 1,626 + 3-1996 + B1 + Fairly Paid + Philosopher (Trainee) + + + 1,627 + 1-2003 + B2 + Underpaid + Sports Mascot for the Environment + + + 1,628 + 9-2016 + C1 + Massively Underpaid + Builder for Eternity + + + 1,629 + 5-1992 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 1,630 + 3-1996 + C1 + Massively Underpaid + Historian Laureate + + + 1,631 + 7-2006 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 1,632 + 8-2023 + B2 + Underpaid + Vigilante of Parties + + + 1,633 + 6-2021 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 1,634 + 7-2018 + A2 + Overpaid + Software Developer in Chief + + + 1,635 + 8-2007 + A2 + Overpaid + Food Taster Extraordinaire + + + 1,636 + 3-2016 + B2 + Underpaid + Food Taster for the Environment + + + 1,637 + 7-2001 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 1,638 + 6-1994 + A2 + Overpaid + Software Developer (Trainee) + + + 1,639 + 7-2022 + C2 + Slave Labour + Philosopher for the Environment + + + 1,640 + 6-2010 + A2 + Overpaid + Historian for Eternity + + + 1,641 + 2-1998 + C1 + Massively Underpaid + Builder for Eternity + + + 1,642 + 1-1992 + A2 + Overpaid + Vigilante for Eternity + + + 1,643 + 12-2004 + B2 + Underpaid + Sports Mascot Trainer + + + 1,644 + 7-1993 + A2 + Overpaid + Author of Cattle + + + 1,645 + 9-2017 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 1,646 + 12-1991 + C1 + Massively Underpaid + Builder of Cattle + + + 1,647 + 5-2018 + B1 + Fairly Paid + Assassin of Cattle + + + 1,648 + 6-2021 + B1 + Fairly Paid + Author of Doom + + + 1,649 + 6-2000 + B2 + Underpaid + Vigilante of Parties + + + 1,650 + 10-2002 + B2 + Underpaid + Sports Mascot of Parties + + + 1,651 + 6-2018 + A2 + Overpaid + Author of Doom + + + 1,652 + 5-2011 + C2 + Slave Labour + Assassin of Doom + + + 1,653 + 12-2005 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 1,654 + 2-1992 + C1 + Massively Underpaid + Historian Trainer + + + 1,655 + 2-2005 + C2 + Slave Labour + Assassin of Parties + + + 1,656 + 10-2009 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 1,657 + 2-2018 + C2 + Slave Labour + Sports Mascot of Parties + + + 1,658 + 5-2021 + C1 + Massively Underpaid + Vigilante of Cattle + + + 1,659 + 7-2014 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 1,660 + 4-2014 + B2 + Underpaid + Sports Mascot for Eternity + + + 1,661 + 11-2005 + C1 + Massively Underpaid + Builder Extraordinaire + + + 1,662 + 6-2013 + C1 + Massively Underpaid + Historian Extraordinaire + + + 1,663 + 5-2022 + C1 + Massively Underpaid + Historian of Doom + + + 1,664 + 11-2020 + B2 + Underpaid + Vigilante Extraordinaire + + + 1,665 + 11-1995 + A1 + Massively Overpaid + Historian of Cattle + + + 1,666 + 9-2010 + A2 + Overpaid + Software Developer for Eternity + + + 1,667 + 7-2001 + C2 + Slave Labour + Vigilante for Eternity + + + 1,668 + 11-2007 + A2 + Overpaid + Author Laureate + + + 1,669 + 2-2006 + B1 + Fairly Paid + Vigilante for Eternity + + + 1,670 + 4-2019 + A1 + Massively Overpaid + Historian for Eternity + + + 1,671 + 4-2022 + A1 + Massively Overpaid + Software Developer of Parties + + + 1,672 + 3-2003 + C1 + Massively Underpaid + Author for the Environment + + + 1,673 + 11-1995 + A1 + Massively Overpaid + Assassin for Eternity + + + 1,674 + 8-2005 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 1,675 + 6-2006 + C2 + Slave Labour + Software Developer Extraordinaire + + + 1,676 + 12-2006 + A2 + Overpaid + Philosopher Laureate + + + 1,677 + 4-2023 + C2 + Slave Labour + Sports Mascot in Chief + + + 1,678 + 2-2015 + B1 + Fairly Paid + Skydiving Instructor (Trainee) + + + 1,679 + 1-2006 + B2 + Underpaid + Historian in Chief + + + 1,680 + 8-1991 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 1,681 + 11-2000 + A1 + Massively Overpaid + Author for Schools + + + 1,682 + 11-2021 + C1 + Massively Underpaid + Historian of Doom + + + 1,683 + 2-2020 + B2 + Underpaid + Assassin for the Environment + + + 1,684 + 2-1996 + B1 + Fairly Paid + Assassin of Doom + + + 1,685 + 4-1996 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 1,686 + 6-2000 + B2 + Underpaid + Philosopher for the Environment + + + 1,687 + 5-1994 + B2 + Underpaid + Vigilante of Parties + + + 1,688 + 4-1990 + C1 + Massively Underpaid + Builder for the Environment + + + 1,689 + 4-2004 + B2 + Underpaid + Software Developer (Trainee) + + + 1,690 + 6-1994 + A1 + Massively Overpaid + Vigilante for Schools + + + 1,691 + 9-1991 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 1,692 + 12-2006 + B2 + Underpaid + Skydiving Instructor in Chief + + + 1,693 + 7-2001 + B1 + Fairly Paid + Sports Mascot in Chief + + + 1,694 + 9-2016 + A2 + Overpaid + Vigilante of Doom + + + 1,695 + 4-1992 + B1 + Fairly Paid + Assassin Extraordinaire + + + 1,696 + 8-2018 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 1,697 + 11-2022 + A1 + Massively Overpaid + Historian of Cattle + + + 1,698 + 2-2015 + A2 + Overpaid + Software Developer for Eternity + + + 1,699 + 9-2012 + B2 + Underpaid + Builder Laureate + + + 1,700 + 1-2003 + A1 + Massively Overpaid + Philosopher Laureate + + + 1,701 + 12-2018 + A1 + Massively Overpaid + Vigilante for Schools + + + 1,702 + 8-1998 + C1 + Massively Underpaid + Assassin for the Environment + + + 1,703 + 7-2022 + B2 + Underpaid + Sports Mascot of Doom + + + 1,704 + 11-1998 + B1 + Fairly Paid + Author for Eternity + + + 1,705 + 10-1991 + A1 + Massively Overpaid + Author (Trainee) + + + 1,706 + 7-2011 + A1 + Massively Overpaid + Assassin for Eternity + + + 1,707 + 2-2014 + B2 + Underpaid + Skydiving Instructor in Chief + + + 1,708 + 11-2009 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 1,709 + 6-2023 + C1 + Massively Underpaid + Author for Schools + + + 1,710 + 3-2023 + B1 + Fairly Paid + Software Developer for Schools + + + 1,711 + 4-2017 + B1 + Fairly Paid + Historian Trainer + + + 1,712 + 10-2013 + B1 + Fairly Paid + Author for Schools + + + 1,713 + 6-2020 + A2 + Overpaid + Food Taster of Cattle + + + 1,714 + 5-2007 + C2 + Slave Labour + Software Developer of Doom + + + 1,715 + 2-2002 + C2 + Slave Labour + Assassin of Cattle + + + 1,716 + 2-1992 + B2 + Underpaid + Historian (Trainee) + + + 1,717 + 1-2014 + B1 + Fairly Paid + Historian for Schools + + + 1,718 + 2-1996 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 1,719 + 9-2012 + B1 + Fairly Paid + Vigilante (Trainee) + + + 1,720 + 11-2013 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 1,721 + 7-1999 + A2 + Overpaid + Historian of Cattle + + + 1,722 + 12-2017 + B1 + Fairly Paid + Food Taster for Eternity + + + 1,723 + 5-2001 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 1,724 + 11-2006 + A1 + Massively Overpaid + Software Developer of Cattle + + + 1,725 + 1-2016 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 1,726 + 8-2021 + A1 + Massively Overpaid + Builder for the Environment + + + 1,727 + 4-2002 + A1 + Massively Overpaid + Philosopher of Doom + + + 1,728 + 8-2007 + B2 + Underpaid + Assassin Laureate + + + 1,729 + 5-2020 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 1,730 + 6-2005 + B2 + Underpaid + Vigilante Laureate + + + 1,731 + 4-2019 + B2 + Underpaid + Food Taster Extraordinaire + + + 1,732 + 11-1993 + B1 + Fairly Paid + Historian for the Environment + + + 1,733 + 1-2014 + B1 + Fairly Paid + Food Taster Laureate + + + 1,734 + 4-1993 + A2 + Overpaid + Author of Cattle + + + 1,735 + 4-2012 + C1 + Massively Underpaid + Vigilante of Cattle + + + 1,736 + 11-2002 + A1 + Massively Overpaid + Author of Doom + + + 1,737 + 6-2020 + C2 + Slave Labour + Philosopher in Chief + + + 1,738 + 9-2021 + C2 + Slave Labour + Builder in Chief + + + 1,739 + 10-2019 + B1 + Fairly Paid + Author Extraordinaire + + + 1,740 + 12-1999 + A2 + Overpaid + Author for Eternity + + + 1,741 + 12-1997 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 1,742 + 10-2006 + B2 + Underpaid + Vigilante (Trainee) + + + 1,743 + 2-2018 + A1 + Massively Overpaid + Builder for Eternity + + + 1,744 + 9-1996 + B1 + Fairly Paid + Software Developer Laureate + + + 1,745 + 1-2013 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 1,746 + 12-2011 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 1,747 + 5-2003 + C2 + Slave Labour + Author of Doom + + + 1,748 + 12-1997 + C1 + Massively Underpaid + Author of Doom + + + 1,749 + 11-2006 + B1 + Fairly Paid + Food Taster of Cattle + + + 1,750 + 9-2002 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 1,751 + 8-2000 + C2 + Slave Labour + Vigilante for Schools + + + 1,752 + 8-1993 + A1 + Massively Overpaid + Vigilante of Cattle + + + 1,753 + 3-2010 + A1 + Massively Overpaid + Builder Extraordinaire + + + 1,754 + 8-2006 + A1 + Massively Overpaid + Assassin in Chief + + + 1,755 + 1-2002 + A2 + Overpaid + Historian of Parties + + + 1,756 + 3-2010 + B2 + Underpaid + Builder for the Environment + + + 1,757 + 9-2002 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 1,758 + 12-2017 + C2 + Slave Labour + Philosopher in Chief + + + 1,759 + 2-2004 + B2 + Underpaid + Vigilante Extraordinaire + + + 1,760 + 11-2002 + C2 + Slave Labour + Vigilante Trainer + + + 1,761 + 9-2014 + C2 + Slave Labour + Author of Parties + + + 1,762 + 9-1999 + A2 + Overpaid + Author of Doom + + + 1,763 + 1-1996 + A2 + Overpaid + Author for the Environment + + + 1,764 + 7-2015 + B1 + Fairly Paid + Builder for Eternity + + + 1,765 + 1-2016 + C1 + Massively Underpaid + Vigilante for the Environment + + + 1,766 + 2-2013 + A1 + Massively Overpaid + Assassin for Eternity + + + 1,767 + 7-2001 + C1 + Massively Underpaid + Vigilante in Chief + + + 1,768 + 10-1998 + A2 + Overpaid + Food Taster in Chief + + + 1,769 + 4-1995 + B2 + Underpaid + Assassin of Doom + + + 1,770 + 12-2014 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 1,771 + 11-2000 + B2 + Underpaid + Software Developer for Eternity + + + 1,772 + 11-2022 + C1 + Massively Underpaid + Food Taster in Chief + + + 1,773 + 6-2008 + B1 + Fairly Paid + Assassin Extraordinaire + + + 1,774 + 4-2012 + A1 + Massively Overpaid + Assassin for Eternity + + + 1,775 + 8-2000 + A2 + Overpaid + Vigilante of Parties + + + 1,776 + 9-2016 + C1 + Massively Underpaid + Historian of Doom + + + 1,777 + 12-2004 + C1 + Massively Underpaid + Builder of Doom + + + 1,778 + 11-1995 + B2 + Underpaid + Vigilante in Chief + + + 1,779 + 4-2008 + A2 + Overpaid + Author Extraordinaire + + + 1,780 + 6-1990 + B2 + Underpaid + Sports Mascot (Trainee) + + + 1,781 + 2-2019 + B2 + Underpaid + Software Developer for Schools + + + 1,782 + 3-2009 + A2 + Overpaid + Software Developer (Trainee) + + + 1,783 + 12-1994 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 1,784 + 7-2022 + C2 + Slave Labour + Software Developer Laureate + + + 1,785 + 9-1998 + A1 + Massively Overpaid + Assassin of Cattle + + + 1,786 + 10-2004 + B1 + Fairly Paid + Software Developer Trainer + + + 1,787 + 6-1999 + A1 + Massively Overpaid + Author of Parties + + + 1,788 + 6-2008 + B1 + Fairly Paid + Author of Cattle + + + 1,789 + 2-2000 + C1 + Massively Underpaid + Software Developer of Parties + + + 1,790 + 8-1995 + B2 + Underpaid + Builder for the Environment + + + 1,791 + 8-2014 + B2 + Underpaid + Software Developer of Cattle + + + 1,792 + 4-2022 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 1,793 + 4-2004 + A1 + Massively Overpaid + Builder in Chief + + + 1,794 + 10-1993 + C2 + Slave Labour + Food Taster Laureate + + + 1,795 + 12-2017 + B2 + Underpaid + Skydiving Instructor of Parties + + + 1,796 + 7-2020 + C1 + Massively Underpaid + Food Taster for Eternity + + + 1,797 + 3-1994 + B2 + Underpaid + Assassin for Schools + + + 1,798 + 1-2000 + A2 + Overpaid + Food Taster Trainer + + + 1,799 + 5-2019 + C2 + Slave Labour + Historian Trainer + + + 1,800 + 9-2002 + A2 + Overpaid + Vigilante of Parties + + + 1,801 + 6-2009 + C2 + Slave Labour + Philosopher Trainer + + + 1,802 + 6-2018 + B2 + Underpaid + Historian Laureate + + + 1,803 + 12-2011 + A2 + Overpaid + Software Developer (Trainee) + + + 1,804 + 8-2009 + A2 + Overpaid + Philosopher for Eternity + + + 1,805 + 12-2014 + B2 + Underpaid + Software Developer (Trainee) + + + 1,806 + 7-2020 + C2 + Slave Labour + Software Developer in Chief + + + 1,807 + 8-1999 + A2 + Overpaid + Philosopher in Chief + + + 1,808 + 5-2014 + C2 + Slave Labour + Builder (Trainee) + + + 1,809 + 7-2017 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 1,810 + 12-2013 + B2 + Underpaid + Software Developer for the Environment + + + 1,811 + 3-2010 + A2 + Overpaid + Builder in Chief + + + 1,812 + 6-2000 + A2 + Overpaid + Food Taster in Chief + + + 1,813 + 2-2021 + B1 + Fairly Paid + Food Taster of Doom + + + 1,814 + 8-2022 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 1,815 + 4-1993 + C2 + Slave Labour + Builder Trainer + + + 1,816 + 3-2016 + A2 + Overpaid + Author Extraordinaire + + + 1,817 + 12-1993 + A1 + Massively Overpaid + Historian Trainer + + + 1,818 + 6-2013 + A2 + Overpaid + Vigilante for Schools + + + 1,819 + 3-1990 + B2 + Underpaid + Skydiving Instructor of Doom + + + 1,820 + 6-2011 + A2 + Overpaid + Author of Doom + + + 1,821 + 2-2006 + B1 + Fairly Paid + Historian of Cattle + + + 1,822 + 8-1991 + B2 + Underpaid + Food Taster of Doom + + + 1,823 + 12-2015 + B1 + Fairly Paid + Food Taster (Trainee) + + + 1,824 + 8-2002 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 1,825 + 5-2015 + B2 + Underpaid + Philosopher Laureate + + + 1,826 + 3-1997 + B1 + Fairly Paid + Philosopher in Chief + + + 1,827 + 12-1999 + B1 + Fairly Paid + Historian (Trainee) + + + 1,828 + 5-1990 + A1 + Massively Overpaid + Builder for Eternity + + + 1,829 + 1-1997 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 1,830 + 2-2002 + A2 + Overpaid + Software Developer in Chief + + + 1,831 + 8-2012 + A2 + Overpaid + Author for Schools + + + 1,832 + 12-1995 + B2 + Underpaid + Builder for Schools + + + 1,833 + 12-1993 + A2 + Overpaid + Builder for the Environment + + + 1,834 + 4-2007 + A1 + Massively Overpaid + Philosopher Laureate + + + 1,835 + 3-2018 + C1 + Massively Underpaid + Software Developer of Doom + + + 1,836 + 7-2015 + A2 + Overpaid + Historian Extraordinaire + + + 1,837 + 6-2009 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 1,838 + 9-2015 + C2 + Slave Labour + Vigilante for Eternity + + + 1,839 + 8-2014 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 1,840 + 1-2019 + B1 + Fairly Paid + Food Taster Laureate + + + 1,841 + 7-2014 + C1 + Massively Underpaid + Author in Chief + + + 1,842 + 5-1990 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 1,843 + 2-2017 + B2 + Underpaid + Historian Trainer + + + 1,844 + 6-2017 + C2 + Slave Labour + Software Developer (Trainee) + + + 1,845 + 10-2012 + B1 + Fairly Paid + Software Developer of Doom + + + 1,846 + 5-2009 + A2 + Overpaid + Historian in Chief + + + 1,847 + 1-2023 + B2 + Underpaid + Vigilante of Parties + + + 1,848 + 9-2009 + C2 + Slave Labour + Vigilante in Chief + + + 1,849 + 1-2002 + A1 + Massively Overpaid + Software Developer Laureate + + + 1,850 + 12-2023 + C2 + Slave Labour + Philosopher (Trainee) + + + 1,851 + 5-1994 + A1 + Massively Overpaid + Author Extraordinaire + + + 1,852 + 5-2015 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 1,853 + 3-2001 + A1 + Massively Overpaid + Philosopher of Cattle + + + 1,854 + 1-2004 + A2 + Overpaid + Builder in Chief + + + 1,855 + 5-2015 + C2 + Slave Labour + Software Developer (Trainee) + + + 1,856 + 6-2011 + C2 + Slave Labour + Builder in Chief + + + 1,857 + 1-2000 + A1 + Massively Overpaid + Author for Eternity + + + 1,858 + 6-2010 + B1 + Fairly Paid + Assassin of Cattle + + + 1,859 + 9-1990 + A1 + Massively Overpaid + Food Taster for Schools + + + 1,860 + 4-1998 + A1 + Massively Overpaid + Assassin for Eternity + + + 1,861 + 1-2018 + B1 + Fairly Paid + Software Developer (Trainee) + + + 1,862 + 12-2001 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 1,863 + 2-2007 + C1 + Massively Underpaid + Food Taster for Schools + + + 1,864 + 6-2022 + A1 + Massively Overpaid + Author of Doom + + + 1,865 + 9-2011 + C1 + Massively Underpaid + Author (Trainee) + + + 1,866 + 10-1995 + A2 + Overpaid + Philosopher for Eternity + + + 1,867 + 5-2006 + C1 + Massively Underpaid + Historian Laureate + + + 1,868 + 4-1996 + C1 + Massively Underpaid + Software Developer of Cattle + + + 1,869 + 4-1992 + A2 + Overpaid + Vigilante for Eternity + + + 1,870 + 6-2013 + C2 + Slave Labour + Historian Extraordinaire + + + 1,871 + 11-2001 + C2 + Slave Labour + Food Taster of Cattle + + + 1,872 + 10-1999 + A2 + Overpaid + Software Developer Extraordinaire + + + 1,873 + 8-2018 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 1,874 + 3-2018 + C1 + Massively Underpaid + Philosopher for the Environment + + + 1,875 + 8-1999 + C1 + Massively Underpaid + Builder (Trainee) + + + 1,876 + 1-2011 + C2 + Slave Labour + Software Developer Extraordinaire + + + 1,877 + 4-2016 + A1 + Massively Overpaid + Software Developer for Eternity + + + 1,878 + 5-2012 + C2 + Slave Labour + Vigilante in Chief + + + 1,879 + 5-1996 + B2 + Underpaid + Author of Parties + + + 1,880 + 4-2019 + B1 + Fairly Paid + Software Developer of Parties + + + 1,881 + 7-2011 + B1 + Fairly Paid + Historian Laureate + + + 1,882 + 3-2009 + C1 + Massively Underpaid + Builder in Chief + + + 1,883 + 8-1990 + B2 + Underpaid + Builder for Schools + + + 1,884 + 5-2009 + A1 + Massively Overpaid + Author of Cattle + + + 1,885 + 10-1993 + A2 + Overpaid + Sports Mascot (Trainee) + + + 1,886 + 10-2005 + A1 + Massively Overpaid + Historian in Chief + + + 1,887 + 3-2007 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 1,888 + 11-2017 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 1,889 + 7-2021 + C2 + Slave Labour + Builder (Trainee) + + + 1,890 + 3-1990 + C2 + Slave Labour + Sports Mascot of Cattle + + + 1,891 + 5-2006 + B2 + Underpaid + Assassin in Chief + + + 1,892 + 12-1992 + C2 + Slave Labour + Author of Cattle + + + 1,893 + 9-2007 + B1 + Fairly Paid + Builder for Schools + + + 1,894 + 3-1994 + A2 + Overpaid + Sports Mascot Laureate + + + 1,895 + 10-1992 + B1 + Fairly Paid + Food Taster for Eternity + + + 1,896 + 7-2007 + B1 + Fairly Paid + Historian for Eternity + + + 1,897 + 9-2023 + A1 + Massively Overpaid + Builder of Cattle + + + 1,898 + 4-2013 + A1 + Massively Overpaid + Historian of Cattle + + + 1,899 + 2-1991 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 1,900 + 7-2021 + A1 + Massively Overpaid + Vigilante Laureate + + + 1,901 + 2-1991 + A2 + Overpaid + Vigilante Extraordinaire + + + 1,902 + 12-2019 + B1 + Fairly Paid + Assassin of Cattle + + + 1,903 + 9-2013 + A2 + Overpaid + Author of Cattle + + + 1,904 + 3-1995 + A1 + Massively Overpaid + Assassin for Eternity + + + 1,905 + 8-2003 + A1 + Massively Overpaid + Author for the Environment + + + 1,906 + 2-2009 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 1,907 + 10-2015 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 1,908 + 3-2005 + B2 + Underpaid + Historian for Schools + + + 1,909 + 10-1996 + C2 + Slave Labour + Food Taster for Eternity + + + 1,910 + 12-2018 + B1 + Fairly Paid + Historian Laureate + + + 1,911 + 5-2006 + A2 + Overpaid + Vigilante for Eternity + + + 1,912 + 9-2010 + B1 + Fairly Paid + Food Taster (Trainee) + + + 1,913 + 7-1994 + A1 + Massively Overpaid + Food Taster in Chief + + + 1,914 + 4-2019 + B2 + Underpaid + Assassin of Cattle + + + 1,915 + 11-2013 + A2 + Overpaid + Food Taster Extraordinaire + + + 1,916 + 12-1998 + B1 + Fairly Paid + Software Developer of Parties + + + 1,917 + 7-2004 + A1 + Massively Overpaid + Vigilante for Schools + + + 1,918 + 11-2021 + C2 + Slave Labour + Sports Mascot Trainer + + + 1,919 + 6-2018 + C2 + Slave Labour + Historian for Eternity + + + 1,920 + 6-2007 + A2 + Overpaid + Vigilante for Eternity + + + 1,921 + 4-1999 + C1 + Massively Underpaid + Assassin (Trainee) + + + 1,922 + 4-2021 + C2 + Slave Labour + Builder Laureate + + + 1,923 + 6-1994 + C2 + Slave Labour + Builder (Trainee) + + + 1,924 + 8-2022 + C2 + Slave Labour + Sports Mascot of Doom + + + 1,925 + 3-2018 + B1 + Fairly Paid + Philosopher (Trainee) + + + 1,926 + 11-2000 + C2 + Slave Labour + Sports Mascot of Cattle + + + 1,927 + 4-1998 + A2 + Overpaid + Vigilante for Eternity + + + 1,928 + 12-2017 + B2 + Underpaid + Philosopher for Eternity + + + 1,929 + 9-2004 + B2 + Underpaid + Vigilante of Cattle + + + 1,930 + 5-2000 + A2 + Overpaid + Food Taster of Parties + + + 1,931 + 9-1995 + A2 + Overpaid + Philosopher (Trainee) + + + 1,932 + 12-2004 + A2 + Overpaid + Sports Mascot in Chief + + + 1,933 + 12-1993 + C2 + Slave Labour + Author for Schools + + + 1,934 + 5-1994 + B2 + Underpaid + Historian of Cattle + + + 1,935 + 10-1992 + C1 + Massively Underpaid + Historian (Trainee) + + + 1,936 + 6-1998 + A1 + Massively Overpaid + Assassin Laureate + + + 1,937 + 1-1994 + C1 + Massively Underpaid + Builder (Trainee) + + + 1,938 + 9-2021 + A2 + Overpaid + Software Developer (Trainee) + + + 1,939 + 11-1995 + C1 + Massively Underpaid + Historian for Eternity + + + 1,940 + 11-2017 + B2 + Underpaid + Assassin Extraordinaire + + + 1,941 + 5-2005 + B1 + Fairly Paid + Vigilante for the Environment + + + 1,942 + 3-2001 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 1,943 + 6-2007 + B2 + Underpaid + Author Extraordinaire + + + 1,944 + 11-1993 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 1,945 + 12-2007 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 1,946 + 3-1997 + B2 + Underpaid + Assassin of Cattle + + + 1,947 + 11-2000 + A2 + Overpaid + Software Developer Extraordinaire + + + 1,948 + 8-2019 + C2 + Slave Labour + Builder for the Environment + + + 1,949 + 3-1999 + C1 + Massively Underpaid + Vigilante of Doom + + + 1,950 + 5-2010 + B2 + Underpaid + Assassin Extraordinaire + + + 1,951 + 2-2016 + A2 + Overpaid + Historian of Doom + + + 1,952 + 12-1997 + C1 + Massively Underpaid + Vigilante Laureate + + + 1,953 + 10-2008 + B2 + Underpaid + Philosopher for Schools + + + 1,954 + 11-2023 + B1 + Fairly Paid + Assassin Trainer + + + 1,955 + 9-2021 + B2 + Underpaid + Author for Schools + + + 1,956 + 6-2017 + A1 + Massively Overpaid + Vigilante for Eternity + + + 1,957 + 8-1994 + C1 + Massively Underpaid + Philosopher for Schools + + + 1,958 + 1-1996 + B1 + Fairly Paid + Author of Parties + + + 1,959 + 12-1993 + C1 + Massively Underpaid + Assassin of Parties + + + 1,960 + 9-1990 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 1,961 + 4-2001 + A2 + Overpaid + Vigilante of Parties + + + 1,962 + 11-2009 + C2 + Slave Labour + Historian Trainer + + + 1,963 + 7-2011 + B1 + Fairly Paid + Vigilante in Chief + + + 1,964 + 11-2007 + B2 + Underpaid + Philosopher for the Environment + + + 1,965 + 9-2017 + B1 + Fairly Paid + Historian for Eternity + + + 1,966 + 8-2006 + B1 + Fairly Paid + Food Taster of Doom + + + 1,967 + 11-2021 + A1 + Massively Overpaid + Food Taster Trainer + + + 1,968 + 8-1998 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 1,969 + 11-2020 + A2 + Overpaid + Historian of Parties + + + 1,970 + 1-2009 + B1 + Fairly Paid + Sports Mascot of Doom + + + 1,971 + 12-2021 + A2 + Overpaid + Vigilante of Parties + + + 1,972 + 3-2012 + C2 + Slave Labour + Historian of Parties + + + 1,973 + 9-2015 + B2 + Underpaid + Builder for Schools + + + 1,974 + 8-2013 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 1,975 + 7-1995 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 1,976 + 11-2014 + A2 + Overpaid + Builder Laureate + + + 1,977 + 6-1998 + C1 + Massively Underpaid + Vigilante Laureate + + + 1,978 + 7-1994 + C1 + Massively Underpaid + Vigilante for Schools + + + 1,979 + 4-1998 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 1,980 + 8-2019 + A1 + Massively Overpaid + Historian of Parties + + + 1,981 + 8-2015 + B1 + Fairly Paid + Assassin of Cattle + + + 1,982 + 6-2008 + B2 + Underpaid + Builder for Eternity + + + 1,983 + 4-2006 + C2 + Slave Labour + Software Developer of Doom + + + 1,984 + 8-2001 + B1 + Fairly Paid + Philosopher Trainer + + + 1,985 + 12-1991 + B1 + Fairly Paid + Food Taster for Eternity + + + 1,986 + 6-1993 + B1 + Fairly Paid + Vigilante in Chief + + + 1,987 + 5-2020 + A1 + Massively Overpaid + Author Laureate + + + 1,988 + 1-2013 + A2 + Overpaid + Assassin of Cattle + + + 1,989 + 4-2009 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 1,990 + 8-2016 + B2 + Underpaid + Philosopher for the Environment + + + 1,991 + 3-2019 + C2 + Slave Labour + Philosopher for Schools + + + 1,992 + 5-2023 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 1,993 + 6-2002 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 1,994 + 8-2002 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 1,995 + 7-2015 + A2 + Overpaid + Historian of Doom + + + 1,996 + 4-2001 + C2 + Slave Labour + Assassin in Chief + + + 1,997 + 11-2017 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 1,998 + 1-2002 + B1 + Fairly Paid + Assassin for Eternity + + + 1,999 + 6-2010 + C1 + Massively Underpaid + Software Developer in Chief + + + 2,000 + 3-2013 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 2,001 + 9-1993 + A1 + Massively Overpaid + Builder for the Environment + + + 2,002 + 10-2015 + C1 + Massively Underpaid + Food Taster for the Environment + + + 2,003 + 1-2015 + A2 + Overpaid + Historian of Parties + + + 2,004 + 6-2007 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 2,005 + 10-2011 + C2 + Slave Labour + Historian Laureate + + + 2,006 + 1-2006 + B2 + Underpaid + Food Taster Laureate + + + 2,007 + 12-2020 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 2,008 + 8-2001 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 2,009 + 2-1997 + A1 + Massively Overpaid + Philosopher of Cattle + + + 2,010 + 6-2022 + C2 + Slave Labour + Vigilante (Trainee) + + + 2,011 + 5-1992 + A2 + Overpaid + Assassin for the Environment + + + 2,012 + 12-1992 + C1 + Massively Underpaid + Assassin in Chief + + + 2,013 + 10-2010 + B2 + Underpaid + Software Developer for the Environment + + + 2,014 + 4-2004 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 2,015 + 3-1995 + C1 + Massively Underpaid + Historian Laureate + + + 2,016 + 11-2003 + C1 + Massively Underpaid + Food Taster of Cattle + + + 2,017 + 3-2009 + A1 + Massively Overpaid + Author Laureate + + + 2,018 + 3-2016 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 2,019 + 11-2004 + B2 + Underpaid + Skydiving Instructor Trainer + + + 2,020 + 9-2001 + B1 + Fairly Paid + Builder Laureate + + + 2,021 + 7-2012 + A2 + Overpaid + Philosopher of Cattle + + + 2,022 + 12-2005 + B2 + Underpaid + Builder in Chief + + + 2,023 + 3-1994 + B1 + Fairly Paid + Vigilante in Chief + + + 2,024 + 12-2019 + B1 + Fairly Paid + Author of Cattle + + + 2,025 + 4-2004 + A1 + Massively Overpaid + Historian for Schools + + + 2,026 + 12-2011 + B2 + Underpaid + Builder (Trainee) + + + 2,027 + 6-2022 + C1 + Massively Underpaid + Builder of Cattle + + + 2,028 + 4-1997 + C1 + Massively Underpaid + Historian for Eternity + + + 2,029 + 4-1995 + C2 + Slave Labour + Food Taster for Schools + + + 2,030 + 5-2003 + A2 + Overpaid + Software Developer of Parties + + + 2,031 + 1-2023 + B1 + Fairly Paid + Vigilante of Parties + + + 2,032 + 3-2008 + B2 + Underpaid + Software Developer (Trainee) + + + 2,033 + 3-1990 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 2,034 + 4-1999 + A1 + Massively Overpaid + Food Taster Trainer + + + 2,035 + 1-2004 + B2 + Underpaid + Vigilante in Chief + + + 2,036 + 8-2001 + A1 + Massively Overpaid + Historian of Parties + + + 2,037 + 8-2003 + C1 + Massively Underpaid + Historian of Parties + + + 2,038 + 10-2019 + A2 + Overpaid + Philosopher Trainer + + + 2,039 + 5-2010 + A2 + Overpaid + Skydiving Instructor for Schools + + + 2,040 + 11-2010 + A1 + Massively Overpaid + Vigilante of Parties + + + 2,041 + 3-2000 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 2,042 + 10-2009 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 2,043 + 11-2017 + B2 + Underpaid + Builder for Eternity + + + 2,044 + 7-2011 + C1 + Massively Underpaid + Historian (Trainee) + + + 2,045 + 11-2018 + C2 + Slave Labour + Sports Mascot for Schools + + + 2,046 + 3-1992 + A2 + Overpaid + Sports Mascot of Doom + + + 2,047 + 6-2007 + A2 + Overpaid + Food Taster Laureate + + + 2,048 + 9-2004 + B2 + Underpaid + Author for Eternity + + + 2,049 + 2-2021 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 2,050 + 6-2004 + C1 + Massively Underpaid + Author for the Environment + + + 2,051 + 3-2009 + C1 + Massively Underpaid + Vigilante of Parties + + + 2,052 + 10-2019 + B2 + Underpaid + Builder for Eternity + + + 2,053 + 9-2018 + A1 + Massively Overpaid + Vigilante of Cattle + + + 2,054 + 9-2012 + B2 + Underpaid + Builder (Trainee) + + + 2,055 + 6-2009 + B2 + Underpaid + Skydiving Instructor of Parties + + + 2,056 + 8-1991 + A2 + Overpaid + Food Taster (Trainee) + + + 2,057 + 2-2010 + C2 + Slave Labour + Assassin of Cattle + + + 2,058 + 9-1996 + C2 + Slave Labour + Builder for Schools + + + 2,059 + 4-2019 + B1 + Fairly Paid + Vigilante of Cattle + + + 2,060 + 9-2001 + C1 + Massively Underpaid + Historian for Eternity + + + 2,061 + 5-2001 + C1 + Massively Underpaid + Author (Trainee) + + + 2,062 + 11-1992 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 2,063 + 3-1992 + A1 + Massively Overpaid + Historian for the Environment + + + 2,064 + 11-2017 + B2 + Underpaid + Sports Mascot of Cattle + + + 2,065 + 10-1991 + B2 + Underpaid + Sports Mascot for the Environment + + + 2,066 + 7-2014 + C1 + Massively Underpaid + Historian Laureate + + + 2,067 + 9-2004 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 2,068 + 9-1992 + B2 + Underpaid + Vigilante for Schools + + + 2,069 + 11-2021 + B2 + Underpaid + Author of Doom + + + 2,070 + 5-1997 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 2,071 + 7-2019 + B1 + Fairly Paid + Skydiving Instructor (Trainee) + + + 2,072 + 8-2009 + B2 + Underpaid + Food Taster for the Environment + + + 2,073 + 5-2011 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 2,074 + 8-2014 + B2 + Underpaid + Historian of Cattle + + + 2,075 + 2-2016 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 2,076 + 3-1993 + B1 + Fairly Paid + Food Taster Laureate + + + 2,077 + 2-1996 + B2 + Underpaid + Sports Mascot for Eternity + + + 2,078 + 1-2002 + B1 + Fairly Paid + Assassin Trainer + + + 2,079 + 11-2012 + C1 + Massively Underpaid + Author of Cattle + + + 2,080 + 5-2010 + C1 + Massively Underpaid + Philosopher Laureate + + + 2,081 + 10-1994 + B1 + Fairly Paid + Software Developer for Eternity + + + 2,082 + 5-2004 + A1 + Massively Overpaid + Software Developer Laureate + + + 2,083 + 9-1992 + B2 + Underpaid + Builder for the Environment + + + 2,084 + 12-1994 + B1 + Fairly Paid + Philosopher of Doom + + + 2,085 + 2-1991 + C1 + Massively Underpaid + Author Laureate + + + 2,086 + 12-2021 + B1 + Fairly Paid + Historian for Eternity + + + 2,087 + 7-1990 + B2 + Underpaid + Philosopher for the Environment + + + 2,088 + 2-2021 + C1 + Massively Underpaid + Vigilante in Chief + + + 2,089 + 2-1999 + B2 + Underpaid + Historian in Chief + + + 2,090 + 1-2018 + A2 + Overpaid + Philosopher of Parties + + + 2,091 + 12-2007 + B2 + Underpaid + Sports Mascot of Cattle + + + 2,092 + 8-2022 + C2 + Slave Labour + Assassin of Doom + + + 2,093 + 2-2016 + C1 + Massively Underpaid + Philosopher Trainer + + + 2,094 + 11-2005 + B1 + Fairly Paid + Author for Eternity + + + 2,095 + 8-2007 + A2 + Overpaid + Food Taster Laureate + + + 2,096 + 4-2007 + B1 + Fairly Paid + Assassin in Chief + + + 2,097 + 5-2012 + C2 + Slave Labour + Historian in Chief + + + 2,098 + 10-2010 + C1 + Massively Underpaid + Vigilante for the Environment + + + 2,099 + 5-2005 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 2,100 + 5-1997 + A1 + Massively Overpaid + Builder of Doom + + + 2,101 + 7-1990 + A2 + Overpaid + Sports Mascot for Schools + + + 2,102 + 12-2019 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 2,103 + 4-2023 + B1 + Fairly Paid + Food Taster of Parties + + + 2,104 + 10-2003 + B2 + Underpaid + Software Developer Trainer + + + 2,105 + 1-2004 + C1 + Massively Underpaid + Vigilante for Eternity + + + 2,106 + 8-1991 + A2 + Overpaid + Sports Mascot Trainer + + + 2,107 + 9-2007 + A1 + Massively Overpaid + Builder in Chief + + + 2,108 + 11-2016 + B2 + Underpaid + Historian for the Environment + + + 2,109 + 7-2016 + A2 + Overpaid + Vigilante Trainer + + + 2,110 + 12-1998 + B1 + Fairly Paid + Food Taster for Schools + + + 2,111 + 10-1994 + A2 + Overpaid + Assassin Laureate + + + 2,112 + 11-2010 + B2 + Underpaid + Software Developer Extraordinaire + + + 2,113 + 5-1994 + B2 + Underpaid + Skydiving Instructor of Parties + + + 2,114 + 6-2003 + B1 + Fairly Paid + Historian Extraordinaire + + + 2,115 + 8-1997 + A2 + Overpaid + Skydiving Instructor of Doom + + + 2,116 + 7-1993 + A1 + Massively Overpaid + Historian for the Environment + + + 2,117 + 10-2023 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 2,118 + 12-1993 + A2 + Overpaid + Sports Mascot for Eternity + + + 2,119 + 2-1990 + B2 + Underpaid + Vigilante Laureate + + + 2,120 + 5-2014 + B2 + Underpaid + Skydiving Instructor Laureate + + + 2,121 + 6-2000 + B1 + Fairly Paid + Historian Extraordinaire + + + 2,122 + 5-1995 + B2 + Underpaid + Food Taster in Chief + + + 2,123 + 12-2006 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 2,124 + 10-2013 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 2,125 + 7-1990 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 2,126 + 6-2018 + A1 + Massively Overpaid + Historian Trainer + + + 2,127 + 8-2006 + A1 + Massively Overpaid + Food Taster of Parties + + + 2,128 + 1-2004 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 2,129 + 1-1990 + C2 + Slave Labour + Assassin for Schools + + + 2,130 + 11-1997 + A2 + Overpaid + Historian in Chief + + + 2,131 + 5-2014 + A2 + Overpaid + Vigilante for Schools + + + 2,132 + 12-2010 + C2 + Slave Labour + Author of Cattle + + + 2,133 + 12-2006 + B1 + Fairly Paid + Vigilante in Chief + + + 2,134 + 11-2011 + B2 + Underpaid + Historian for Eternity + + + 2,135 + 2-2020 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 2,136 + 9-1996 + B2 + Underpaid + Builder for Eternity + + + 2,137 + 2-1996 + B1 + Fairly Paid + Sports Mascot for Schools + + + 2,138 + 5-2018 + A1 + Massively Overpaid + Assassin (Trainee) + + + 2,139 + 1-1995 + A1 + Massively Overpaid + Historian Laureate + + + 2,140 + 3-1993 + C1 + Massively Underpaid + Builder of Parties + + + 2,141 + 1-1996 + C1 + Massively Underpaid + Author of Cattle + + + 2,142 + 11-2004 + B1 + Fairly Paid + Vigilante in Chief + + + 2,143 + 5-1991 + C1 + Massively Underpaid + Vigilante Laureate + + + 2,144 + 8-2021 + C2 + Slave Labour + Food Taster of Parties + + + 2,145 + 2-2001 + C2 + Slave Labour + Author for Eternity + + + 2,146 + 2-2014 + B1 + Fairly Paid + Philosopher in Chief + + + 2,147 + 8-1997 + B2 + Underpaid + Software Developer Extraordinaire + + + 2,148 + 11-2014 + A1 + Massively Overpaid + Software Developer Laureate + + + 2,149 + 5-1996 + B2 + Underpaid + Sports Mascot for Schools + + + 2,150 + 10-2007 + A1 + Massively Overpaid + Philosopher for Schools + + + 2,151 + 7-2007 + C2 + Slave Labour + Software Developer (Trainee) + + + 2,152 + 6-1998 + A1 + Massively Overpaid + Historian for the Environment + + + 2,153 + 10-2020 + B2 + Underpaid + Philosopher (Trainee) + + + 2,154 + 1-2013 + B1 + Fairly Paid + Philosopher Trainer + + + 2,155 + 9-2016 + B1 + Fairly Paid + Builder (Trainee) + + + 2,156 + 5-2011 + A1 + Massively Overpaid + Vigilante in Chief + + + 2,157 + 4-2015 + C2 + Slave Labour + Builder (Trainee) + + + 2,158 + 3-1999 + B1 + Fairly Paid + Vigilante for the Environment + + + 2,159 + 12-2011 + A2 + Overpaid + Assassin of Doom + + + 2,160 + 7-1990 + C1 + Massively Underpaid + Philosopher for the Environment + + + 2,161 + 1-2006 + C1 + Massively Underpaid + Vigilante of Parties + + + 2,162 + 7-1996 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 2,163 + 4-2022 + B1 + Fairly Paid + Author of Doom + + + 2,164 + 6-1995 + C1 + Massively Underpaid + Vigilante of Cattle + + + 2,165 + 1-2000 + B2 + Underpaid + Historian of Doom + + + 2,166 + 4-2014 + A2 + Overpaid + Sports Mascot of Doom + + + 2,167 + 3-2016 + A2 + Overpaid + Sports Mascot of Doom + + + 2,168 + 2-2018 + A1 + Massively Overpaid + Vigilante Trainer + + + 2,169 + 5-2017 + B1 + Fairly Paid + Assassin of Doom + + + 2,170 + 4-1991 + A2 + Overpaid + Assassin for Schools + + + 2,171 + 9-2016 + A1 + Massively Overpaid + Builder for the Environment + + + 2,172 + 3-2008 + A2 + Overpaid + Sports Mascot of Parties + + + 2,173 + 2-2003 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 2,174 + 11-2000 + A2 + Overpaid + Food Taster of Cattle + + + 2,175 + 9-2017 + A2 + Overpaid + Assassin for the Environment + + + 2,176 + 12-1992 + C1 + Massively Underpaid + Philosopher of Parties + + + 2,177 + 10-2023 + A2 + Overpaid + Assassin Laureate + + + 2,178 + 1-2012 + A2 + Overpaid + Food Taster of Cattle + + + 2,179 + 11-2021 + C1 + Massively Underpaid + Food Taster of Parties + + + 2,180 + 8-2004 + A1 + Massively Overpaid + Food Taster of Cattle + + + 2,181 + 10-2009 + C1 + Massively Underpaid + Philosopher for the Environment + + + 2,182 + 8-2021 + A1 + Massively Overpaid + Software Developer for Schools + + + 2,183 + 7-2007 + B1 + Fairly Paid + Philosopher for the Environment + + + 2,184 + 8-2011 + C2 + Slave Labour + Philosopher of Doom + + + 2,185 + 1-2018 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 2,186 + 1-2007 + B1 + Fairly Paid + Assassin of Cattle + + + 2,187 + 10-1996 + A2 + Overpaid + Vigilante for Schools + + + 2,188 + 2-1993 + C2 + Slave Labour + Assassin Trainer + + + 2,189 + 9-2011 + A1 + Massively Overpaid + Assassin for Schools + + + 2,190 + 10-2001 + B1 + Fairly Paid + Food Taster of Parties + + + 2,191 + 6-1994 + B1 + Fairly Paid + Vigilante for Schools + + + 2,192 + 5-2017 + C2 + Slave Labour + Author for Schools + + + 2,193 + 5-1993 + C1 + Massively Underpaid + Historian Laureate + + + 2,194 + 9-2005 + A2 + Overpaid + Builder Trainer + + + 2,195 + 6-2018 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 2,196 + 11-2022 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 2,197 + 12-2012 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 2,198 + 1-2011 + C2 + Slave Labour + Historian for Eternity + + + 2,199 + 5-1996 + A2 + Overpaid + Sports Mascot Extraordinaire + + + 2,200 + 5-2019 + B2 + Underpaid + Skydiving Instructor Trainer + + + 2,201 + 5-2012 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 2,202 + 5-1997 + C2 + Slave Labour + Builder of Doom + + + 2,203 + 7-1999 + C2 + Slave Labour + Builder of Doom + + + 2,204 + 10-2000 + C1 + Massively Underpaid + Assassin of Doom + + + 2,205 + 12-2000 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 2,206 + 2-1990 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 2,207 + 11-2002 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 2,208 + 2-2008 + A2 + Overpaid + Builder in Chief + + + 2,209 + 12-1999 + A2 + Overpaid + Builder (Trainee) + + + 2,210 + 7-2004 + C2 + Slave Labour + Philosopher (Trainee) + + + 2,211 + 11-1996 + B1 + Fairly Paid + Software Developer Trainer + + + 2,212 + 10-2004 + B1 + Fairly Paid + Assassin Laureate + + + 2,213 + 7-1992 + B1 + Fairly Paid + Author for Eternity + + + 2,214 + 12-1990 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 2,215 + 4-1992 + A1 + Massively Overpaid + Builder (Trainee) + + + 2,216 + 12-1999 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 2,217 + 11-2021 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 2,218 + 12-1995 + C2 + Slave Labour + Author of Parties + + + 2,219 + 1-1991 + C1 + Massively Underpaid + Philosopher for Eternity + + + 2,220 + 10-2020 + C1 + Massively Underpaid + Vigilante of Doom + + + 2,221 + 6-1992 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 2,222 + 11-2020 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 2,223 + 12-2013 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 2,224 + 8-2020 + C1 + Massively Underpaid + Builder in Chief + + + 2,225 + 6-2005 + A2 + Overpaid + Vigilante Trainer + + + 2,226 + 12-1996 + A2 + Overpaid + Sports Mascot for Schools + + + 2,227 + 9-2013 + A2 + Overpaid + Builder for Schools + + + 2,228 + 3-2014 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 2,229 + 3-1990 + C2 + Slave Labour + Builder (Trainee) + + + 2,230 + 5-2023 + B1 + Fairly Paid + Vigilante of Doom + + + 2,231 + 7-2014 + A2 + Overpaid + Assassin of Cattle + + + 2,232 + 5-2019 + A2 + Overpaid + Historian for the Environment + + + 2,233 + 3-2001 + C2 + Slave Labour + Philosopher of Doom + + + 2,234 + 4-2020 + A2 + Overpaid + Food Taster Extraordinaire + + + 2,235 + 5-2000 + A2 + Overpaid + Philosopher of Cattle + + + 2,236 + 10-1993 + C2 + Slave Labour + Sports Mascot for Eternity + + + 2,237 + 8-2015 + B2 + Underpaid + Vigilante for the Environment + + + 2,238 + 6-1990 + A2 + Overpaid + Vigilante in Chief + + + 2,239 + 2-2021 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 2,240 + 10-1993 + C2 + Slave Labour + Assassin Extraordinaire + + + 2,241 + 5-2005 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 2,242 + 7-2011 + B1 + Fairly Paid + Builder of Doom + + + 2,243 + 4-2006 + B2 + Underpaid + Food Taster Laureate + + + 2,244 + 5-1997 + B1 + Fairly Paid + Software Developer (Trainee) + + + 2,245 + 10-1995 + B1 + Fairly Paid + Builder Laureate + + + 2,246 + 9-2005 + C2 + Slave Labour + Historian of Cattle + + + 2,247 + 4-2001 + A2 + Overpaid + Author of Cattle + + + 2,248 + 1-1994 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 2,249 + 5-1997 + B2 + Underpaid + Food Taster for the Environment + + + 2,250 + 12-1999 + B2 + Underpaid + Food Taster of Doom + + + 2,251 + 10-1995 + C2 + Slave Labour + Vigilante for Schools + + + 2,252 + 3-1998 + C2 + Slave Labour + Historian Trainer + + + 2,253 + 7-2018 + B2 + Underpaid + Vigilante for Schools + + + 2,254 + 7-2000 + B1 + Fairly Paid + Author of Cattle + + + 2,255 + 5-2014 + C1 + Massively Underpaid + Philosopher for the Environment + + + 2,256 + 7-1991 + A2 + Overpaid + Builder of Doom + + + 2,257 + 4-2012 + C1 + Massively Underpaid + Historian of Doom + + + 2,258 + 7-2004 + C1 + Massively Underpaid + Food Taster Laureate + + + 2,259 + 11-1994 + A2 + Overpaid + Builder (Trainee) + + + 2,260 + 2-2003 + B2 + Underpaid + Builder Trainer + + + 2,261 + 8-2015 + C2 + Slave Labour + Author for Schools + + + 2,262 + 12-2000 + B1 + Fairly Paid + Vigilante for Eternity + + + 2,263 + 10-1993 + A1 + Massively Overpaid + Assassin Trainer + + + 2,264 + 7-1996 + C1 + Massively Underpaid + Software Developer of Cattle + + + 2,265 + 2-2007 + B2 + Underpaid + Assassin Laureate + + + 2,266 + 5-1992 + B1 + Fairly Paid + Builder of Cattle + + + 2,267 + 3-1992 + B1 + Fairly Paid + Assassin for Eternity + + + 2,268 + 10-1995 + A1 + Massively Overpaid + Assassin of Cattle + + + 2,269 + 6-2018 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 2,270 + 2-1990 + B2 + Underpaid + Philosopher of Parties + + + 2,271 + 6-2023 + B1 + Fairly Paid + Food Taster of Cattle + + + 2,272 + 9-2008 + A1 + Massively Overpaid + Builder (Trainee) + + + 2,273 + 12-2006 + C1 + Massively Underpaid + Builder for the Environment + + + 2,274 + 10-2001 + B1 + Fairly Paid + Historian of Cattle + + + 2,275 + 8-2004 + A1 + Massively Overpaid + Software Developer in Chief + + + 2,276 + 5-2004 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 2,277 + 8-2004 + B1 + Fairly Paid + Vigilante for Schools + + + 2,278 + 8-2004 + C1 + Massively Underpaid + Author Laureate + + + 2,279 + 10-2012 + A1 + Massively Overpaid + Food Taster of Doom + + + 2,280 + 9-1997 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 2,281 + 6-2014 + A1 + Massively Overpaid + Historian for the Environment + + + 2,282 + 3-1991 + C2 + Slave Labour + Vigilante Extraordinaire + + + 2,283 + 3-2000 + A2 + Overpaid + Historian in Chief + + + 2,284 + 3-1996 + B2 + Underpaid + Builder for Eternity + + + 2,285 + 4-2020 + B1 + Fairly Paid + Historian for Schools + + + 2,286 + 12-2002 + C2 + Slave Labour + Historian in Chief + + + 2,287 + 1-2016 + B1 + Fairly Paid + Food Taster (Trainee) + + + 2,288 + 11-1995 + C2 + Slave Labour + Builder for Schools + + + 2,289 + 9-1996 + A2 + Overpaid + Food Taster for the Environment + + + 2,290 + 7-2001 + B2 + Underpaid + Builder (Trainee) + + + 2,291 + 8-2008 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 2,292 + 12-1999 + C2 + Slave Labour + Food Taster Trainer + + + 2,293 + 5-2006 + A2 + Overpaid + Philosopher Laureate + + + 2,294 + 11-2014 + B1 + Fairly Paid + Food Taster of Cattle + + + 2,295 + 8-2010 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 2,296 + 10-2013 + B1 + Fairly Paid + Historian Extraordinaire + + + 2,297 + 2-2007 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 2,298 + 11-2010 + C1 + Massively Underpaid + Assassin Laureate + + + 2,299 + 6-1999 + B2 + Underpaid + Author of Parties + + + 2,300 + 10-2021 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 2,301 + 11-2022 + A2 + Overpaid + Food Taster in Chief + + + 2,302 + 3-2020 + C2 + Slave Labour + Historian of Parties + + + 2,303 + 7-1996 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 2,304 + 11-2021 + A2 + Overpaid + Assassin for Schools + + + 2,305 + 5-2015 + A2 + Overpaid + Vigilante for Schools + + + 2,306 + 4-2010 + A2 + Overpaid + Historian of Doom + + + 2,307 + 1-2013 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 2,308 + 6-1993 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 2,309 + 9-1991 + A2 + Overpaid + Author for the Environment + + + 2,310 + 4-1998 + B2 + Underpaid + Sports Mascot (Trainee) + + + 2,311 + 10-2022 + B1 + Fairly Paid + Assassin for Eternity + + + 2,312 + 8-2016 + B2 + Underpaid + Philosopher of Parties + + + 2,313 + 11-1990 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 2,314 + 5-2014 + A2 + Overpaid + Assassin of Parties + + + 2,315 + 2-2005 + C2 + Slave Labour + Food Taster Trainer + + + 2,316 + 4-2005 + C1 + Massively Underpaid + Philosopher Laureate + + + 2,317 + 12-1992 + C2 + Slave Labour + Vigilante of Parties + + + 2,318 + 9-1991 + A2 + Overpaid + Sports Mascot of Cattle + + + 2,319 + 8-1999 + C1 + Massively Underpaid + Assassin Trainer + + + 2,320 + 4-2017 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 2,321 + 11-2023 + B2 + Underpaid + Philosopher Laureate + + + 2,322 + 4-2001 + C1 + Massively Underpaid + Philosopher for Eternity + + + 2,323 + 10-2010 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 2,324 + 5-1993 + A2 + Overpaid + Author (Trainee) + + + 2,325 + 3-2017 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 2,326 + 1-2001 + B2 + Underpaid + Skydiving Instructor of Parties + + + 2,327 + 3-2023 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 2,328 + 6-2014 + C1 + Massively Underpaid + Assassin for Eternity + + + 2,329 + 4-2017 + C1 + Massively Underpaid + Food Taster for Eternity + + + 2,330 + 11-2002 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 2,331 + 10-2003 + C2 + Slave Labour + Vigilante Trainer + + + 2,332 + 6-2023 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 2,333 + 5-1998 + B1 + Fairly Paid + Assassin for Eternity + + + 2,334 + 4-1990 + B1 + Fairly Paid + Builder of Cattle + + + 2,335 + 5-1999 + C2 + Slave Labour + Philosopher of Cattle + + + 2,336 + 11-2022 + B2 + Underpaid + Vigilante for Eternity + + + 2,337 + 9-2002 + C2 + Slave Labour + Philosopher of Doom + + + 2,338 + 9-1994 + A1 + Massively Overpaid + Philosopher of Cattle + + + 2,339 + 11-1998 + B2 + Underpaid + Skydiving Instructor for Schools + + + 2,340 + 4-2001 + C2 + Slave Labour + Builder of Cattle + + + 2,341 + 2-1994 + A2 + Overpaid + Author of Doom + + + 2,342 + 5-2022 + C2 + Slave Labour + Author of Parties + + + 2,343 + 12-2006 + A2 + Overpaid + Skydiving Instructor of Doom + + + 2,344 + 3-1997 + C2 + Slave Labour + Author (Trainee) + + + 2,345 + 11-2020 + A2 + Overpaid + Builder for the Environment + + + 2,346 + 8-2019 + C1 + Massively Underpaid + Assassin (Trainee) + + + 2,347 + 4-2023 + B2 + Underpaid + Food Taster (Trainee) + + + 2,348 + 12-2012 + B2 + Underpaid + Philosopher in Chief + + + 2,349 + 8-1998 + A2 + Overpaid + Historian for Schools + + + 2,350 + 3-1996 + C2 + Slave Labour + Software Developer of Parties + + + 2,351 + 10-2010 + A2 + Overpaid + Software Developer in Chief + + + 2,352 + 9-2020 + A2 + Overpaid + Assassin Trainer + + + 2,353 + 6-1997 + A1 + Massively Overpaid + Food Taster for Eternity + + + 2,354 + 10-1994 + B1 + Fairly Paid + Builder for Schools + + + 2,355 + 3-2017 + B1 + Fairly Paid + Philosopher (Trainee) + + + 2,356 + 5-2000 + B2 + Underpaid + Builder of Parties + + + 2,357 + 10-2018 + B2 + Underpaid + Food Taster of Cattle + + + 2,358 + 2-2021 + B2 + Underpaid + Software Developer Extraordinaire + + + 2,359 + 1-2007 + B1 + Fairly Paid + Assassin of Cattle + + + 2,360 + 1-1997 + A1 + Massively Overpaid + Food Taster of Doom + + + 2,361 + 7-2022 + B1 + Fairly Paid + Sports Mascot of Parties + + + 2,362 + 1-1994 + C1 + Massively Underpaid + Assassin of Doom + + + 2,363 + 7-2021 + B2 + Underpaid + Author in Chief + + + 2,364 + 2-1998 + C1 + Massively Underpaid + Assassin for Eternity + + + 2,365 + 4-1997 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 2,366 + 9-2008 + B2 + Underpaid + Sports Mascot for the Environment + + + 2,367 + 4-2005 + C2 + Slave Labour + Assassin (Trainee) + + + 2,368 + 2-2022 + A2 + Overpaid + Food Taster for Schools + + + 2,369 + 11-2017 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 2,370 + 9-2002 + C2 + Slave Labour + Philosopher Laureate + + + 2,371 + 8-2004 + A2 + Overpaid + Author of Parties + + + 2,372 + 7-1990 + A1 + Massively Overpaid + Author Trainer + + + 2,373 + 9-2004 + C2 + Slave Labour + Author Laureate + + + 2,374 + 3-1998 + C2 + Slave Labour + Philosopher for the Environment + + + 2,375 + 4-2014 + A2 + Overpaid + Historian (Trainee) + + + 2,376 + 10-2014 + A2 + Overpaid + Builder Laureate + + + 2,377 + 10-2001 + A1 + Massively Overpaid + Assassin for the Environment + + + 2,378 + 10-2007 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 2,379 + 8-2003 + C2 + Slave Labour + Builder Trainer + + + 2,380 + 9-1990 + B1 + Fairly Paid + Vigilante of Cattle + + + 2,381 + 9-2001 + B1 + Fairly Paid + Historian for Schools + + + 2,382 + 4-1998 + A1 + Massively Overpaid + Historian for Eternity + + + 2,383 + 8-1997 + A2 + Overpaid + Food Taster of Doom + + + 2,384 + 2-2020 + C2 + Slave Labour + Author (Trainee) + + + 2,385 + 1-2019 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 2,386 + 4-2001 + B1 + Fairly Paid + Assassin for the Environment + + + 2,387 + 3-2021 + B1 + Fairly Paid + Software Developer of Parties + + + 2,388 + 12-2008 + C1 + Massively Underpaid + Philosopher Trainer + + + 2,389 + 6-2020 + C2 + Slave Labour + Food Taster for the Environment + + + 2,390 + 10-2019 + B2 + Underpaid + Author Extraordinaire + + + 2,391 + 4-2013 + A1 + Massively Overpaid + Vigilante in Chief + + + 2,392 + 8-1996 + C1 + Massively Underpaid + Software Developer for Schools + + + 2,393 + 4-1996 + B1 + Fairly Paid + Software Developer of Parties + + + 2,394 + 9-2001 + C1 + Massively Underpaid + Software Developer in Chief + + + 2,395 + 4-2011 + B2 + Underpaid + Historian Trainer + + + 2,396 + 7-2002 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 2,397 + 10-2008 + C1 + Massively Underpaid + Philosopher of Cattle + + + 2,398 + 10-2020 + B1 + Fairly Paid + Assassin for the Environment + + + 2,399 + 1-2002 + B1 + Fairly Paid + Software Developer for Eternity + + + 2,400 + 3-2001 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 2,401 + 7-2013 + A1 + Massively Overpaid + Software Developer of Cattle + + + 2,402 + 7-2020 + B2 + Underpaid + Vigilante Laureate + + + 2,403 + 2-1996 + B2 + Underpaid + Assassin of Cattle + + + 2,404 + 7-1994 + A2 + Overpaid + Food Taster for Schools + + + 2,405 + 10-2019 + B2 + Underpaid + Author for Eternity + + + 2,406 + 10-2021 + A1 + Massively Overpaid + Historian of Parties + + + 2,407 + 3-2000 + A2 + Overpaid + Philosopher (Trainee) + + + 2,408 + 4-2013 + C2 + Slave Labour + Author for Schools + + + 2,409 + 9-1994 + B2 + Underpaid + Historian of Doom + + + 2,410 + 1-2006 + C2 + Slave Labour + Philosopher in Chief + + + 2,411 + 11-2013 + C1 + Massively Underpaid + Builder in Chief + + + 2,412 + 9-2017 + B2 + Underpaid + Author Extraordinaire + + + 2,413 + 5-2008 + A2 + Overpaid + Assassin for the Environment + + + 2,414 + 3-2005 + C2 + Slave Labour + Vigilante of Parties + + + 2,415 + 8-1995 + B1 + Fairly Paid + Software Developer Laureate + + + 2,416 + 12-2004 + B1 + Fairly Paid + Vigilante of Cattle + + + 2,417 + 8-2013 + C2 + Slave Labour + Philosopher Extraordinaire + + + 2,418 + 3-1998 + B1 + Fairly Paid + Assassin of Cattle + + + 2,419 + 12-2003 + A2 + Overpaid + Philosopher Extraordinaire + + + 2,420 + 11-2015 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 2,421 + 7-2001 + C2 + Slave Labour + Builder (Trainee) + + + 2,422 + 2-1990 + B2 + Underpaid + Sports Mascot of Parties + + + 2,423 + 11-1991 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 2,424 + 6-2023 + B1 + Fairly Paid + Builder Laureate + + + 2,425 + 6-2012 + B2 + Underpaid + Food Taster of Doom + + + 2,426 + 11-2011 + B1 + Fairly Paid + Food Taster for Schools + + + 2,427 + 2-1997 + B2 + Underpaid + Builder for the Environment + + + 2,428 + 7-1995 + A1 + Massively Overpaid + Author in Chief + + + 2,429 + 6-1996 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 2,430 + 4-1994 + C1 + Massively Underpaid + Vigilante for Schools + + + 2,431 + 7-2005 + C2 + Slave Labour + Historian for the Environment + + + 2,432 + 7-2020 + A1 + Massively Overpaid + Food Taster of Doom + + + 2,433 + 2-2001 + C2 + Slave Labour + Author Trainer + + + 2,434 + 4-2022 + B2 + Underpaid + Vigilante (Trainee) + + + 2,435 + 9-2016 + B1 + Fairly Paid + Historian (Trainee) + + + 2,436 + 1-2017 + A1 + Massively Overpaid + Historian Extraordinaire + + + 2,437 + 3-2003 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 2,438 + 7-1992 + C2 + Slave Labour + Vigilante Trainer + + + 2,439 + 7-2022 + C2 + Slave Labour + Vigilante of Parties + + + 2,440 + 1-2003 + B1 + Fairly Paid + Assassin Laureate + + + 2,441 + 11-2016 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 2,442 + 6-1993 + C1 + Massively Underpaid + Builder of Cattle + + + 2,443 + 1-1994 + B2 + Underpaid + Vigilante of Parties + + + 2,444 + 11-2020 + A2 + Overpaid + Sports Mascot Extraordinaire + + + 2,445 + 5-2019 + B1 + Fairly Paid + Philosopher for the Environment + + + 2,446 + 4-2016 + A2 + Overpaid + Author (Trainee) + + + 2,447 + 12-2002 + C2 + Slave Labour + Builder (Trainee) + + + 2,448 + 3-1996 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 2,449 + 8-2012 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 2,450 + 3-2013 + A1 + Massively Overpaid + Builder of Doom + + + 2,451 + 8-2005 + B2 + Underpaid + Software Developer for Schools + + + 2,452 + 4-2005 + C2 + Slave Labour + Assassin Laureate + + + 2,453 + 12-2000 + B1 + Fairly Paid + Vigilante of Parties + + + 2,454 + 1-1998 + C2 + Slave Labour + Assassin of Doom + + + 2,455 + 11-2005 + C2 + Slave Labour + Builder in Chief + + + 2,456 + 4-2005 + B2 + Underpaid + Assassin (Trainee) + + + 2,457 + 9-2018 + B2 + Underpaid + Assassin of Doom + + + 2,458 + 2-2016 + A2 + Overpaid + Skydiving Instructor for Schools + + + 2,459 + 8-2004 + C1 + Massively Underpaid + Vigilante Trainer + + + 2,460 + 3-1994 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 2,461 + 4-1991 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 2,462 + 2-1992 + C1 + Massively Underpaid + Food Taster of Doom + + + 2,463 + 5-2004 + B1 + Fairly Paid + Historian of Parties + + + 2,464 + 1-2006 + B2 + Underpaid + Food Taster (Trainee) + + + 2,465 + 12-2017 + C2 + Slave Labour + Philosopher of Parties + + + 2,466 + 12-2011 + A1 + Massively Overpaid + Builder Laureate + + + 2,467 + 3-2016 + B1 + Fairly Paid + Vigilante of Parties + + + 2,468 + 7-2005 + A2 + Overpaid + Author Extraordinaire + + + 2,469 + 11-2012 + C1 + Massively Underpaid + Software Developer for Schools + + + 2,470 + 9-2008 + B2 + Underpaid + Historian of Doom + + + 2,471 + 2-1994 + B2 + Underpaid + Software Developer for Schools + + + 2,472 + 10-2004 + A1 + Massively Overpaid + Builder Trainer + + + 2,473 + 1-2005 + C2 + Slave Labour + Historian for Schools + + + 2,474 + 4-2023 + B2 + Underpaid + Assassin for Schools + + + 2,475 + 5-2014 + B1 + Fairly Paid + Assassin for Schools + + + 2,476 + 2-2021 + C2 + Slave Labour + Author Extraordinaire + + + 2,477 + 5-1998 + A2 + Overpaid + Historian Extraordinaire + + + 2,478 + 8-2006 + C2 + Slave Labour + Philosopher Trainer + + + 2,479 + 3-2013 + B2 + Underpaid + Builder for Eternity + + + 2,480 + 6-2014 + A2 + Overpaid + Historian of Doom + + + 2,481 + 8-2001 + B1 + Fairly Paid + Philosopher (Trainee) + + + 2,482 + 12-2007 + C2 + Slave Labour + Philosopher of Cattle + + + 2,483 + 11-1994 + C2 + Slave Labour + Sports Mascot for Eternity + + + 2,484 + 4-2005 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 2,485 + 7-2021 + A2 + Overpaid + Historian of Doom + + + 2,486 + 7-1998 + C1 + Massively Underpaid + Philosopher Laureate + + + 2,487 + 6-1995 + A2 + Overpaid + Vigilante in Chief + + + 2,488 + 5-2008 + C1 + Massively Underpaid + Software Developer for Schools + + + 2,489 + 2-2014 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 2,490 + 7-2001 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 2,491 + 3-2003 + C2 + Slave Labour + Assassin for Schools + + + 2,492 + 11-2005 + A2 + Overpaid + Assassin of Parties + + + 2,493 + 3-2021 + B2 + Underpaid + Sports Mascot Laureate + + + 2,494 + 4-2007 + C2 + Slave Labour + Sports Mascot for Schools + + + 2,495 + 5-2017 + C2 + Slave Labour + Food Taster for the Environment + + + 2,496 + 2-2021 + C1 + Massively Underpaid + Vigilante of Cattle + + + 2,497 + 4-2000 + B1 + Fairly Paid + Assassin for Schools + + + 2,498 + 3-2021 + A1 + Massively Overpaid + Philosopher of Doom + + + 2,499 + 10-2016 + C1 + Massively Underpaid + Software Developer for the Environment + + + 2,500 + 10-1997 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 2,501 + 10-2013 + C2 + Slave Labour + Assassin in Chief + + + 2,502 + 5-1994 + C1 + Massively Underpaid + Builder of Doom + + + 2,503 + 11-2020 + C1 + Massively Underpaid + Builder (Trainee) + + + 2,504 + 3-2020 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 2,505 + 6-1997 + B1 + Fairly Paid + Assassin Laureate + + + 2,506 + 2-2022 + A2 + Overpaid + Historian of Parties + + + 2,507 + 8-2014 + B1 + Fairly Paid + Historian of Cattle + + + 2,508 + 8-2018 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 2,509 + 1-2023 + A2 + Overpaid + Builder Trainer + + + 2,510 + 10-2021 + C2 + Slave Labour + Philosopher Extraordinaire + + + 2,511 + 1-1997 + C1 + Massively Underpaid + Software Developer for the Environment + + + 2,512 + 1-2012 + C2 + Slave Labour + Historian Laureate + + + 2,513 + 12-2016 + A2 + Overpaid + Historian for Eternity + + + 2,514 + 6-2020 + A1 + Massively Overpaid + Philosopher for Eternity + + + 2,515 + 2-1992 + C2 + Slave Labour + Builder in Chief + + + 2,516 + 8-1992 + C2 + Slave Labour + Philosopher Trainer + + + 2,517 + 7-2000 + B2 + Underpaid + Builder of Doom + + + 2,518 + 1-1994 + A2 + Overpaid + Builder of Cattle + + + 2,519 + 9-2003 + B2 + Underpaid + Skydiving Instructor for Schools + + + 2,520 + 7-1996 + C1 + Massively Underpaid + Software Developer of Doom + + + 2,521 + 8-2022 + C1 + Massively Underpaid + Builder in Chief + + + 2,522 + 6-1992 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 2,523 + 7-2018 + C1 + Massively Underpaid + Historian for the Environment + + + 2,524 + 11-2013 + C2 + Slave Labour + Sports Mascot of Doom + + + 2,525 + 12-1992 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 2,526 + 7-1994 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 2,527 + 3-2006 + B2 + Underpaid + Builder Trainer + + + 2,528 + 10-1997 + B1 + Fairly Paid + Author (Trainee) + + + 2,529 + 10-2009 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 2,530 + 12-2010 + A1 + Massively Overpaid + Vigilante Trainer + + + 2,531 + 6-2014 + A2 + Overpaid + Skydiving Instructor for Schools + + + 2,532 + 3-2001 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 2,533 + 7-2013 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 2,534 + 10-2012 + A2 + Overpaid + Author for the Environment + + + 2,535 + 10-2016 + B2 + Underpaid + Assassin of Parties + + + 2,536 + 11-1993 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 2,537 + 10-2016 + B2 + Underpaid + Philosopher in Chief + + + 2,538 + 7-2020 + B2 + Underpaid + Vigilante Trainer + + + 2,539 + 2-2002 + B2 + Underpaid + Philosopher in Chief + + + 2,540 + 12-2017 + B2 + Underpaid + Software Developer in Chief + + + 2,541 + 1-2023 + B1 + Fairly Paid + Software Developer (Trainee) + + + 2,542 + 10-1997 + A1 + Massively Overpaid + Builder for Eternity + + + 2,543 + 2-1992 + A1 + Massively Overpaid + Builder for Schools + + + 2,544 + 5-2016 + B1 + Fairly Paid + Philosopher for Eternity + + + 2,545 + 1-2008 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 2,546 + 12-1992 + A2 + Overpaid + Assassin Trainer + + + 2,547 + 1-1998 + C2 + Slave Labour + Software Developer of Cattle + + + 2,548 + 10-1990 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 2,549 + 3-2000 + C2 + Slave Labour + Philosopher of Parties + + + 2,550 + 2-2003 + B1 + Fairly Paid + Vigilante (Trainee) + + + 2,551 + 1-1997 + A1 + Massively Overpaid + Author of Doom + + + 2,552 + 2-2005 + B2 + Underpaid + Author Trainer + + + 2,553 + 4-2023 + A2 + Overpaid + Philosopher Trainer + + + 2,554 + 11-2005 + B1 + Fairly Paid + Vigilante of Cattle + + + 2,555 + 12-2005 + C2 + Slave Labour + Historian of Cattle + + + 2,556 + 4-2002 + A2 + Overpaid + Builder Extraordinaire + + + 2,557 + 8-1992 + C1 + Massively Underpaid + Historian Extraordinaire + + + 2,558 + 9-2017 + C2 + Slave Labour + Vigilante Trainer + + + 2,559 + 1-2017 + B2 + Underpaid + Sports Mascot for Eternity + + + 2,560 + 7-2009 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 2,561 + 2-2008 + C1 + Massively Underpaid + Historian for Eternity + + + 2,562 + 12-2002 + C1 + Massively Underpaid + Philosopher Laureate + + + 2,563 + 8-2023 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 2,564 + 4-1998 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 2,565 + 7-1990 + A2 + Overpaid + Sports Mascot of Parties + + + 2,566 + 12-2000 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 2,567 + 11-2016 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 2,568 + 7-1999 + B1 + Fairly Paid + Vigilante of Parties + + + 2,569 + 10-2005 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 2,570 + 8-2008 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 2,571 + 6-2001 + A2 + Overpaid + Builder of Cattle + + + 2,572 + 1-2002 + B2 + Underpaid + Software Developer in Chief + + + 2,573 + 1-1993 + C1 + Massively Underpaid + Assassin Laureate + + + 2,574 + 4-2005 + B2 + Underpaid + Sports Mascot of Parties + + + 2,575 + 11-2002 + C2 + Slave Labour + Philosopher for Eternity + + + 2,576 + 2-1995 + A1 + Massively Overpaid + Food Taster of Parties + + + 2,577 + 2-1999 + B1 + Fairly Paid + Sports Mascot Laureate + + + 2,578 + 11-1992 + B2 + Underpaid + Assassin for the Environment + + + 2,579 + 12-2017 + B2 + Underpaid + Sports Mascot of Cattle + + + 2,580 + 7-2023 + B2 + Underpaid + Food Taster Laureate + + + 2,581 + 1-2017 + C2 + Slave Labour + Builder of Parties + + + 2,582 + 6-2008 + A1 + Massively Overpaid + Vigilante of Doom + + + 2,583 + 1-2021 + A1 + Massively Overpaid + Assassin of Parties + + + 2,584 + 11-2013 + B1 + Fairly Paid + Philosopher for the Environment + + + 2,585 + 1-2013 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 2,586 + 5-2014 + A2 + Overpaid + Sports Mascot (Trainee) + + + 2,587 + 9-2023 + C2 + Slave Labour + Builder for the Environment + + + 2,588 + 8-1995 + C2 + Slave Labour + Builder in Chief + + + 2,589 + 1-2019 + C2 + Slave Labour + Author in Chief + + + 2,590 + 1-2001 + C2 + Slave Labour + Food Taster for Schools + + + 2,591 + 4-2004 + A2 + Overpaid + Software Developer Extraordinaire + + + 2,592 + 4-2015 + A1 + Massively Overpaid + Assassin for Eternity + + + 2,593 + 5-2015 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 2,594 + 3-2010 + C2 + Slave Labour + Builder for the Environment + + + 2,595 + 4-2010 + A2 + Overpaid + Philosopher (Trainee) + + + 2,596 + 2-2009 + B1 + Fairly Paid + Food Taster for Schools + + + 2,597 + 11-2021 + A1 + Massively Overpaid + Philosopher of Parties + + + 2,598 + 7-2015 + C2 + Slave Labour + Historian for Eternity + + + 2,599 + 7-2014 + C2 + Slave Labour + Assassin Extraordinaire + + + 2,600 + 2-2018 + A2 + Overpaid + Builder for Eternity + + + 2,601 + 9-2015 + A2 + Overpaid + Skydiving Instructor for Schools + + + 2,602 + 5-2001 + C2 + Slave Labour + Software Developer in Chief + + + 2,603 + 5-2022 + B1 + Fairly Paid + Historian in Chief + + + 2,604 + 1-2010 + B2 + Underpaid + Software Developer for Eternity + + + 2,605 + 1-2011 + B1 + Fairly Paid + Sports Mascot Trainer + + + 2,606 + 9-2003 + B1 + Fairly Paid + Software Developer Trainer + + + 2,607 + 10-1999 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 2,608 + 11-1993 + C2 + Slave Labour + Philosopher of Doom + + + 2,609 + 12-1993 + A2 + Overpaid + Sports Mascot of Doom + + + 2,610 + 3-2022 + B1 + Fairly Paid + Assassin for Schools + + + 2,611 + 6-2022 + C2 + Slave Labour + Philosopher (Trainee) + + + 2,612 + 12-2020 + B2 + Underpaid + Software Developer for Eternity + + + 2,613 + 7-1990 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 2,614 + 9-1998 + B1 + Fairly Paid + Historian Laureate + + + 2,615 + 5-1990 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 2,616 + 1-1997 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 2,617 + 11-2022 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 2,618 + 5-2012 + A2 + Overpaid + Food Taster for Schools + + + 2,619 + 11-1990 + B2 + Underpaid + Skydiving Instructor in Chief + + + 2,620 + 12-2003 + B2 + Underpaid + Food Taster of Parties + + + 2,621 + 4-1999 + C1 + Massively Underpaid + Author for Eternity + + + 2,622 + 4-1990 + C2 + Slave Labour + Assassin for the Environment + + + 2,623 + 5-2017 + C2 + Slave Labour + Philosopher (Trainee) + + + 2,624 + 7-2015 + A2 + Overpaid + Philosopher Laureate + + + 2,625 + 7-2015 + A2 + Overpaid + Author of Parties + + + 2,626 + 9-1994 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 2,627 + 10-2012 + A2 + Overpaid + Historian for Schools + + + 2,628 + 10-2009 + A1 + Massively Overpaid + Vigilante for Schools + + + 2,629 + 1-2020 + C2 + Slave Labour + Sports Mascot for Schools + + + 2,630 + 7-2008 + A1 + Massively Overpaid + Author of Doom + + + 2,631 + 12-1994 + A1 + Massively Overpaid + Author Laureate + + + 2,632 + 5-1995 + B2 + Underpaid + Food Taster for the Environment + + + 2,633 + 8-2017 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 2,634 + 1-1992 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 2,635 + 2-2000 + B2 + Underpaid + Vigilante Extraordinaire + + + 2,636 + 11-2013 + A2 + Overpaid + Philosopher for Schools + + + 2,637 + 12-2007 + C2 + Slave Labour + Historian in Chief + + + 2,638 + 4-2011 + C1 + Massively Underpaid + Food Taster for the Environment + + + 2,639 + 1-1997 + C1 + Massively Underpaid + Philosopher in Chief + + + 2,640 + 1-1996 + B2 + Underpaid + Food Taster of Parties + + + 2,641 + 10-2002 + B2 + Underpaid + Builder for the Environment + + + 2,642 + 4-2016 + B2 + Underpaid + Historian for the Environment + + + 2,643 + 8-2017 + B2 + Underpaid + Sports Mascot for Schools + + + 2,644 + 7-2009 + C1 + Massively Underpaid + Historian Extraordinaire + + + 2,645 + 10-2000 + C1 + Massively Underpaid + Food Taster Trainer + + + 2,646 + 8-2016 + A2 + Overpaid + Builder Extraordinaire + + + 2,647 + 9-1997 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 2,648 + 1-1993 + B2 + Underpaid + Author Trainer + + + 2,649 + 3-2000 + A2 + Overpaid + Food Taster for the Environment + + + 2,650 + 11-2001 + A1 + Massively Overpaid + Author of Cattle + + + 2,651 + 5-1990 + C2 + Slave Labour + Assassin Laureate + + + 2,652 + 4-2000 + B2 + Underpaid + Sports Mascot for Eternity + + + 2,653 + 9-1995 + B2 + Underpaid + Skydiving Instructor of Parties + + + 2,654 + 12-2023 + A1 + Massively Overpaid + Philosopher of Doom + + + 2,655 + 7-2002 + A1 + Massively Overpaid + Builder Extraordinaire + + + 2,656 + 8-1991 + B1 + Fairly Paid + Author Laureate + + + 2,657 + 11-2021 + C2 + Slave Labour + Food Taster for Schools + + + 2,658 + 6-1992 + A2 + Overpaid + Food Taster of Doom + + + 2,659 + 5-1997 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 2,660 + 6-1997 + B1 + Fairly Paid + Assassin for Schools + + + 2,661 + 2-1997 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 2,662 + 9-1994 + B1 + Fairly Paid + Historian of Parties + + + 2,663 + 4-2000 + B1 + Fairly Paid + Assassin of Parties + + + 2,664 + 7-2006 + A2 + Overpaid + Historian Laureate + + + 2,665 + 6-2003 + B2 + Underpaid + Software Developer for Schools + + + 2,666 + 2-2010 + A1 + Massively Overpaid + Builder of Doom + + + 2,667 + 7-2013 + B2 + Underpaid + Builder for Schools + + + 2,668 + 11-1994 + A2 + Overpaid + Software Developer for Schools + + + 2,669 + 12-2006 + C1 + Massively Underpaid + Vigilante for the Environment + + + 2,670 + 9-2003 + B2 + Underpaid + Sports Mascot Laureate + + + 2,671 + 7-2012 + C2 + Slave Labour + Software Developer in Chief + + + 2,672 + 7-2016 + C1 + Massively Underpaid + Software Developer of Parties + + + 2,673 + 9-2008 + B2 + Underpaid + Builder of Cattle + + + 2,674 + 1-2007 + A1 + Massively Overpaid + Vigilante of Doom + + + 2,675 + 10-2016 + C1 + Massively Underpaid + Software Developer Trainer + + + 2,676 + 6-2000 + A1 + Massively Overpaid + Builder of Cattle + + + 2,677 + 6-2013 + C2 + Slave Labour + Vigilante of Cattle + + + 2,678 + 10-2002 + A1 + Massively Overpaid + Historian Extraordinaire + + + 2,679 + 9-2018 + A2 + Overpaid + Builder of Doom + + + 2,680 + 11-2011 + B1 + Fairly Paid + Software Developer of Parties + + + 2,681 + 8-1996 + B2 + Underpaid + Vigilante of Doom + + + 2,682 + 11-1999 + B2 + Underpaid + Skydiving Instructor of Doom + + + 2,683 + 12-2016 + A1 + Massively Overpaid + Software Developer for Eternity + + + 2,684 + 1-1999 + C2 + Slave Labour + Sports Mascot of Parties + + + 2,685 + 12-1997 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 2,686 + 11-2015 + A2 + Overpaid + Historian of Parties + + + 2,687 + 2-2007 + A1 + Massively Overpaid + Assassin of Cattle + + + 2,688 + 12-2015 + A2 + Overpaid + Philosopher Trainer + + + 2,689 + 9-2011 + B2 + Underpaid + Philosopher of Parties + + + 2,690 + 9-2023 + C2 + Slave Labour + Historian of Cattle + + + 2,691 + 10-2005 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 2,692 + 12-1999 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 2,693 + 10-2003 + B1 + Fairly Paid + Historian in Chief + + + 2,694 + 8-1998 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 2,695 + 10-2013 + C1 + Massively Underpaid + Software Developer for Schools + + + 2,696 + 9-2011 + B2 + Underpaid + Sports Mascot Laureate + + + 2,697 + 3-2001 + A2 + Overpaid + Vigilante Laureate + + + 2,698 + 6-2006 + A2 + Overpaid + Author in Chief + + + 2,699 + 2-2021 + C2 + Slave Labour + Food Taster Trainer + + + 2,700 + 1-2006 + B1 + Fairly Paid + Software Developer (Trainee) + + + 2,701 + 3-2008 + B1 + Fairly Paid + Author of Parties + + + 2,702 + 1-1995 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 2,703 + 4-2022 + B2 + Underpaid + Assassin for Schools + + + 2,704 + 6-1991 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 2,705 + 8-2003 + C2 + Slave Labour + Sports Mascot for Schools + + + 2,706 + 3-2015 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 2,707 + 3-2004 + A2 + Overpaid + Software Developer for the Environment + + + 2,708 + 3-2017 + B1 + Fairly Paid + Vigilante of Cattle + + + 2,709 + 7-2001 + C1 + Massively Underpaid + Vigilante in Chief + + + 2,710 + 3-2020 + B1 + Fairly Paid + Vigilante for Schools + + + 2,711 + 6-2020 + A2 + Overpaid + Assassin Laureate + + + 2,712 + 6-2017 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 2,713 + 7-2013 + C1 + Massively Underpaid + Software Developer of Doom + + + 2,714 + 5-1991 + A1 + Massively Overpaid + Vigilante of Parties + + + 2,715 + 8-2008 + A2 + Overpaid + Philosopher (Trainee) + + + 2,716 + 1-2007 + C1 + Massively Underpaid + Assassin for Schools + + + 2,717 + 5-1999 + B1 + Fairly Paid + Food Taster of Parties + + + 2,718 + 12-2013 + B2 + Underpaid + Sports Mascot Trainer + + + 2,719 + 6-2014 + B1 + Fairly Paid + Historian (Trainee) + + + 2,720 + 3-1999 + A2 + Overpaid + Builder for Eternity + + + 2,721 + 1-2019 + B2 + Underpaid + Builder of Parties + + + 2,722 + 3-2015 + C2 + Slave Labour + Historian for the Environment + + + 2,723 + 1-2015 + A2 + Overpaid + Software Developer Laureate + + + 2,724 + 2-1990 + C2 + Slave Labour + Assassin (Trainee) + + + 2,725 + 9-2015 + A2 + Overpaid + Builder of Doom + + + 2,726 + 5-2006 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 2,727 + 6-1997 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 2,728 + 10-2008 + C1 + Massively Underpaid + Historian for the Environment + + + 2,729 + 4-2010 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 2,730 + 6-1995 + C2 + Slave Labour + Vigilante for Schools + + + 2,731 + 11-2009 + C1 + Massively Underpaid + Vigilante for Eternity + + + 2,732 + 9-2001 + C1 + Massively Underpaid + Builder Laureate + + + 2,733 + 10-2015 + B2 + Underpaid + Author of Cattle + + + 2,734 + 7-2015 + A2 + Overpaid + Historian for Schools + + + 2,735 + 8-2011 + C2 + Slave Labour + Author of Cattle + + + 2,736 + 1-2022 + C2 + Slave Labour + Philosopher for Schools + + + 2,737 + 7-2000 + A1 + Massively Overpaid + Builder Extraordinaire + + + 2,738 + 2-2008 + A1 + Massively Overpaid + Food Taster of Doom + + + 2,739 + 4-2002 + B1 + Fairly Paid + Vigilante for Schools + + + 2,740 + 4-1991 + C1 + Massively Underpaid + Historian for Schools + + + 2,741 + 9-1990 + B2 + Underpaid + Assassin in Chief + + + 2,742 + 8-2019 + A2 + Overpaid + Author in Chief + + + 2,743 + 6-2012 + B2 + Underpaid + Vigilante (Trainee) + + + 2,744 + 11-2019 + A2 + Overpaid + Author Trainer + + + 2,745 + 11-2014 + C2 + Slave Labour + Builder Trainer + + + 2,746 + 8-2011 + A2 + Overpaid + Software Developer of Doom + + + 2,747 + 12-2013 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 2,748 + 10-2003 + C2 + Slave Labour + Sports Mascot in Chief + + + 2,749 + 12-2016 + B2 + Underpaid + Assassin for Schools + + + 2,750 + 4-2014 + A2 + Overpaid + Skydiving Instructor of Doom + + + 2,751 + 3-2001 + B1 + Fairly Paid + Historian Laureate + + + 2,752 + 12-2002 + C2 + Slave Labour + Historian of Cattle + + + 2,753 + 4-1992 + B1 + Fairly Paid + Software Developer Laureate + + + 2,754 + 8-2001 + C2 + Slave Labour + Sports Mascot for Schools + + + 2,755 + 9-2021 + A2 + Overpaid + Builder for Eternity + + + 2,756 + 10-2017 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 2,757 + 11-1999 + A2 + Overpaid + Philosopher Extraordinaire + + + 2,758 + 6-1996 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 2,759 + 4-2007 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 2,760 + 7-1993 + B2 + Underpaid + Sports Mascot for the Environment + + + 2,761 + 4-2019 + C2 + Slave Labour + Vigilante of Parties + + + 2,762 + 6-2003 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 2,763 + 7-2016 + C1 + Massively Underpaid + Builder of Doom + + + 2,764 + 11-2000 + B1 + Fairly Paid + Builder of Cattle + + + 2,765 + 10-2007 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 2,766 + 11-1993 + B1 + Fairly Paid + Author (Trainee) + + + 2,767 + 12-2023 + A1 + Massively Overpaid + Builder for Schools + + + 2,768 + 5-2013 + C1 + Massively Underpaid + Historian Trainer + + + 2,769 + 3-2006 + A2 + Overpaid + Author Trainer + + + 2,770 + 10-2021 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 2,771 + 8-2020 + B2 + Underpaid + Software Developer for Eternity + + + 2,772 + 3-1996 + A1 + Massively Overpaid + Historian of Doom + + + 2,773 + 12-1991 + A1 + Massively Overpaid + Historian of Cattle + + + 2,774 + 3-2010 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 2,775 + 7-2020 + C2 + Slave Labour + Assassin Trainer + + + 2,776 + 5-2010 + A1 + Massively Overpaid + Vigilante Trainer + + + 2,777 + 9-2017 + C2 + Slave Labour + Sports Mascot of Parties + + + 2,778 + 9-2005 + A2 + Overpaid + Assassin of Cattle + + + 2,779 + 9-2019 + A2 + Overpaid + Philosopher Laureate + + + 2,780 + 10-2016 + B1 + Fairly Paid + Food Taster Trainer + + + 2,781 + 8-1995 + B2 + Underpaid + Author Extraordinaire + + + 2,782 + 9-1992 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 2,783 + 2-2004 + A1 + Massively Overpaid + Historian (Trainee) + + + 2,784 + 4-2014 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 2,785 + 2-2005 + C2 + Slave Labour + Food Taster Trainer + + + 2,786 + 4-1997 + A2 + Overpaid + Historian Trainer + + + 2,787 + 7-1994 + B1 + Fairly Paid + Builder Trainer + + + 2,788 + 11-2012 + B1 + Fairly Paid + Food Taster of Doom + + + 2,789 + 12-1999 + B2 + Underpaid + Builder for Eternity + + + 2,790 + 5-1997 + C1 + Massively Underpaid + Assassin for the Environment + + + 2,791 + 4-2023 + B2 + Underpaid + Sports Mascot Trainer + + + 2,792 + 7-2021 + A2 + Overpaid + Assassin (Trainee) + + + 2,793 + 12-2019 + B1 + Fairly Paid + Builder for Eternity + + + 2,794 + 9-2022 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 2,795 + 10-1994 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 2,796 + 7-2018 + C1 + Massively Underpaid + Food Taster for the Environment + + + 2,797 + 5-2010 + A2 + Overpaid + Food Taster in Chief + + + 2,798 + 5-2023 + B1 + Fairly Paid + Philosopher for the Environment + + + 2,799 + 2-2007 + A1 + Massively Overpaid + Assassin of Cattle + + + 2,800 + 10-2004 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 2,801 + 11-2014 + C1 + Massively Underpaid + Builder Laureate + + + 2,802 + 3-2022 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 2,803 + 12-2018 + C1 + Massively Underpaid + Vigilante Trainer + + + 2,804 + 6-2019 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 2,805 + 4-2015 + C1 + Massively Underpaid + Assassin of Parties + + + 2,806 + 11-2012 + C1 + Massively Underpaid + Software Developer Laureate + + + 2,807 + 4-2007 + A2 + Overpaid + Author of Cattle + + + 2,808 + 12-2012 + A1 + Massively Overpaid + Software Developer for Schools + + + 2,809 + 3-2000 + A1 + Massively Overpaid + Assassin for Schools + + + 2,810 + 8-1994 + C1 + Massively Underpaid + Philosopher of Doom + + + 2,811 + 7-1991 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 2,812 + 11-2005 + A2 + Overpaid + Assassin of Parties + + + 2,813 + 3-1992 + C2 + Slave Labour + Sports Mascot of Cattle + + + 2,814 + 2-2019 + A1 + Massively Overpaid + Builder in Chief + + + 2,815 + 5-2009 + B2 + Underpaid + Assassin for Schools + + + 2,816 + 2-2015 + A1 + Massively Overpaid + Author Extraordinaire + + + 2,817 + 10-2011 + A1 + Massively Overpaid + Author for the Environment + + + 2,818 + 10-2021 + A2 + Overpaid + Sports Mascot Laureate + + + 2,819 + 2-2019 + C1 + Massively Underpaid + Software Developer of Doom + + + 2,820 + 8-2018 + A2 + Overpaid + Software Developer of Doom + + + 2,821 + 4-2018 + A1 + Massively Overpaid + Food Taster for Eternity + + + 2,822 + 2-2020 + B2 + Underpaid + Vigilante of Parties + + + 2,823 + 3-1998 + C1 + Massively Underpaid + Vigilante Trainer + + + 2,824 + 3-2000 + B1 + Fairly Paid + Assassin (Trainee) + + + 2,825 + 3-2008 + C1 + Massively Underpaid + Food Taster of Doom + + + 2,826 + 1-1995 + B1 + Fairly Paid + Historian of Cattle + + + 2,827 + 4-2015 + A1 + Massively Overpaid + Food Taster of Parties + + + 2,828 + 9-2000 + B1 + Fairly Paid + Software Developer of Parties + + + 2,829 + 4-2018 + B2 + Underpaid + Philosopher for Schools + + + 2,830 + 10-1997 + A2 + Overpaid + Philosopher in Chief + + + 2,831 + 8-2003 + C2 + Slave Labour + Software Developer Laureate + + + 2,832 + 7-2021 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 2,833 + 11-1990 + C2 + Slave Labour + Assassin for the Environment + + + 2,834 + 12-2009 + B2 + Underpaid + Historian for the Environment + + + 2,835 + 12-2011 + B2 + Underpaid + Author for Schools + + + 2,836 + 4-2013 + C2 + Slave Labour + Software Developer of Cattle + + + 2,837 + 4-1992 + A1 + Massively Overpaid + Author for the Environment + + + 2,838 + 5-2012 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 2,839 + 5-2019 + A2 + Overpaid + Author Laureate + + + 2,840 + 2-2015 + A2 + Overpaid + Software Developer (Trainee) + + + 2,841 + 8-2006 + C2 + Slave Labour + Software Developer for Schools + + + 2,842 + 1-1995 + A2 + Overpaid + Software Developer Laureate + + + 2,843 + 5-2005 + A2 + Overpaid + Assassin of Doom + + + 2,844 + 3-2011 + B1 + Fairly Paid + Vigilante Laureate + + + 2,845 + 3-1991 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 2,846 + 11-2002 + A2 + Overpaid + Food Taster in Chief + + + 2,847 + 6-2005 + B1 + Fairly Paid + Food Taster Trainer + + + 2,848 + 7-2005 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 2,849 + 4-2002 + B2 + Underpaid + Philosopher of Doom + + + 2,850 + 4-2017 + A2 + Overpaid + Historian for the Environment + + + 2,851 + 3-2018 + C1 + Massively Underpaid + Philosopher for the Environment + + + 2,852 + 7-2001 + C2 + Slave Labour + Philosopher of Cattle + + + 2,853 + 2-1994 + A2 + Overpaid + Assassin Extraordinaire + + + 2,854 + 11-2004 + B1 + Fairly Paid + Vigilante Trainer + + + 2,855 + 11-2014 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 2,856 + 11-1998 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 2,857 + 6-1994 + B2 + Underpaid + Assassin (Trainee) + + + 2,858 + 8-2004 + C1 + Massively Underpaid + Software Developer for Eternity + + + 2,859 + 12-2012 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 2,860 + 5-2019 + B2 + Underpaid + Vigilante of Cattle + + + 2,861 + 3-2009 + A1 + Massively Overpaid + Software Developer of Cattle + + + 2,862 + 5-1993 + B1 + Fairly Paid + Software Developer of Cattle + + + 2,863 + 12-2000 + A2 + Overpaid + Assassin of Cattle + + + 2,864 + 8-2006 + A1 + Massively Overpaid + Builder (Trainee) + + + 2,865 + 8-1994 + B1 + Fairly Paid + Author for Schools + + + 2,866 + 10-2015 + C2 + Slave Labour + Vigilante Extraordinaire + + + 2,867 + 4-2018 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 2,868 + 4-2011 + B1 + Fairly Paid + Philosopher (Trainee) + + + 2,869 + 6-1996 + C2 + Slave Labour + Vigilante (Trainee) + + + 2,870 + 7-2020 + A2 + Overpaid + Vigilante in Chief + + + 2,871 + 2-2018 + B1 + Fairly Paid + Author for the Environment + + + 2,872 + 12-2011 + B1 + Fairly Paid + Author Laureate + + + 2,873 + 5-1996 + A1 + Massively Overpaid + Builder of Parties + + + 2,874 + 7-2013 + A2 + Overpaid + Philosopher for the Environment + + + 2,875 + 12-2002 + A2 + Overpaid + Assassin (Trainee) + + + 2,876 + 8-2000 + A2 + Overpaid + Vigilante of Doom + + + 2,877 + 2-1992 + A2 + Overpaid + Philosopher in Chief + + + 2,878 + 9-1992 + B1 + Fairly Paid + Assassin for Schools + + + 2,879 + 4-2018 + A2 + Overpaid + Philosopher of Cattle + + + 2,880 + 6-2016 + A2 + Overpaid + Historian Trainer + + + 2,881 + 9-1990 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 2,882 + 12-2009 + A1 + Massively Overpaid + Philosopher of Parties + + + 2,883 + 6-2011 + B2 + Underpaid + Vigilante Laureate + + + 2,884 + 7-2018 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 2,885 + 4-2009 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 2,886 + 10-2002 + B1 + Fairly Paid + Software Developer of Parties + + + 2,887 + 10-2003 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 2,888 + 9-1993 + B1 + Fairly Paid + Assassin in Chief + + + 2,889 + 7-2016 + A1 + Massively Overpaid + Software Developer for Eternity + + + 2,890 + 2-2004 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 2,891 + 7-2010 + B1 + Fairly Paid + Food Taster of Cattle + + + 2,892 + 3-2000 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 2,893 + 10-1995 + B2 + Underpaid + Assassin (Trainee) + + + 2,894 + 10-2013 + B2 + Underpaid + Sports Mascot (Trainee) + + + 2,895 + 4-2008 + B2 + Underpaid + Assassin for Schools + + + 2,896 + 2-1991 + B1 + Fairly Paid + Sports Mascot Trainer + + + 2,897 + 6-2008 + A2 + Overpaid + Sports Mascot of Doom + + + 2,898 + 5-1992 + A1 + Massively Overpaid + Food Taster in Chief + + + 2,899 + 11-2010 + B1 + Fairly Paid + Sports Mascot of Doom + + + 2,900 + 8-2003 + B2 + Underpaid + Vigilante for Eternity + + + 2,901 + 12-2011 + C1 + Massively Underpaid + Food Taster in Chief + + + 2,902 + 4-1993 + A1 + Massively Overpaid + Food Taster Trainer + + + 2,903 + 8-2018 + C2 + Slave Labour + Food Taster for Eternity + + + 2,904 + 5-1992 + B1 + Fairly Paid + Sports Mascot of Parties + + + 2,905 + 4-2019 + A2 + Overpaid + Vigilante Extraordinaire + + + 2,906 + 12-2020 + C1 + Massively Underpaid + Author (Trainee) + + + 2,907 + 8-1997 + C1 + Massively Underpaid + Philosopher for Schools + + + 2,908 + 12-1991 + B2 + Underpaid + Vigilante for Eternity + + + 2,909 + 2-1998 + C2 + Slave Labour + Sports Mascot of Cattle + + + 2,910 + 7-2002 + B1 + Fairly Paid + Assassin Extraordinaire + + + 2,911 + 11-2007 + B1 + Fairly Paid + Builder Laureate + + + 2,912 + 12-2017 + B2 + Underpaid + Philosopher of Cattle + + + 2,913 + 8-1999 + B2 + Underpaid + Vigilante for Schools + + + 2,914 + 10-2022 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 2,915 + 3-1999 + C2 + Slave Labour + Sports Mascot Trainer + + + 2,916 + 11-1995 + A2 + Overpaid + Author for Schools + + + 2,917 + 8-2014 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 2,918 + 2-1990 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 2,919 + 7-2021 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 2,920 + 8-1991 + A1 + Massively Overpaid + Historian Trainer + + + 2,921 + 9-2003 + A1 + Massively Overpaid + Author in Chief + + + 2,922 + 1-2001 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 2,923 + 4-2010 + C2 + Slave Labour + Philosopher Extraordinaire + + + 2,924 + 11-2014 + A1 + Massively Overpaid + Vigilante of Cattle + + + 2,925 + 4-2010 + B1 + Fairly Paid + Software Developer Trainer + + + 2,926 + 6-2013 + C1 + Massively Underpaid + Assassin of Doom + + + 2,927 + 8-2022 + C1 + Massively Underpaid + Philosopher for the Environment + + + 2,928 + 9-2022 + C2 + Slave Labour + Philosopher in Chief + + + 2,929 + 3-2005 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 2,930 + 9-1998 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 2,931 + 10-1995 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 2,932 + 11-2022 + B1 + Fairly Paid + Builder of Parties + + + 2,933 + 12-2000 + A1 + Massively Overpaid + Food Taster for the Environment + + + 2,934 + 8-2018 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 2,935 + 9-2023 + A1 + Massively Overpaid + Author for the Environment + + + 2,936 + 10-2006 + A1 + Massively Overpaid + Builder (Trainee) + + + 2,937 + 5-2011 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 2,938 + 10-1991 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 2,939 + 4-2023 + C2 + Slave Labour + Vigilante for Eternity + + + 2,940 + 5-2015 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 2,941 + 12-2016 + A2 + Overpaid + Author of Cattle + + + 2,942 + 5-1991 + B1 + Fairly Paid + Philosopher Trainer + + + 2,943 + 3-2009 + C1 + Massively Underpaid + Software Developer of Cattle + + + 2,944 + 8-1996 + A2 + Overpaid + Skydiving Instructor for Schools + + + 2,945 + 5-1993 + B1 + Fairly Paid + Food Taster of Doom + + + 2,946 + 4-2019 + B2 + Underpaid + Builder of Parties + + + 2,947 + 1-2015 + A2 + Overpaid + Vigilante of Parties + + + 2,948 + 1-1990 + B2 + Underpaid + Software Developer in Chief + + + 2,949 + 11-2020 + A1 + Massively Overpaid + Food Taster for Eternity + + + 2,950 + 7-2017 + B2 + Underpaid + Vigilante for Eternity + + + 2,951 + 2-2022 + A2 + Overpaid + Food Taster Extraordinaire + + + 2,952 + 2-1992 + B1 + Fairly Paid + Historian (Trainee) + + + 2,953 + 1-2005 + B2 + Underpaid + Builder Trainer + + + 2,954 + 12-1999 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 2,955 + 5-1992 + C2 + Slave Labour + Author of Doom + + + 2,956 + 1-1995 + B1 + Fairly Paid + Vigilante for the Environment + + + 2,957 + 3-2014 + A1 + Massively Overpaid + Food Taster of Parties + + + 2,958 + 11-2014 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 2,959 + 6-2008 + C1 + Massively Underpaid + Historian Trainer + + + 2,960 + 12-1998 + A2 + Overpaid + Historian for Eternity + + + 2,961 + 10-2014 + B2 + Underpaid + Software Developer Laureate + + + 2,962 + 10-1997 + A2 + Overpaid + Software Developer for Eternity + + + 2,963 + 8-2020 + B1 + Fairly Paid + Author Trainer + + + 2,964 + 1-1990 + C2 + Slave Labour + Author for Eternity + + + 2,965 + 12-2020 + C1 + Massively Underpaid + Software Developer for the Environment + + + 2,966 + 3-1991 + B2 + Underpaid + Author in Chief + + + 2,967 + 11-2013 + B2 + Underpaid + Skydiving Instructor for Schools + + + 2,968 + 6-2017 + B1 + Fairly Paid + Vigilante of Cattle + + + 2,969 + 11-1990 + A2 + Overpaid + Vigilante of Doom + + + 2,970 + 1-2016 + C1 + Massively Underpaid + Philosopher for Schools + + + 2,971 + 5-1999 + C1 + Massively Underpaid + Historian of Cattle + + + 2,972 + 10-1998 + B2 + Underpaid + Builder Extraordinaire + + + 2,973 + 8-2004 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 2,974 + 11-1999 + C2 + Slave Labour + Vigilante of Parties + + + 2,975 + 4-2010 + A2 + Overpaid + Historian Extraordinaire + + + 2,976 + 3-2005 + B2 + Underpaid + Assassin (Trainee) + + + 2,977 + 5-2008 + A2 + Overpaid + Philosopher for the Environment + + + 2,978 + 4-2018 + B2 + Underpaid + Vigilante Extraordinaire + + + 2,979 + 8-2010 + B1 + Fairly Paid + Assassin in Chief + + + 2,980 + 8-2008 + C2 + Slave Labour + Vigilante of Doom + + + 2,981 + 5-2009 + C2 + Slave Labour + Vigilante Laureate + + + 2,982 + 6-2016 + A2 + Overpaid + Sports Mascot (Trainee) + + + 2,983 + 4-2008 + A2 + Overpaid + Vigilante Trainer + + + 2,984 + 8-2009 + B1 + Fairly Paid + Software Developer of Cattle + + + 2,985 + 6-2006 + C2 + Slave Labour + Philosopher Extraordinaire + + + 2,986 + 10-1999 + A2 + Overpaid + Assassin for the Environment + + + 2,987 + 7-2017 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 2,988 + 5-2002 + B2 + Underpaid + Assassin for the Environment + + + 2,989 + 5-1999 + A2 + Overpaid + Author for Eternity + + + 2,990 + 11-2019 + B2 + Underpaid + Assassin for Eternity + + + 2,991 + 3-1990 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 2,992 + 11-2017 + C1 + Massively Underpaid + Historian of Cattle + + + 2,993 + 12-2008 + B1 + Fairly Paid + Food Taster for the Environment + + + 2,994 + 7-2016 + B2 + Underpaid + Vigilante of Cattle + + + 2,995 + 2-2011 + B1 + Fairly Paid + Assassin of Doom + + + 2,996 + 1-2003 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 2,997 + 9-2015 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 2,998 + 6-2015 + C2 + Slave Labour + Assassin Extraordinaire + + + 2,999 + 1-2010 + A1 + Massively Overpaid + Builder for the Environment + + + 3,000 + 6-2020 + B2 + Underpaid + Philosopher (Trainee) + + + 3,001 + 6-2023 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 3,002 + 11-2014 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 3,003 + 7-1990 + C2 + Slave Labour + Philosopher Laureate + + + 3,004 + 6-1993 + A1 + Massively Overpaid + Software Developer for the Environment + + + 3,005 + 10-2020 + C2 + Slave Labour + Author Laureate + + + 3,006 + 10-2005 + C1 + Massively Underpaid + Builder (Trainee) + + + 3,007 + 3-2003 + A2 + Overpaid + Assassin of Doom + + + 3,008 + 12-1998 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 3,009 + 11-1997 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 3,010 + 12-2000 + A2 + Overpaid + Philosopher Trainer + + + 3,011 + 6-2007 + C1 + Massively Underpaid + Software Developer of Doom + + + 3,012 + 12-2011 + A2 + Overpaid + Vigilante of Doom + + + 3,013 + 7-1997 + B2 + Underpaid + Builder (Trainee) + + + 3,014 + 10-2011 + B2 + Underpaid + Software Developer for the Environment + + + 3,015 + 11-2015 + C2 + Slave Labour + Software Developer of Cattle + + + 3,016 + 5-1996 + A2 + Overpaid + Philosopher Extraordinaire + + + 3,017 + 5-2019 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 3,018 + 4-2011 + B2 + Underpaid + Author for the Environment + + + 3,019 + 5-2005 + C1 + Massively Underpaid + Builder Laureate + + + 3,020 + 3-2008 + B1 + Fairly Paid + Software Developer for the Environment + + + 3,021 + 4-1994 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 3,022 + 2-2002 + C1 + Massively Underpaid + Author (Trainee) + + + 3,023 + 12-2018 + C1 + Massively Underpaid + Historian (Trainee) + + + 3,024 + 7-1995 + C1 + Massively Underpaid + Food Taster for the Environment + + + 3,025 + 3-2015 + A2 + Overpaid + Assassin (Trainee) + + + 3,026 + 9-2009 + A1 + Massively Overpaid + Food Taster of Parties + + + 3,027 + 4-2015 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 3,028 + 9-1992 + B1 + Fairly Paid + Historian of Cattle + + + 3,029 + 11-2022 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 3,030 + 4-2007 + B2 + Underpaid + Vigilante Laureate + + + 3,031 + 3-2008 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 3,032 + 1-2019 + A1 + Massively Overpaid + Food Taster of Doom + + + 3,033 + 11-1992 + A2 + Overpaid + Sports Mascot for Schools + + + 3,034 + 10-2021 + C1 + Massively Underpaid + Software Developer of Parties + + + 3,035 + 5-1991 + B2 + Underpaid + Food Taster of Cattle + + + 3,036 + 7-2002 + A2 + Overpaid + Builder of Cattle + + + 3,037 + 10-2003 + C2 + Slave Labour + Software Developer for Schools + + + 3,038 + 9-1998 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 3,039 + 3-2007 + A2 + Overpaid + Vigilante for Eternity + + + 3,040 + 12-2023 + B2 + Underpaid + Food Taster for Schools + + + 3,041 + 8-2008 + A2 + Overpaid + Software Developer (Trainee) + + + 3,042 + 10-2018 + B2 + Underpaid + Software Developer (Trainee) + + + 3,043 + 5-2015 + B2 + Underpaid + Philosopher for Eternity + + + 3,044 + 5-2000 + C2 + Slave Labour + Builder Laureate + + + 3,045 + 11-2020 + B2 + Underpaid + Software Developer for the Environment + + + 3,046 + 11-2021 + C2 + Slave Labour + Historian in Chief + + + 3,047 + 5-2015 + A2 + Overpaid + Vigilante of Parties + + + 3,048 + 9-1999 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 3,049 + 12-2021 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 3,050 + 2-2007 + B1 + Fairly Paid + Author in Chief + + + 3,051 + 9-2009 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 3,052 + 8-1991 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 3,053 + 10-1993 + B1 + Fairly Paid + Philosopher in Chief + + + 3,054 + 6-2022 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 3,055 + 4-2013 + B2 + Underpaid + Assassin for the Environment + + + 3,056 + 12-1993 + A1 + Massively Overpaid + Vigilante Laureate + + + 3,057 + 10-2019 + A2 + Overpaid + Vigilante (Trainee) + + + 3,058 + 8-2006 + C2 + Slave Labour + Software Developer for Eternity + + + 3,059 + 9-2000 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 3,060 + 4-2006 + A2 + Overpaid + Vigilante for the Environment + + + 3,061 + 10-2003 + A2 + Overpaid + Philosopher Trainer + + + 3,062 + 5-2007 + A2 + Overpaid + Author Laureate + + + 3,063 + 9-2007 + C1 + Massively Underpaid + Historian Trainer + + + 3,064 + 9-2008 + A2 + Overpaid + Author for Schools + + + 3,065 + 9-1998 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 3,066 + 12-2013 + C1 + Massively Underpaid + Food Taster for the Environment + + + 3,067 + 8-2019 + C2 + Slave Labour + Historian of Doom + + + 3,068 + 4-2001 + B2 + Underpaid + Sports Mascot (Trainee) + + + 3,069 + 12-2018 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 3,070 + 8-2010 + B1 + Fairly Paid + Food Taster of Doom + + + 3,071 + 3-2020 + B1 + Fairly Paid + Sports Mascot for Schools + + + 3,072 + 2-1996 + B2 + Underpaid + Philosopher for the Environment + + + 3,073 + 11-2012 + A2 + Overpaid + Philosopher of Cattle + + + 3,074 + 2-2019 + B2 + Underpaid + Assassin Laureate + + + 3,075 + 6-1995 + A1 + Massively Overpaid + Food Taster of Cattle + + + 3,076 + 10-2023 + B2 + Underpaid + Historian for Schools + + + 3,077 + 10-2011 + B2 + Underpaid + Vigilante Laureate + + + 3,078 + 2-1995 + A2 + Overpaid + Author of Cattle + + + 3,079 + 4-2008 + A1 + Massively Overpaid + Historian in Chief + + + 3,080 + 2-2015 + A1 + Massively Overpaid + Author for Eternity + + + 3,081 + 10-1998 + A2 + Overpaid + Skydiving Instructor of Parties + + + 3,082 + 11-2009 + C1 + Massively Underpaid + Philosopher of Doom + + + 3,083 + 12-1991 + C2 + Slave Labour + Software Developer of Parties + + + 3,084 + 10-2014 + B2 + Underpaid + Philosopher Trainer + + + 3,085 + 1-2016 + A1 + Massively Overpaid + Food Taster for Eternity + + + 3,086 + 5-1993 + B2 + Underpaid + Skydiving Instructor of Doom + + + 3,087 + 7-2023 + C1 + Massively Underpaid + Author of Parties + + + 3,088 + 5-2015 + B1 + Fairly Paid + Vigilante of Doom + + + 3,089 + 6-2008 + B2 + Underpaid + Assassin Trainer + + + 3,090 + 3-1992 + C1 + Massively Underpaid + Author of Cattle + + + 3,091 + 10-2009 + C1 + Massively Underpaid + Food Taster for the Environment + + + 3,092 + 2-2011 + C2 + Slave Labour + Assassin for Schools + + + 3,093 + 6-2006 + C2 + Slave Labour + Food Taster of Parties + + + 3,094 + 10-2016 + A1 + Massively Overpaid + Author Trainer + + + 3,095 + 11-2019 + B1 + Fairly Paid + Builder for Schools + + + 3,096 + 10-2010 + C2 + Slave Labour + Author in Chief + + + 3,097 + 12-2020 + C2 + Slave Labour + Software Developer for Schools + + + 3,098 + 3-2023 + A1 + Massively Overpaid + Software Developer for Schools + + + 3,099 + 4-2023 + C2 + Slave Labour + Philosopher Trainer + + + 3,100 + 9-1999 + A1 + Massively Overpaid + Vigilante for Schools + + + 3,101 + 12-2003 + A2 + Overpaid + Vigilante Trainer + + + 3,102 + 4-2021 + B1 + Fairly Paid + Vigilante Laureate + + + 3,103 + 5-2022 + A1 + Massively Overpaid + Builder Extraordinaire + + + 3,104 + 3-2008 + B2 + Underpaid + Builder for the Environment + + + 3,105 + 1-2014 + A2 + Overpaid + Philosopher for Schools + + + 3,106 + 6-2023 + A2 + Overpaid + Food Taster Extraordinaire + + + 3,107 + 8-2019 + A1 + Massively Overpaid + Vigilante of Cattle + + + 3,108 + 1-2013 + A2 + Overpaid + Philosopher Trainer + + + 3,109 + 7-2003 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 3,110 + 5-1995 + B2 + Underpaid + Author of Parties + + + 3,111 + 10-2000 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 3,112 + 1-1995 + B2 + Underpaid + Skydiving Instructor for Schools + + + 3,113 + 12-1991 + C2 + Slave Labour + Historian for Schools + + + 3,114 + 8-1995 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 3,115 + 1-2002 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 3,116 + 6-2015 + C2 + Slave Labour + Sports Mascot in Chief + + + 3,117 + 3-2006 + C2 + Slave Labour + Sports Mascot for Eternity + + + 3,118 + 11-1994 + C2 + Slave Labour + Software Developer Extraordinaire + + + 3,119 + 1-2009 + A1 + Massively Overpaid + Software Developer Laureate + + + 3,120 + 7-1997 + A2 + Overpaid + Food Taster for the Environment + + + 3,121 + 10-2006 + B2 + Underpaid + Sports Mascot (Trainee) + + + 3,122 + 4-1995 + A2 + Overpaid + Philosopher Trainer + + + 3,123 + 11-2017 + A2 + Overpaid + Vigilante of Doom + + + 3,124 + 7-2022 + C2 + Slave Labour + Author of Cattle + + + 3,125 + 3-2008 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 3,126 + 6-1995 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 3,127 + 2-2014 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 3,128 + 5-2007 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 3,129 + 1-2014 + A1 + Massively Overpaid + Food Taster Laureate + + + 3,130 + 7-2001 + A1 + Massively Overpaid + Vigilante of Doom + + + 3,131 + 4-1999 + B1 + Fairly Paid + Vigilante in Chief + + + 3,132 + 1-2005 + A1 + Massively Overpaid + Assassin Laureate + + + 3,133 + 6-1990 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 3,134 + 4-2020 + B2 + Underpaid + Philosopher of Parties + + + 3,135 + 9-2019 + A2 + Overpaid + Software Developer in Chief + + + 3,136 + 7-2021 + A2 + Overpaid + Philosopher for Schools + + + 3,137 + 4-1992 + C2 + Slave Labour + Historian for Eternity + + + 3,138 + 4-2020 + B2 + Underpaid + Vigilante of Doom + + + 3,139 + 8-1997 + B2 + Underpaid + Author (Trainee) + + + 3,140 + 5-1991 + A1 + Massively Overpaid + Software Developer Laureate + + + 3,141 + 12-2016 + C2 + Slave Labour + Philosopher for Eternity + + + 3,142 + 10-1994 + B2 + Underpaid + Skydiving Instructor Laureate + + + 3,143 + 10-1991 + B2 + Underpaid + Software Developer of Cattle + + + 3,144 + 8-2001 + C1 + Massively Underpaid + Philosopher Laureate + + + 3,145 + 5-2005 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 3,146 + 10-1992 + A1 + Massively Overpaid + Builder Laureate + + + 3,147 + 1-1997 + C2 + Slave Labour + Author of Doom + + + 3,148 + 9-2006 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 3,149 + 9-2018 + A1 + Massively Overpaid + Food Taster of Parties + + + 3,150 + 6-2000 + C2 + Slave Labour + Builder in Chief + + + 3,151 + 10-2003 + B1 + Fairly Paid + Sports Mascot of Parties + + + 3,152 + 1-2021 + B2 + Underpaid + Builder of Parties + + + 3,153 + 3-2001 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 3,154 + 5-2009 + A1 + Massively Overpaid + Author (Trainee) + + + 3,155 + 8-1995 + C2 + Slave Labour + Assassin for Schools + + + 3,156 + 11-2006 + A2 + Overpaid + Sports Mascot of Cattle + + + 3,157 + 12-2001 + C2 + Slave Labour + Historian of Doom + + + 3,158 + 9-1998 + A1 + Massively Overpaid + Food Taster for Eternity + + + 3,159 + 8-2004 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 3,160 + 2-2005 + C2 + Slave Labour + Software Developer of Cattle + + + 3,161 + 4-2011 + A2 + Overpaid + Assassin Trainer + + + 3,162 + 2-2021 + B2 + Underpaid + Software Developer in Chief + + + 3,163 + 1-2020 + B1 + Fairly Paid + Builder Trainer + + + 3,164 + 10-2011 + A2 + Overpaid + Philosopher for Schools + + + 3,165 + 5-1991 + A1 + Massively Overpaid + Historian for Eternity + + + 3,166 + 12-2019 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 3,167 + 1-2001 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 3,168 + 1-2005 + B2 + Underpaid + Builder Trainer + + + 3,169 + 7-1994 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,170 + 1-1992 + A1 + Massively Overpaid + Builder (Trainee) + + + 3,171 + 2-2018 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 3,172 + 2-2005 + A1 + Massively Overpaid + Vigilante of Cattle + + + 3,173 + 4-2011 + A1 + Massively Overpaid + Historian of Parties + + + 3,174 + 9-2003 + A1 + Massively Overpaid + Assassin (Trainee) + + + 3,175 + 8-1991 + A2 + Overpaid + Historian Extraordinaire + + + 3,176 + 7-2000 + C1 + Massively Underpaid + Historian Trainer + + + 3,177 + 2-2018 + A1 + Massively Overpaid + Assassin for Eternity + + + 3,178 + 6-2019 + A2 + Overpaid + Builder Laureate + + + 3,179 + 4-2007 + B2 + Underpaid + Sports Mascot for Eternity + + + 3,180 + 1-1992 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 3,181 + 11-1998 + C1 + Massively Underpaid + Builder of Parties + + + 3,182 + 8-2012 + A1 + Massively Overpaid + Author of Parties + + + 3,183 + 2-2020 + C1 + Massively Underpaid + Author for Schools + + + 3,184 + 12-1993 + B2 + Underpaid + Builder for the Environment + + + 3,185 + 4-2000 + B1 + Fairly Paid + Assassin for Schools + + + 3,186 + 3-1994 + C2 + Slave Labour + Software Developer for the Environment + + + 3,187 + 4-2020 + A2 + Overpaid + Philosopher in Chief + + + 3,188 + 10-2010 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 3,189 + 3-1998 + B1 + Fairly Paid + Vigilante (Trainee) + + + 3,190 + 4-2008 + B2 + Underpaid + Builder in Chief + + + 3,191 + 11-2021 + B1 + Fairly Paid + Philosopher of Cattle + + + 3,192 + 4-2002 + B2 + Underpaid + Skydiving Instructor of Doom + + + 3,193 + 7-2001 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,194 + 10-2011 + A2 + Overpaid + Author Extraordinaire + + + 3,195 + 11-1996 + A1 + Massively Overpaid + Historian for the Environment + + + 3,196 + 3-2014 + C1 + Massively Underpaid + Author (Trainee) + + + 3,197 + 3-2007 + A2 + Overpaid + Author (Trainee) + + + 3,198 + 4-2004 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,199 + 6-2006 + B2 + Underpaid + Vigilante for Schools + + + 3,200 + 5-2016 + A1 + Massively Overpaid + Author Trainer + + + 3,201 + 11-1997 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 3,202 + 5-2014 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 3,203 + 9-2010 + B2 + Underpaid + Philosopher of Parties + + + 3,204 + 12-2014 + B2 + Underpaid + Skydiving Instructor Laureate + + + 3,205 + 2-2002 + C2 + Slave Labour + Builder of Cattle + + + 3,206 + 8-1996 + C1 + Massively Underpaid + Software Developer of Doom + + + 3,207 + 1-2019 + B1 + Fairly Paid + Historian Trainer + + + 3,208 + 2-2004 + A2 + Overpaid + Software Developer in Chief + + + 3,209 + 2-1997 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 3,210 + 11-2003 + C1 + Massively Underpaid + Food Taster for the Environment + + + 3,211 + 2-2005 + A2 + Overpaid + Philosopher Extraordinaire + + + 3,212 + 7-1994 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 3,213 + 5-1996 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 3,214 + 8-2006 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 3,215 + 10-2014 + B1 + Fairly Paid + Software Developer Laureate + + + 3,216 + 3-2013 + C1 + Massively Underpaid + Food Taster Laureate + + + 3,217 + 10-2008 + B1 + Fairly Paid + Philosopher Trainer + + + 3,218 + 6-1995 + A2 + Overpaid + Sports Mascot for Schools + + + 3,219 + 3-1999 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 3,220 + 4-1991 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 3,221 + 2-2021 + C1 + Massively Underpaid + Author for Schools + + + 3,222 + 1-1992 + B2 + Underpaid + Philosopher for Eternity + + + 3,223 + 6-2005 + A2 + Overpaid + Vigilante Extraordinaire + + + 3,224 + 11-1998 + B2 + Underpaid + Philosopher of Doom + + + 3,225 + 9-2013 + B2 + Underpaid + Philosopher Laureate + + + 3,226 + 8-2004 + A1 + Massively Overpaid + Vigilante Laureate + + + 3,227 + 1-2022 + B2 + Underpaid + Philosopher Trainer + + + 3,228 + 8-2013 + A2 + Overpaid + Historian for Schools + + + 3,229 + 10-2005 + A1 + Massively Overpaid + Philosopher Trainer + + + 3,230 + 5-1999 + A1 + Massively Overpaid + Builder of Parties + + + 3,231 + 1-2018 + C1 + Massively Underpaid + Author for the Environment + + + 3,232 + 8-2004 + B1 + Fairly Paid + Philosopher for the Environment + + + 3,233 + 11-1994 + C2 + Slave Labour + Sports Mascot in Chief + + + 3,234 + 8-1995 + C1 + Massively Underpaid + Vigilante Trainer + + + 3,235 + 10-1994 + A1 + Massively Overpaid + Builder (Trainee) + + + 3,236 + 11-2003 + C1 + Massively Underpaid + Food Taster Laureate + + + 3,237 + 12-2013 + C2 + Slave Labour + Vigilante for Schools + + + 3,238 + 9-2000 + B1 + Fairly Paid + Author for Eternity + + + 3,239 + 3-1991 + B1 + Fairly Paid + Author for Schools + + + 3,240 + 7-1999 + A1 + Massively Overpaid + Builder for Schools + + + 3,241 + 9-2022 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 3,242 + 8-2022 + B2 + Underpaid + Philosopher Trainer + + + 3,243 + 6-2000 + B2 + Underpaid + Food Taster of Cattle + + + 3,244 + 8-2012 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 3,245 + 7-2014 + A2 + Overpaid + Skydiving Instructor of Parties + + + 3,246 + 10-2001 + C2 + Slave Labour + Author for Schools + + + 3,247 + 8-2000 + C2 + Slave Labour + Historian of Cattle + + + 3,248 + 3-2001 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 3,249 + 7-1999 + A1 + Massively Overpaid + Author of Doom + + + 3,250 + 9-2017 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 3,251 + 7-2017 + A1 + Massively Overpaid + Builder of Doom + + + 3,252 + 9-2014 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 3,253 + 7-1996 + A1 + Massively Overpaid + Historian of Parties + + + 3,254 + 6-2006 + B2 + Underpaid + Vigilante for the Environment + + + 3,255 + 1-1995 + C1 + Massively Underpaid + Philosopher of Doom + + + 3,256 + 4-2001 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 3,257 + 11-1992 + A2 + Overpaid + Skydiving Instructor Laureate + + + 3,258 + 4-1990 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 3,259 + 8-2006 + C2 + Slave Labour + Philosopher of Doom + + + 3,260 + 9-2021 + B1 + Fairly Paid + Vigilante of Parties + + + 3,261 + 11-2001 + B1 + Fairly Paid + Builder of Parties + + + 3,262 + 10-2004 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 3,263 + 5-2012 + B2 + Underpaid + Food Taster Trainer + + + 3,264 + 6-1996 + B2 + Underpaid + Software Developer of Parties + + + 3,265 + 8-2015 + A1 + Massively Overpaid + Vigilante for Schools + + + 3,266 + 1-2018 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 3,267 + 5-1990 + B2 + Underpaid + Sports Mascot for the Environment + + + 3,268 + 3-1997 + C2 + Slave Labour + Sports Mascot Laureate + + + 3,269 + 12-2007 + A1 + Massively Overpaid + Philosopher Laureate + + + 3,270 + 11-1996 + B2 + Underpaid + Sports Mascot in Chief + + + 3,271 + 4-2002 + B2 + Underpaid + Sports Mascot for Schools + + + 3,272 + 5-2022 + B1 + Fairly Paid + Software Developer Laureate + + + 3,273 + 12-2017 + A2 + Overpaid + Sports Mascot for the Environment + + + 3,274 + 8-2000 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 3,275 + 4-2011 + C2 + Slave Labour + Builder Laureate + + + 3,276 + 2-2012 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 3,277 + 8-1999 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 3,278 + 7-2000 + A2 + Overpaid + Assassin Laureate + + + 3,279 + 10-2022 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 3,280 + 11-2006 + C1 + Massively Underpaid + Vigilante Trainer + + + 3,281 + 2-2009 + B2 + Underpaid + Sports Mascot Laureate + + + 3,282 + 2-2020 + B1 + Fairly Paid + Builder of Parties + + + 3,283 + 2-1997 + B2 + Underpaid + Builder for the Environment + + + 3,284 + 1-2011 + C2 + Slave Labour + Software Developer of Cattle + + + 3,285 + 8-2000 + C2 + Slave Labour + Assassin of Doom + + + 3,286 + 3-2001 + A2 + Overpaid + Sports Mascot for Eternity + + + 3,287 + 12-2021 + B2 + Underpaid + Vigilante of Doom + + + 3,288 + 11-2005 + C1 + Massively Underpaid + Food Taster Laureate + + + 3,289 + 7-2004 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 3,290 + 6-1995 + B2 + Underpaid + Vigilante (Trainee) + + + 3,291 + 7-2005 + C1 + Massively Underpaid + Historian Trainer + + + 3,292 + 2-1995 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 3,293 + 6-2021 + C1 + Massively Underpaid + Author Laureate + + + 3,294 + 7-2021 + A2 + Overpaid + Assassin in Chief + + + 3,295 + 2-1995 + A1 + Massively Overpaid + Assassin (Trainee) + + + 3,296 + 2-2018 + A2 + Overpaid + Philosopher (Trainee) + + + 3,297 + 8-2003 + C2 + Slave Labour + Author for the Environment + + + 3,298 + 1-2019 + C2 + Slave Labour + Author of Doom + + + 3,299 + 1-1997 + B1 + Fairly Paid + Philosopher of Parties + + + 3,300 + 9-2021 + A2 + Overpaid + Historian (Trainee) + + + 3,301 + 11-2022 + C1 + Massively Underpaid + Food Taster Trainer + + + 3,302 + 3-2000 + C2 + Slave Labour + Vigilante of Parties + + + 3,303 + 11-1994 + C1 + Massively Underpaid + Software Developer for the Environment + + + 3,304 + 10-2019 + A2 + Overpaid + Philosopher (Trainee) + + + 3,305 + 2-1990 + A2 + Overpaid + Food Taster Trainer + + + 3,306 + 8-2023 + C2 + Slave Labour + Philosopher of Parties + + + 3,307 + 10-1997 + B1 + Fairly Paid + Author for Schools + + + 3,308 + 3-2008 + B2 + Underpaid + Philosopher Laureate + + + 3,309 + 6-2008 + A1 + Massively Overpaid + Software Developer of Cattle + + + 3,310 + 2-2009 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 3,311 + 3-2006 + A2 + Overpaid + Builder Trainer + + + 3,312 + 8-1992 + B2 + Underpaid + Assassin (Trainee) + + + 3,313 + 12-2015 + A2 + Overpaid + Food Taster in Chief + + + 3,314 + 7-2000 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 3,315 + 4-1992 + A1 + Massively Overpaid + Assassin Laureate + + + 3,316 + 12-2017 + A1 + Massively Overpaid + Assassin of Parties + + + 3,317 + 6-2018 + B1 + Fairly Paid + Assassin of Parties + + + 3,318 + 2-1996 + A2 + Overpaid + Philosopher for the Environment + + + 3,319 + 8-1992 + B2 + Underpaid + Skydiving Instructor Laureate + + + 3,320 + 7-2001 + C1 + Massively Underpaid + Builder for Eternity + + + 3,321 + 1-2007 + A2 + Overpaid + Builder of Doom + + + 3,322 + 9-2022 + B1 + Fairly Paid + Historian Laureate + + + 3,323 + 4-2021 + B2 + Underpaid + Historian of Parties + + + 3,324 + 3-2012 + B2 + Underpaid + Assassin of Parties + + + 3,325 + 11-2022 + B2 + Underpaid + Sports Mascot Laureate + + + 3,326 + 5-2012 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 3,327 + 8-2009 + B2 + Underpaid + Assassin Laureate + + + 3,328 + 9-2010 + C2 + Slave Labour + Software Developer in Chief + + + 3,329 + 6-2001 + C2 + Slave Labour + Builder for Schools + + + 3,330 + 12-1993 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 3,331 + 7-2023 + A2 + Overpaid + Builder for Eternity + + + 3,332 + 7-1999 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 3,333 + 10-2015 + A1 + Massively Overpaid + Software Developer Laureate + + + 3,334 + 4-2021 + A2 + Overpaid + Food Taster of Cattle + + + 3,335 + 12-2020 + A1 + Massively Overpaid + Author for the Environment + + + 3,336 + 10-2011 + A1 + Massively Overpaid + Historian for Schools + + + 3,337 + 3-1991 + B2 + Underpaid + Vigilante of Parties + + + 3,338 + 12-2009 + A1 + Massively Overpaid + Assassin for Eternity + + + 3,339 + 2-2019 + B1 + Fairly Paid + Author Trainer + + + 3,340 + 5-2021 + B2 + Underpaid + Skydiving Instructor Laureate + + + 3,341 + 11-2011 + B2 + Underpaid + Assassin of Doom + + + 3,342 + 7-1991 + C2 + Slave Labour + Software Developer Laureate + + + 3,343 + 7-2022 + A1 + Massively Overpaid + Food Taster of Doom + + + 3,344 + 6-2011 + C1 + Massively Underpaid + Historian Extraordinaire + + + 3,345 + 11-1996 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 3,346 + 5-2019 + B1 + Fairly Paid + Philosopher of Doom + + + 3,347 + 3-1993 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 3,348 + 2-1997 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 3,349 + 6-2000 + B1 + Fairly Paid + Vigilante for the Environment + + + 3,350 + 5-2015 + C1 + Massively Underpaid + Philosopher for Eternity + + + 3,351 + 11-2013 + B2 + Underpaid + Food Taster (Trainee) + + + 3,352 + 9-2005 + C2 + Slave Labour + Philosopher of Doom + + + 3,353 + 3-2004 + A2 + Overpaid + Food Taster for Eternity + + + 3,354 + 2-2019 + C2 + Slave Labour + Software Developer of Parties + + + 3,355 + 4-2021 + A2 + Overpaid + Assassin for Schools + + + 3,356 + 12-2001 + B2 + Underpaid + Philosopher for Eternity + + + 3,357 + 6-1994 + B1 + Fairly Paid + Assassin of Parties + + + 3,358 + 7-1991 + B1 + Fairly Paid + Author for the Environment + + + 3,359 + 7-1991 + C1 + Massively Underpaid + Software Developer for the Environment + + + 3,360 + 6-2002 + C1 + Massively Underpaid + Author Trainer + + + 3,361 + 11-2003 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 3,362 + 10-2005 + B2 + Underpaid + Philosopher of Parties + + + 3,363 + 3-2015 + A2 + Overpaid + Food Taster Extraordinaire + + + 3,364 + 2-1994 + A1 + Massively Overpaid + Food Taster for Schools + + + 3,365 + 11-1994 + A1 + Massively Overpaid + Builder Laureate + + + 3,366 + 2-2022 + B2 + Underpaid + Software Developer of Doom + + + 3,367 + 7-2022 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,368 + 2-1997 + A1 + Massively Overpaid + Historian for Schools + + + 3,369 + 8-2004 + A2 + Overpaid + Food Taster in Chief + + + 3,370 + 7-2020 + A1 + Massively Overpaid + Assassin for Eternity + + + 3,371 + 4-1997 + A1 + Massively Overpaid + Food Taster Laureate + + + 3,372 + 2-1990 + B2 + Underpaid + Philosopher Extraordinaire + + + 3,373 + 1-1997 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 3,374 + 10-2007 + C1 + Massively Underpaid + Software Developer of Parties + + + 3,375 + 7-1997 + A1 + Massively Overpaid + Vigilante of Cattle + + + 3,376 + 12-2001 + C2 + Slave Labour + Philosopher Extraordinaire + + + 3,377 + 3-2005 + B1 + Fairly Paid + Assassin Laureate + + + 3,378 + 3-1997 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 3,379 + 9-2017 + A1 + Massively Overpaid + Historian of Doom + + + 3,380 + 12-1992 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 3,381 + 2-1998 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 3,382 + 7-2021 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 3,383 + 3-1994 + B2 + Underpaid + Philosopher Extraordinaire + + + 3,384 + 8-2013 + C2 + Slave Labour + Builder in Chief + + + 3,385 + 3-2016 + B1 + Fairly Paid + Author Laureate + + + 3,386 + 5-2002 + B1 + Fairly Paid + Vigilante of Doom + + + 3,387 + 6-2017 + C1 + Massively Underpaid + Assassin of Doom + + + 3,388 + 5-2000 + B1 + Fairly Paid + Builder Laureate + + + 3,389 + 7-2017 + B1 + Fairly Paid + Assassin (Trainee) + + + 3,390 + 1-2002 + C1 + Massively Underpaid + Food Taster Laureate + + + 3,391 + 7-2016 + B2 + Underpaid + Skydiving Instructor in Chief + + + 3,392 + 6-2018 + C2 + Slave Labour + Vigilante for Eternity + + + 3,393 + 2-1999 + A1 + Massively Overpaid + Vigilante Laureate + + + 3,394 + 3-2015 + B2 + Underpaid + Sports Mascot for Schools + + + 3,395 + 8-2014 + A2 + Overpaid + Software Developer for Eternity + + + 3,396 + 6-2012 + C1 + Massively Underpaid + Builder for Schools + + + 3,397 + 3-2011 + A2 + Overpaid + Historian Trainer + + + 3,398 + 9-2009 + B2 + Underpaid + Author Trainer + + + 3,399 + 8-2019 + A2 + Overpaid + Historian for the Environment + + + 3,400 + 5-1996 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 3,401 + 9-2016 + B2 + Underpaid + Software Developer Trainer + + + 3,402 + 7-2020 + C2 + Slave Labour + Sports Mascot Laureate + + + 3,403 + 10-1992 + C2 + Slave Labour + Philosopher of Doom + + + 3,404 + 4-2007 + A2 + Overpaid + Assassin of Cattle + + + 3,405 + 2-1994 + B2 + Underpaid + Food Taster Laureate + + + 3,406 + 4-2018 + C1 + Massively Underpaid + Builder for Schools + + + 3,407 + 1-2023 + C2 + Slave Labour + Food Taster for Schools + + + 3,408 + 2-2004 + B2 + Underpaid + Historian (Trainee) + + + 3,409 + 3-2004 + A2 + Overpaid + Assassin for Eternity + + + 3,410 + 7-2019 + C2 + Slave Labour + Assassin in Chief + + + 3,411 + 2-2009 + A1 + Massively Overpaid + Software Developer in Chief + + + 3,412 + 12-1997 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 3,413 + 2-2017 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 3,414 + 9-2007 + B1 + Fairly Paid + Author Trainer + + + 3,415 + 11-2017 + C2 + Slave Labour + Philosopher for Eternity + + + 3,416 + 9-2005 + B1 + Fairly Paid + Sports Mascot Trainer + + + 3,417 + 1-1993 + C2 + Slave Labour + Historian for Schools + + + 3,418 + 6-2010 + A2 + Overpaid + Software Developer Trainer + + + 3,419 + 8-2018 + C2 + Slave Labour + Historian for Eternity + + + 3,420 + 10-2008 + B2 + Underpaid + Skydiving Instructor for Schools + + + 3,421 + 6-2000 + B1 + Fairly Paid + Assassin Laureate + + + 3,422 + 7-2000 + C1 + Massively Underpaid + Vigilante in Chief + + + 3,423 + 9-1996 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 3,424 + 4-1991 + B1 + Fairly Paid + Historian (Trainee) + + + 3,425 + 4-2016 + A2 + Overpaid + Builder Laureate + + + 3,426 + 6-2013 + B2 + Underpaid + Software Developer for Eternity + + + 3,427 + 7-2019 + C1 + Massively Underpaid + Software Developer for the Environment + + + 3,428 + 7-2005 + A1 + Massively Overpaid + Builder of Parties + + + 3,429 + 2-1993 + A2 + Overpaid + Philosopher for Eternity + + + 3,430 + 10-2003 + C2 + Slave Labour + Builder of Doom + + + 3,431 + 4-2004 + C1 + Massively Underpaid + Food Taster in Chief + + + 3,432 + 4-2013 + B1 + Fairly Paid + Historian in Chief + + + 3,433 + 1-2005 + B2 + Underpaid + Builder of Parties + + + 3,434 + 10-1992 + A2 + Overpaid + Historian of Doom + + + 3,435 + 10-1996 + C2 + Slave Labour + Sports Mascot of Cattle + + + 3,436 + 1-1996 + A2 + Overpaid + Author Trainer + + + 3,437 + 11-2018 + A2 + Overpaid + Food Taster in Chief + + + 3,438 + 12-2000 + A1 + Massively Overpaid + Historian for Schools + + + 3,439 + 7-2015 + B2 + Underpaid + Assassin (Trainee) + + + 3,440 + 5-2021 + B2 + Underpaid + Author for the Environment + + + 3,441 + 7-2001 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 3,442 + 3-1997 + B2 + Underpaid + Skydiving Instructor of Parties + + + 3,443 + 8-2020 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 3,444 + 11-2018 + C2 + Slave Labour + Software Developer Laureate + + + 3,445 + 8-2019 + B1 + Fairly Paid + Builder Trainer + + + 3,446 + 2-1999 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 3,447 + 7-2022 + C1 + Massively Underpaid + Software Developer of Doom + + + 3,448 + 7-2015 + B2 + Underpaid + Assassin Laureate + + + 3,449 + 8-1996 + B2 + Underpaid + Software Developer Laureate + + + 3,450 + 8-2005 + C1 + Massively Underpaid + Author for Schools + + + 3,451 + 11-2023 + A1 + Massively Overpaid + Builder Laureate + + + 3,452 + 11-2004 + C2 + Slave Labour + Assassin for the Environment + + + 3,453 + 3-1994 + A2 + Overpaid + Sports Mascot for Eternity + + + 3,454 + 2-2020 + B2 + Underpaid + Author for Schools + + + 3,455 + 12-2014 + C2 + Slave Labour + Food Taster for the Environment + + + 3,456 + 9-2023 + C1 + Massively Underpaid + Software Developer for the Environment + + + 3,457 + 12-2003 + A2 + Overpaid + Historian Extraordinaire + + + 3,458 + 12-2001 + A2 + Overpaid + Historian Extraordinaire + + + 3,459 + 12-2017 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 3,460 + 10-1993 + A1 + Massively Overpaid + Food Taster of Doom + + + 3,461 + 7-2002 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 3,462 + 3-2003 + C2 + Slave Labour + Assassin of Cattle + + + 3,463 + 5-2011 + A2 + Overpaid + Vigilante of Doom + + + 3,464 + 7-1994 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 3,465 + 8-2004 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 3,466 + 5-2000 + A2 + Overpaid + Builder for the Environment + + + 3,467 + 12-2004 + B2 + Underpaid + Assassin Trainer + + + 3,468 + 4-2008 + A2 + Overpaid + Assassin of Parties + + + 3,469 + 1-2010 + C1 + Massively Underpaid + Food Taster of Parties + + + 3,470 + 11-2021 + B1 + Fairly Paid + Assassin Extraordinaire + + + 3,471 + 8-1992 + A2 + Overpaid + Sports Mascot Laureate + + + 3,472 + 1-2012 + A2 + Overpaid + Skydiving Instructor for Schools + + + 3,473 + 12-2005 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 3,474 + 4-2009 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 3,475 + 9-2008 + A2 + Overpaid + Author Laureate + + + 3,476 + 1-2014 + B2 + Underpaid + Vigilante of Parties + + + 3,477 + 11-2021 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 3,478 + 6-2012 + A1 + Massively Overpaid + Historian Trainer + + + 3,479 + 11-2016 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 3,480 + 3-1998 + A1 + Massively Overpaid + Philosopher of Cattle + + + 3,481 + 12-2001 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 3,482 + 1-1997 + A2 + Overpaid + Historian Laureate + + + 3,483 + 3-2023 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 3,484 + 8-1994 + A1 + Massively Overpaid + Historian of Parties + + + 3,485 + 1-2023 + A1 + Massively Overpaid + Philosopher of Doom + + + 3,486 + 9-2008 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 3,487 + 7-1992 + B1 + Fairly Paid + Historian for Eternity + + + 3,488 + 12-1995 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 3,489 + 10-2000 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 3,490 + 1-2009 + B2 + Underpaid + Sports Mascot of Doom + + + 3,491 + 6-2013 + B2 + Underpaid + Assassin Extraordinaire + + + 3,492 + 9-1998 + C1 + Massively Underpaid + Author Laureate + + + 3,493 + 4-2023 + B2 + Underpaid + Philosopher for the Environment + + + 3,494 + 7-2010 + A1 + Massively Overpaid + Assassin of Doom + + + 3,495 + 5-2018 + A1 + Massively Overpaid + Software Developer Trainer + + + 3,496 + 9-2006 + C1 + Massively Underpaid + Builder in Chief + + + 3,497 + 2-2022 + A1 + Massively Overpaid + Food Taster Laureate + + + 3,498 + 11-1992 + A2 + Overpaid + Author of Parties + + + 3,499 + 2-2012 + A2 + Overpaid + Food Taster for the Environment + + + 3,500 + 8-2021 + A2 + Overpaid + Vigilante of Cattle + + + 3,501 + 6-1992 + A2 + Overpaid + Vigilante of Cattle + + + 3,502 + 11-2004 + A2 + Overpaid + Philosopher Laureate + + + 3,503 + 4-2012 + B1 + Fairly Paid + Historian in Chief + + + 3,504 + 9-2019 + C1 + Massively Underpaid + Historian Extraordinaire + + + 3,505 + 1-2021 + A1 + Massively Overpaid + Author of Parties + + + 3,506 + 6-2022 + A2 + Overpaid + Historian (Trainee) + + + 3,507 + 1-1994 + B1 + Fairly Paid + Skydiving Instructor (Trainee) + + + 3,508 + 11-2006 + A2 + Overpaid + Vigilante Laureate + + + 3,509 + 7-2001 + A1 + Massively Overpaid + Assassin in Chief + + + 3,510 + 5-2009 + A2 + Overpaid + Philosopher (Trainee) + + + 3,511 + 8-1998 + B1 + Fairly Paid + Vigilante of Parties + + + 3,512 + 4-2010 + A2 + Overpaid + Software Developer (Trainee) + + + 3,513 + 6-1995 + B1 + Fairly Paid + Assassin of Cattle + + + 3,514 + 7-2021 + A2 + Overpaid + Assassin Extraordinaire + + + 3,515 + 3-2001 + A1 + Massively Overpaid + Builder Laureate + + + 3,516 + 10-1998 + B1 + Fairly Paid + Assassin in Chief + + + 3,517 + 6-2007 + A1 + Massively Overpaid + Assassin of Cattle + + + 3,518 + 4-2003 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 3,519 + 8-2012 + A1 + Massively Overpaid + Software Developer in Chief + + + 3,520 + 6-1992 + C1 + Massively Underpaid + Historian in Chief + + + 3,521 + 7-2004 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 3,522 + 5-1996 + A1 + Massively Overpaid + Builder for the Environment + + + 3,523 + 6-1994 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 3,524 + 5-2003 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 3,525 + 8-1993 + A1 + Massively Overpaid + Food Taster of Parties + + + 3,526 + 3-2005 + C1 + Massively Underpaid + Vigilante for the Environment + + + 3,527 + 7-1999 + C1 + Massively Underpaid + Software Developer of Doom + + + 3,528 + 11-2007 + A2 + Overpaid + Sports Mascot Laureate + + + 3,529 + 12-2013 + A1 + Massively Overpaid + Vigilante Trainer + + + 3,530 + 10-1993 + B2 + Underpaid + Software Developer for the Environment + + + 3,531 + 10-1997 + B2 + Underpaid + Food Taster for the Environment + + + 3,532 + 3-1995 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 3,533 + 7-2008 + C2 + Slave Labour + Historian Laureate + + + 3,534 + 6-2011 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 3,535 + 7-2006 + B1 + Fairly Paid + Historian (Trainee) + + + 3,536 + 9-2008 + B2 + Underpaid + Sports Mascot for the Environment + + + 3,537 + 6-1993 + A2 + Overpaid + Philosopher Laureate + + + 3,538 + 12-2014 + A1 + Massively Overpaid + Food Taster Laureate + + + 3,539 + 2-2013 + C2 + Slave Labour + Author for Eternity + + + 3,540 + 7-2005 + B2 + Underpaid + Sports Mascot for the Environment + + + 3,541 + 3-2023 + C2 + Slave Labour + Philosopher of Doom + + + 3,542 + 3-2005 + B1 + Fairly Paid + Philosopher in Chief + + + 3,543 + 8-1996 + B2 + Underpaid + Food Taster Trainer + + + 3,544 + 11-2001 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 3,545 + 9-1997 + B2 + Underpaid + Philosopher (Trainee) + + + 3,546 + 3-2013 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 3,547 + 7-2016 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 3,548 + 3-2013 + C2 + Slave Labour + Assassin in Chief + + + 3,549 + 12-1991 + C1 + Massively Underpaid + Author in Chief + + + 3,550 + 12-2017 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 3,551 + 7-2006 + B2 + Underpaid + Sports Mascot in Chief + + + 3,552 + 6-1999 + C2 + Slave Labour + Philosopher of Doom + + + 3,553 + 2-2002 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 3,554 + 11-1998 + B1 + Fairly Paid + Philosopher in Chief + + + 3,555 + 12-2013 + C1 + Massively Underpaid + Assassin Trainer + + + 3,556 + 2-1990 + A2 + Overpaid + Skydiving Instructor Trainer + + + 3,557 + 6-2022 + A2 + Overpaid + Historian for the Environment + + + 3,558 + 9-2022 + C1 + Massively Underpaid + Food Taster of Cattle + + + 3,559 + 3-2004 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,560 + 8-1991 + C2 + Slave Labour + Historian of Parties + + + 3,561 + 6-2002 + C1 + Massively Underpaid + Assassin (Trainee) + + + 3,562 + 4-1999 + B2 + Underpaid + Skydiving Instructor for Schools + + + 3,563 + 12-1997 + A1 + Massively Overpaid + Builder in Chief + + + 3,564 + 12-2003 + C2 + Slave Labour + Author of Parties + + + 3,565 + 7-2003 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 3,566 + 3-2012 + C1 + Massively Underpaid + Philosopher of Parties + + + 3,567 + 5-2023 + A2 + Overpaid + Vigilante for the Environment + + + 3,568 + 11-1991 + A1 + Massively Overpaid + Assassin Trainer + + + 3,569 + 12-1993 + A1 + Massively Overpaid + Vigilante of Doom + + + 3,570 + 4-1995 + C1 + Massively Underpaid + Author for the Environment + + + 3,571 + 9-1998 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 3,572 + 10-2016 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 3,573 + 1-2000 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 3,574 + 9-2011 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 3,575 + 9-2014 + B1 + Fairly Paid + Software Developer Trainer + + + 3,576 + 3-2015 + B1 + Fairly Paid + Assassin of Cattle + + + 3,577 + 10-2023 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 3,578 + 12-2001 + B1 + Fairly Paid + Builder Trainer + + + 3,579 + 5-2019 + C2 + Slave Labour + Food Taster in Chief + + + 3,580 + 4-2019 + C2 + Slave Labour + Sports Mascot for the Environment + + + 3,581 + 1-2006 + B2 + Underpaid + Author of Cattle + + + 3,582 + 7-2003 + B1 + Fairly Paid + Software Developer Trainer + + + 3,583 + 2-2000 + A2 + Overpaid + Skydiving Instructor Laureate + + + 3,584 + 1-2014 + A1 + Massively Overpaid + Historian for the Environment + + + 3,585 + 12-2014 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 3,586 + 4-1990 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 3,587 + 7-2003 + B2 + Underpaid + Software Developer Extraordinaire + + + 3,588 + 9-1994 + B1 + Fairly Paid + Assassin for Schools + + + 3,589 + 8-2016 + A2 + Overpaid + Assassin for Schools + + + 3,590 + 12-2007 + A2 + Overpaid + Sports Mascot Extraordinaire + + + 3,591 + 9-2023 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 3,592 + 4-2006 + A1 + Massively Overpaid + Philosopher of Cattle + + + 3,593 + 11-2010 + A2 + Overpaid + Historian for Schools + + + 3,594 + 6-2012 + B2 + Underpaid + Vigilante Trainer + + + 3,595 + 10-1997 + A2 + Overpaid + Skydiving Instructor Trainer + + + 3,596 + 10-2017 + B1 + Fairly Paid + Sports Mascot in Chief + + + 3,597 + 4-1996 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 3,598 + 7-2001 + C1 + Massively Underpaid + Vigilante of Doom + + + 3,599 + 2-2009 + A2 + Overpaid + Assassin Laureate + + + 3,600 + 8-1995 + C1 + Massively Underpaid + Historian (Trainee) + + + 3,601 + 6-2020 + B2 + Underpaid + Vigilante Trainer + + + 3,602 + 4-2002 + B2 + Underpaid + Sports Mascot of Parties + + + 3,603 + 1-2007 + C2 + Slave Labour + Food Taster of Parties + + + 3,604 + 1-2019 + A2 + Overpaid + Sports Mascot for Eternity + + + 3,605 + 7-1998 + A2 + Overpaid + Sports Mascot Trainer + + + 3,606 + 6-2023 + A2 + Overpaid + Builder for Schools + + + 3,607 + 6-2023 + C1 + Massively Underpaid + Software Developer Trainer + + + 3,608 + 1-2002 + C1 + Massively Underpaid + Assassin Laureate + + + 3,609 + 12-2016 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 3,610 + 8-2005 + C2 + Slave Labour + Food Taster for the Environment + + + 3,611 + 11-2006 + B2 + Underpaid + Assassin Extraordinaire + + + 3,612 + 9-2002 + B1 + Fairly Paid + Food Taster in Chief + + + 3,613 + 7-1996 + A2 + Overpaid + Philosopher in Chief + + + 3,614 + 3-1994 + B2 + Underpaid + Software Developer for Eternity + + + 3,615 + 2-2018 + B2 + Underpaid + Builder (Trainee) + + + 3,616 + 11-2018 + B2 + Underpaid + Food Taster in Chief + + + 3,617 + 8-2008 + C2 + Slave Labour + Philosopher for the Environment + + + 3,618 + 4-2008 + B2 + Underpaid + Software Developer for the Environment + + + 3,619 + 8-1993 + B2 + Underpaid + Sports Mascot for the Environment + + + 3,620 + 8-1998 + B2 + Underpaid + Builder (Trainee) + + + 3,621 + 4-2021 + B2 + Underpaid + Skydiving Instructor Laureate + + + 3,622 + 12-1993 + B1 + Fairly Paid + Food Taster of Parties + + + 3,623 + 2-2012 + B1 + Fairly Paid + Sports Mascot Laureate + + + 3,624 + 9-2023 + C2 + Slave Labour + Sports Mascot in Chief + + + 3,625 + 3-2021 + B2 + Underpaid + Assassin in Chief + + + 3,626 + 12-1995 + C2 + Slave Labour + Philosopher for Schools + + + 3,627 + 6-2015 + A2 + Overpaid + Skydiving Instructor for Schools + + + 3,628 + 3-2012 + B2 + Underpaid + Vigilante for Schools + + + 3,629 + 5-2007 + C2 + Slave Labour + Food Taster of Doom + + + 3,630 + 4-2021 + B2 + Underpaid + Vigilante in Chief + + + 3,631 + 11-2006 + B2 + Underpaid + Sports Mascot of Doom + + + 3,632 + 11-2018 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 3,633 + 9-1995 + A2 + Overpaid + Vigilante for Eternity + + + 3,634 + 1-2015 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 3,635 + 12-2005 + B1 + Fairly Paid + Historian Trainer + + + 3,636 + 10-2007 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 3,637 + 10-2016 + B2 + Underpaid + Philosopher of Cattle + + + 3,638 + 11-2021 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 3,639 + 11-2000 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 3,640 + 9-2009 + B2 + Underpaid + Author (Trainee) + + + 3,641 + 12-2012 + C1 + Massively Underpaid + Author of Parties + + + 3,642 + 5-2020 + C2 + Slave Labour + Vigilante of Parties + + + 3,643 + 2-2012 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 3,644 + 11-1990 + B2 + Underpaid + Author for the Environment + + + 3,645 + 7-2005 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 3,646 + 12-2009 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 3,647 + 8-1994 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 3,648 + 11-2015 + C2 + Slave Labour + Sports Mascot for Eternity + + + 3,649 + 10-2019 + B2 + Underpaid + Author Trainer + + + 3,650 + 11-1996 + C1 + Massively Underpaid + Author Extraordinaire + + + 3,651 + 10-2002 + B2 + Underpaid + Vigilante for Schools + + + 3,652 + 5-2009 + C1 + Massively Underpaid + Assassin Trainer + + + 3,653 + 7-2010 + C1 + Massively Underpaid + Philosopher in Chief + + + 3,654 + 1-2009 + B1 + Fairly Paid + Author for Eternity + + + 3,655 + 8-2018 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 3,656 + 4-2002 + C2 + Slave Labour + Vigilante for the Environment + + + 3,657 + 7-2007 + A2 + Overpaid + Builder (Trainee) + + + 3,658 + 6-2000 + B2 + Underpaid + Builder of Doom + + + 3,659 + 4-2000 + C1 + Massively Underpaid + Food Taster of Cattle + + + 3,660 + 10-2012 + A1 + Massively Overpaid + Builder of Doom + + + 3,661 + 10-1992 + B2 + Underpaid + Software Developer Laureate + + + 3,662 + 8-2019 + C1 + Massively Underpaid + Historian (Trainee) + + + 3,663 + 3-2006 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 3,664 + 1-2018 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 3,665 + 8-2009 + B1 + Fairly Paid + Builder for the Environment + + + 3,666 + 10-2002 + A1 + Massively Overpaid + Author of Cattle + + + 3,667 + 3-1998 + B2 + Underpaid + Skydiving Instructor of Doom + + + 3,668 + 7-1993 + B1 + Fairly Paid + Historian of Doom + + + 3,669 + 3-1991 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 3,670 + 2-2010 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 3,671 + 12-2001 + A1 + Massively Overpaid + Software Developer Laureate + + + 3,672 + 6-2004 + C2 + Slave Labour + Author for Eternity + + + 3,673 + 12-2017 + A1 + Massively Overpaid + Philosopher of Doom + + + 3,674 + 12-2007 + B2 + Underpaid + Author of Doom + + + 3,675 + 9-1993 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 3,676 + 4-2012 + B2 + Underpaid + Software Developer Extraordinaire + + + 3,677 + 1-1998 + A2 + Overpaid + Skydiving Instructor for Schools + + + 3,678 + 12-1996 + C1 + Massively Underpaid + Author of Parties + + + 3,679 + 11-1998 + A2 + Overpaid + Vigilante for the Environment + + + 3,680 + 5-2012 + C2 + Slave Labour + Builder (Trainee) + + + 3,681 + 7-2018 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 3,682 + 1-2017 + C1 + Massively Underpaid + Philosopher for Schools + + + 3,683 + 11-2000 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 3,684 + 2-2014 + A2 + Overpaid + Philosopher of Doom + + + 3,685 + 5-2018 + A2 + Overpaid + Sports Mascot of Parties + + + 3,686 + 12-1992 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 3,687 + 9-2006 + A2 + Overpaid + Vigilante (Trainee) + + + 3,688 + 12-2020 + B2 + Underpaid + Vigilante (Trainee) + + + 3,689 + 10-2009 + A2 + Overpaid + Historian of Parties + + + 3,690 + 11-2023 + A1 + Massively Overpaid + Author of Doom + + + 3,691 + 5-1997 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 3,692 + 7-2018 + C2 + Slave Labour + Software Developer of Doom + + + 3,693 + 6-2010 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 3,694 + 11-2018 + B2 + Underpaid + Assassin (Trainee) + + + 3,695 + 1-2021 + C1 + Massively Underpaid + Vigilante Laureate + + + 3,696 + 9-2017 + A2 + Overpaid + Historian (Trainee) + + + 3,697 + 12-1998 + A2 + Overpaid + Sports Mascot of Parties + + + 3,698 + 5-2022 + C2 + Slave Labour + Philosopher in Chief + + + 3,699 + 2-2005 + B2 + Underpaid + Assassin for Eternity + + + 3,700 + 10-2004 + C2 + Slave Labour + Software Developer Laureate + + + 3,701 + 12-2003 + B2 + Underpaid + Assassin for Eternity + + + 3,702 + 1-2011 + B1 + Fairly Paid + Software Developer Laureate + + + 3,703 + 9-2013 + A1 + Massively Overpaid + Philosopher of Doom + + + 3,704 + 12-2013 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 3,705 + 7-2020 + B2 + Underpaid + Sports Mascot Laureate + + + 3,706 + 5-1993 + A2 + Overpaid + Philosopher of Cattle + + + 3,707 + 9-2006 + A2 + Overpaid + Assassin of Parties + + + 3,708 + 9-2008 + C1 + Massively Underpaid + Author Extraordinaire + + + 3,709 + 9-2007 + B1 + Fairly Paid + Food Taster for the Environment + + + 3,710 + 9-2013 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 3,711 + 11-2022 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 3,712 + 6-2018 + C2 + Slave Labour + Assassin for Schools + + + 3,713 + 10-2007 + A2 + Overpaid + Sports Mascot for Schools + + + 3,714 + 5-2011 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 3,715 + 5-2018 + C1 + Massively Underpaid + Historian Trainer + + + 3,716 + 9-2011 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 3,717 + 6-1991 + B2 + Underpaid + Author for the Environment + + + 3,718 + 8-2010 + A1 + Massively Overpaid + Vigilante of Cattle + + + 3,719 + 8-1996 + A1 + Massively Overpaid + Food Taster in Chief + + + 3,720 + 2-2002 + C1 + Massively Underpaid + Vigilante of Cattle + + + 3,721 + 8-2022 + A2 + Overpaid + Historian for Schools + + + 3,722 + 12-2023 + C1 + Massively Underpaid + Author for Eternity + + + 3,723 + 1-1991 + B2 + Underpaid + Vigilante of Cattle + + + 3,724 + 10-2015 + C2 + Slave Labour + Sports Mascot Trainer + + + 3,725 + 6-2008 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 3,726 + 7-2002 + A2 + Overpaid + Philosopher Laureate + + + 3,727 + 11-2009 + A2 + Overpaid + Food Taster Trainer + + + 3,728 + 12-2015 + B1 + Fairly Paid + Philosopher for Schools + + + 3,729 + 12-2020 + B1 + Fairly Paid + Software Developer for Eternity + + + 3,730 + 12-2005 + A1 + Massively Overpaid + Software Developer of Doom + + + 3,731 + 12-2007 + A2 + Overpaid + Vigilante for Schools + + + 3,732 + 9-2007 + C1 + Massively Underpaid + Historian of Cattle + + + 3,733 + 6-2016 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 3,734 + 4-1991 + C2 + Slave Labour + Builder for the Environment + + + 3,735 + 2-2003 + B2 + Underpaid + Author Trainer + + + 3,736 + 4-2008 + C1 + Massively Underpaid + Vigilante Trainer + + + 3,737 + 2-1996 + B2 + Underpaid + Author of Parties + + + 3,738 + 7-2010 + B2 + Underpaid + Food Taster Extraordinaire + + + 3,739 + 6-1997 + C2 + Slave Labour + Builder (Trainee) + + + 3,740 + 7-2021 + C1 + Massively Underpaid + Builder for Schools + + + 3,741 + 7-2008 + B1 + Fairly Paid + Author Laureate + + + 3,742 + 6-2002 + A2 + Overpaid + Sports Mascot in Chief + + + 3,743 + 12-2018 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 3,744 + 2-2003 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 3,745 + 4-1991 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 3,746 + 4-1992 + B1 + Fairly Paid + Builder (Trainee) + + + 3,747 + 8-1998 + A1 + Massively Overpaid + Author Trainer + + + 3,748 + 12-2011 + B1 + Fairly Paid + Food Taster Laureate + + + 3,749 + 6-1997 + C2 + Slave Labour + Historian Laureate + + + 3,750 + 2-2004 + A1 + Massively Overpaid + Software Developer Laureate + + + 3,751 + 5-2017 + C2 + Slave Labour + Vigilante in Chief + + + 3,752 + 1-2017 + C1 + Massively Underpaid + Historian in Chief + + + 3,753 + 6-2016 + C2 + Slave Labour + Food Taster (Trainee) + + + 3,754 + 5-2006 + A2 + Overpaid + Sports Mascot for Eternity + + + 3,755 + 8-1993 + A2 + Overpaid + Skydiving Instructor in Chief + + + 3,756 + 7-2015 + A1 + Massively Overpaid + Assassin for the Environment + + + 3,757 + 3-1994 + B2 + Underpaid + Historian for the Environment + + + 3,758 + 2-1990 + A2 + Overpaid + Author of Doom + + + 3,759 + 9-2022 + B1 + Fairly Paid + Philosopher of Doom + + + 3,760 + 9-2021 + B2 + Underpaid + Builder of Doom + + + 3,761 + 12-2002 + B1 + Fairly Paid + Builder Laureate + + + 3,762 + 8-2015 + C2 + Slave Labour + Vigilante Extraordinaire + + + 3,763 + 2-2023 + B2 + Underpaid + Philosopher Extraordinaire + + + 3,764 + 6-1995 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 3,765 + 1-2000 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 3,766 + 1-2011 + C2 + Slave Labour + Vigilante for Eternity + + + 3,767 + 8-2011 + A2 + Overpaid + Skydiving Instructor of Parties + + + 3,768 + 10-1991 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,769 + 7-1992 + B2 + Underpaid + Vigilante Extraordinaire + + + 3,770 + 12-2001 + A2 + Overpaid + Skydiving Instructor of Parties + + + 3,771 + 4-2011 + B2 + Underpaid + Author of Parties + + + 3,772 + 12-2000 + A2 + Overpaid + Food Taster (Trainee) + + + 3,773 + 3-2018 + A1 + Massively Overpaid + Software Developer of Parties + + + 3,774 + 8-1990 + A1 + Massively Overpaid + Vigilante of Parties + + + 3,775 + 6-2016 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 3,776 + 4-2020 + B2 + Underpaid + Philosopher for Schools + + + 3,777 + 8-1993 + C2 + Slave Labour + Historian of Cattle + + + 3,778 + 2-1994 + C2 + Slave Labour + Food Taster for Eternity + + + 3,779 + 9-2021 + A2 + Overpaid + Assassin in Chief + + + 3,780 + 1-2009 + A2 + Overpaid + Vigilante (Trainee) + + + 3,781 + 1-2019 + C1 + Massively Underpaid + Philosopher for Eternity + + + 3,782 + 8-2007 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 3,783 + 4-2023 + B2 + Underpaid + Philosopher for Eternity + + + 3,784 + 7-2016 + B2 + Underpaid + Historian of Doom + + + 3,785 + 3-1998 + B1 + Fairly Paid + Historian of Parties + + + 3,786 + 2-2004 + C2 + Slave Labour + Builder Laureate + + + 3,787 + 9-2000 + B1 + Fairly Paid + Software Developer in Chief + + + 3,788 + 10-2001 + B2 + Underpaid + Vigilante Extraordinaire + + + 3,789 + 3-2008 + A2 + Overpaid + Vigilante in Chief + + + 3,790 + 7-2001 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 3,791 + 5-2013 + B1 + Fairly Paid + Philosopher (Trainee) + + + 3,792 + 2-1991 + C2 + Slave Labour + Food Taster of Cattle + + + 3,793 + 1-2003 + A1 + Massively Overpaid + Food Taster Trainer + + + 3,794 + 1-1995 + A1 + Massively Overpaid + Food Taster for the Environment + + + 3,795 + 7-1992 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 3,796 + 7-1990 + A1 + Massively Overpaid + Philosopher Laureate + + + 3,797 + 4-2022 + B1 + Fairly Paid + Author for Eternity + + + 3,798 + 2-1999 + B2 + Underpaid + Vigilante of Doom + + + 3,799 + 6-2006 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,800 + 1-2011 + A1 + Massively Overpaid + Food Taster for the Environment + + + 3,801 + 6-2002 + C1 + Massively Underpaid + Assassin of Parties + + + 3,802 + 2-1995 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 3,803 + 2-2010 + A1 + Massively Overpaid + Philosopher Trainer + + + 3,804 + 6-1992 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 3,805 + 8-2011 + B2 + Underpaid + Software Developer Laureate + + + 3,806 + 6-2001 + C2 + Slave Labour + Builder (Trainee) + + + 3,807 + 2-2016 + B1 + Fairly Paid + Vigilante Trainer + + + 3,808 + 8-2004 + C2 + Slave Labour + Historian in Chief + + + 3,809 + 2-1991 + B1 + Fairly Paid + Assassin of Cattle + + + 3,810 + 7-2011 + B1 + Fairly Paid + Philosopher of Parties + + + 3,811 + 4-1999 + C2 + Slave Labour + Food Taster in Chief + + + 3,812 + 2-2001 + B2 + Underpaid + Sports Mascot in Chief + + + 3,813 + 3-2013 + C2 + Slave Labour + Vigilante of Doom + + + 3,814 + 12-2013 + A2 + Overpaid + Philosopher (Trainee) + + + 3,815 + 1-2023 + A1 + Massively Overpaid + Builder Laureate + + + 3,816 + 12-1999 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 3,817 + 8-2008 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 3,818 + 6-2006 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 3,819 + 9-2007 + B2 + Underpaid + Vigilante for Eternity + + + 3,820 + 11-1993 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 3,821 + 3-1992 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 3,822 + 10-2017 + C2 + Slave Labour + Assassin Extraordinaire + + + 3,823 + 4-2007 + C2 + Slave Labour + Software Developer for the Environment + + + 3,824 + 3-2017 + B2 + Underpaid + Historian of Cattle + + + 3,825 + 2-2013 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 3,826 + 10-2023 + C2 + Slave Labour + Author Trainer + + + 3,827 + 4-2004 + A2 + Overpaid + Philosopher of Cattle + + + 3,828 + 2-2003 + C1 + Massively Underpaid + Historian (Trainee) + + + 3,829 + 1-2007 + C2 + Slave Labour + Sports Mascot of Parties + + + 3,830 + 10-2019 + C1 + Massively Underpaid + Software Developer for Schools + + + 3,831 + 8-2006 + C1 + Massively Underpaid + Author for Eternity + + + 3,832 + 2-2017 + B2 + Underpaid + Food Taster Trainer + + + 3,833 + 6-2006 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 3,834 + 10-1990 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 3,835 + 4-2015 + A1 + Massively Overpaid + Vigilante of Cattle + + + 3,836 + 7-1991 + C2 + Slave Labour + Builder for the Environment + + + 3,837 + 4-1999 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 3,838 + 8-1991 + A2 + Overpaid + Software Developer for the Environment + + + 3,839 + 10-2014 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,840 + 12-2001 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 3,841 + 11-2013 + B1 + Fairly Paid + Vigilante of Parties + + + 3,842 + 2-2020 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 3,843 + 1-2007 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 3,844 + 1-1994 + C2 + Slave Labour + Food Taster Laureate + + + 3,845 + 4-2019 + C1 + Massively Underpaid + Builder of Doom + + + 3,846 + 10-1995 + A2 + Overpaid + Builder Extraordinaire + + + 3,847 + 1-2009 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 3,848 + 5-1991 + B1 + Fairly Paid + Vigilante Trainer + + + 3,849 + 12-1990 + B1 + Fairly Paid + Vigilante of Parties + + + 3,850 + 3-1990 + A2 + Overpaid + Vigilante (Trainee) + + + 3,851 + 4-2011 + B1 + Fairly Paid + Vigilante (Trainee) + + + 3,852 + 2-2013 + A2 + Overpaid + Philosopher of Doom + + + 3,853 + 3-2015 + A2 + Overpaid + Vigilante Extraordinaire + + + 3,854 + 8-2015 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 3,855 + 11-1990 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 3,856 + 9-1991 + B1 + Fairly Paid + Software Developer for the Environment + + + 3,857 + 3-2014 + C2 + Slave Labour + Vigilante (Trainee) + + + 3,858 + 1-2009 + A2 + Overpaid + Author of Parties + + + 3,859 + 5-2019 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 3,860 + 9-2023 + A2 + Overpaid + Food Taster Laureate + + + 3,861 + 3-2011 + A1 + Massively Overpaid + Software Developer of Parties + + + 3,862 + 6-1990 + C2 + Slave Labour + Assassin Extraordinaire + + + 3,863 + 2-1993 + A2 + Overpaid + Sports Mascot in Chief + + + 3,864 + 12-2015 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 3,865 + 3-1999 + C1 + Massively Underpaid + Vigilante for Schools + + + 3,866 + 8-2004 + C1 + Massively Underpaid + Philosopher Trainer + + + 3,867 + 8-2006 + B2 + Underpaid + Philosopher (Trainee) + + + 3,868 + 1-2020 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 3,869 + 9-2004 + A1 + Massively Overpaid + Software Developer for Eternity + + + 3,870 + 6-2007 + A2 + Overpaid + Software Developer for Schools + + + 3,871 + 12-2005 + B2 + Underpaid + Historian Laureate + + + 3,872 + 5-1995 + C1 + Massively Underpaid + Historian in Chief + + + 3,873 + 2-2012 + A1 + Massively Overpaid + Software Developer for Eternity + + + 3,874 + 12-2000 + B2 + Underpaid + Software Developer (Trainee) + + + 3,875 + 11-2006 + C2 + Slave Labour + Software Developer Trainer + + + 3,876 + 5-1998 + B2 + Underpaid + Food Taster in Chief + + + 3,877 + 3-2010 + B1 + Fairly Paid + Software Developer for the Environment + + + 3,878 + 8-1998 + C2 + Slave Labour + Philosopher of Cattle + + + 3,879 + 11-2000 + C1 + Massively Underpaid + Assassin of Doom + + + 3,880 + 9-2000 + B1 + Fairly Paid + Builder of Doom + + + 3,881 + 3-1994 + C2 + Slave Labour + Author for Eternity + + + 3,882 + 3-2018 + C1 + Massively Underpaid + Builder of Doom + + + 3,883 + 9-2005 + A1 + Massively Overpaid + Author Laureate + + + 3,884 + 11-1995 + C2 + Slave Labour + Vigilante of Cattle + + + 3,885 + 3-2002 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 3,886 + 8-2016 + C1 + Massively Underpaid + Author of Parties + + + 3,887 + 9-2001 + C1 + Massively Underpaid + Software Developer for Schools + + + 3,888 + 7-2019 + B1 + Fairly Paid + Food Taster of Cattle + + + 3,889 + 5-1992 + B1 + Fairly Paid + Author of Doom + + + 3,890 + 12-2020 + B2 + Underpaid + Builder for Eternity + + + 3,891 + 10-1998 + A2 + Overpaid + Assassin of Cattle + + + 3,892 + 11-2020 + A2 + Overpaid + Sports Mascot in Chief + + + 3,893 + 9-2002 + C1 + Massively Underpaid + Food Taster Laureate + + + 3,894 + 8-1990 + C2 + Slave Labour + Historian for Schools + + + 3,895 + 8-2014 + B2 + Underpaid + Historian of Cattle + + + 3,896 + 8-2011 + B2 + Underpaid + Builder of Cattle + + + 3,897 + 10-2002 + C1 + Massively Underpaid + Software Developer for Schools + + + 3,898 + 3-2003 + B1 + Fairly Paid + Historian Extraordinaire + + + 3,899 + 8-1992 + C2 + Slave Labour + Philosopher Extraordinaire + + + 3,900 + 10-2005 + B1 + Fairly Paid + Builder for the Environment + + + 3,901 + 9-1990 + B1 + Fairly Paid + Software Developer (Trainee) + + + 3,902 + 9-2021 + C2 + Slave Labour + Vigilante of Cattle + + + 3,903 + 3-2013 + C1 + Massively Underpaid + Philosopher for Schools + + + 3,904 + 1-2003 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 3,905 + 8-1991 + B1 + Fairly Paid + Software Developer in Chief + + + 3,906 + 10-2005 + C2 + Slave Labour + Author of Doom + + + 3,907 + 7-2017 + C2 + Slave Labour + Software Developer Extraordinaire + + + 3,908 + 9-1994 + C2 + Slave Labour + Builder Laureate + + + 3,909 + 10-1995 + C2 + Slave Labour + Author of Parties + + + 3,910 + 12-2013 + B1 + Fairly Paid + Vigilante of Parties + + + 3,911 + 2-1992 + C1 + Massively Underpaid + Author (Trainee) + + + 3,912 + 5-1998 + B2 + Underpaid + Software Developer Laureate + + + 3,913 + 2-2022 + B2 + Underpaid + Assassin of Parties + + + 3,914 + 3-2018 + B2 + Underpaid + Sports Mascot of Parties + + + 3,915 + 5-2021 + C1 + Massively Underpaid + Historian for Schools + + + 3,916 + 6-2017 + B1 + Fairly Paid + Historian for Eternity + + + 3,917 + 7-2001 + A1 + Massively Overpaid + Historian for Eternity + + + 3,918 + 6-2022 + C1 + Massively Underpaid + Historian in Chief + + + 3,919 + 2-2004 + B2 + Underpaid + Builder Laureate + + + 3,920 + 2-2017 + C2 + Slave Labour + Vigilante of Parties + + + 3,921 + 4-1993 + C2 + Slave Labour + Author of Doom + + + 3,922 + 12-1996 + C2 + Slave Labour + Assassin Trainer + + + 3,923 + 6-1997 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 3,924 + 11-2022 + A2 + Overpaid + Assassin for the Environment + + + 3,925 + 9-2020 + C1 + Massively Underpaid + Assassin for the Environment + + + 3,926 + 6-2019 + C1 + Massively Underpaid + Software Developer in Chief + + + 3,927 + 10-1991 + A1 + Massively Overpaid + Author for Eternity + + + 3,928 + 1-2001 + A1 + Massively Overpaid + Food Taster of Parties + + + 3,929 + 6-2004 + B1 + Fairly Paid + Author Laureate + + + 3,930 + 4-2005 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 3,931 + 4-1994 + C1 + Massively Underpaid + Assassin of Parties + + + 3,932 + 12-1992 + C2 + Slave Labour + Philosopher for the Environment + + + 3,933 + 6-2023 + B2 + Underpaid + Software Developer for Eternity + + + 3,934 + 4-1995 + B1 + Fairly Paid + Assassin Laureate + + + 3,935 + 3-2015 + A2 + Overpaid + Sports Mascot for the Environment + + + 3,936 + 12-2002 + A1 + Massively Overpaid + Builder for Schools + + + 3,937 + 12-2016 + A1 + Massively Overpaid + Software Developer for the Environment + + + 3,938 + 1-1995 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 3,939 + 4-2014 + B1 + Fairly Paid + Historian Extraordinaire + + + 3,940 + 3-2012 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 3,941 + 7-2010 + C1 + Massively Underpaid + Builder (Trainee) + + + 3,942 + 9-2010 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 3,943 + 8-2017 + B2 + Underpaid + Author in Chief + + + 3,944 + 9-2011 + B2 + Underpaid + Software Developer Trainer + + + 3,945 + 9-2018 + A2 + Overpaid + Software Developer for the Environment + + + 3,946 + 10-2023 + A1 + Massively Overpaid + Software Developer of Parties + + + 3,947 + 3-2008 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 3,948 + 11-2002 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 3,949 + 3-2000 + C2 + Slave Labour + Historian Trainer + + + 3,950 + 3-1992 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 3,951 + 10-1994 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 3,952 + 6-2020 + C2 + Slave Labour + Builder of Parties + + + 3,953 + 10-1993 + B1 + Fairly Paid + Sports Mascot in Chief + + + 3,954 + 1-2009 + C1 + Massively Underpaid + Vigilante of Doom + + + 3,955 + 6-1994 + B2 + Underpaid + Software Developer in Chief + + + 3,956 + 7-2016 + B2 + Underpaid + Sports Mascot of Cattle + + + 3,957 + 5-2010 + A2 + Overpaid + Historian (Trainee) + + + 3,958 + 3-2001 + B2 + Underpaid + Builder (Trainee) + + + 3,959 + 9-2015 + B1 + Fairly Paid + Software Developer Laureate + + + 3,960 + 12-2002 + C2 + Slave Labour + Vigilante for the Environment + + + 3,961 + 11-2005 + A1 + Massively Overpaid + Philosopher for Schools + + + 3,962 + 3-1999 + B1 + Fairly Paid + Builder of Cattle + + + 3,963 + 2-2011 + B1 + Fairly Paid + Philosopher (Trainee) + + + 3,964 + 2-2008 + A2 + Overpaid + Assassin of Doom + + + 3,965 + 9-2010 + B1 + Fairly Paid + Builder for the Environment + + + 3,966 + 11-2011 + A2 + Overpaid + Assassin Trainer + + + 3,967 + 4-1996 + B1 + Fairly Paid + Sports Mascot of Doom + + + 3,968 + 6-2018 + C2 + Slave Labour + Assassin for Schools + + + 3,969 + 7-2003 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 3,970 + 12-2008 + C1 + Massively Underpaid + Software Developer of Doom + + + 3,971 + 11-2021 + B2 + Underpaid + Historian in Chief + + + 3,972 + 10-2008 + C2 + Slave Labour + Software Developer of Parties + + + 3,973 + 3-2009 + C2 + Slave Labour + Assassin for Schools + + + 3,974 + 7-2022 + B2 + Underpaid + Software Developer of Cattle + + + 3,975 + 12-2007 + A2 + Overpaid + Food Taster in Chief + + + 3,976 + 4-1992 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 3,977 + 10-2009 + C1 + Massively Underpaid + Builder Trainer + + + 3,978 + 2-2021 + A2 + Overpaid + Software Developer Trainer + + + 3,979 + 4-1992 + C2 + Slave Labour + Builder of Doom + + + 3,980 + 2-2004 + A2 + Overpaid + Assassin Laureate + + + 3,981 + 2-2015 + C1 + Massively Underpaid + Software Developer Trainer + + + 3,982 + 2-2021 + C1 + Massively Underpaid + Software Developer of Doom + + + 3,983 + 9-2007 + B1 + Fairly Paid + Historian of Parties + + + 3,984 + 10-1996 + C2 + Slave Labour + Vigilante for Eternity + + + 3,985 + 2-1999 + A1 + Massively Overpaid + Builder Trainer + + + 3,986 + 6-1997 + A2 + Overpaid + Skydiving Instructor Trainer + + + 3,987 + 9-2004 + A1 + Massively Overpaid + Vigilante for Eternity + + + 3,988 + 2-2022 + B2 + Underpaid + Skydiving Instructor of Parties + + + 3,989 + 6-2017 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 3,990 + 8-2001 + C2 + Slave Labour + Author of Doom + + + 3,991 + 12-2012 + A2 + Overpaid + Philosopher for Eternity + + + 3,992 + 12-1994 + B2 + Underpaid + Food Taster Extraordinaire + + + 3,993 + 6-2013 + A1 + Massively Overpaid + Historian for Schools + + + 3,994 + 4-2022 + A2 + Overpaid + Food Taster Extraordinaire + + + 3,995 + 12-1998 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 3,996 + 7-2010 + A1 + Massively Overpaid + Author Trainer + + + 3,997 + 6-2021 + C1 + Massively Underpaid + Historian Extraordinaire + + + 3,998 + 6-2002 + C2 + Slave Labour + Software Developer in Chief + + + 3,999 + 10-2008 + B2 + Underpaid + Author Extraordinaire + + + 4,000 + 9-2002 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 4,001 + 12-2009 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 4,002 + 5-2012 + C2 + Slave Labour + Assassin for Eternity + + + 4,003 + 12-1993 + A1 + Massively Overpaid + Author of Parties + + + 4,004 + 10-1995 + C2 + Slave Labour + Builder Trainer + + + 4,005 + 11-2023 + C2 + Slave Labour + Builder of Cattle + + + 4,006 + 7-2006 + B2 + Underpaid + Philosopher Trainer + + + 4,007 + 11-2016 + C1 + Massively Underpaid + Historian of Cattle + + + 4,008 + 7-1993 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 4,009 + 3-1990 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 4,010 + 9-2000 + A2 + Overpaid + Builder Trainer + + + 4,011 + 12-1998 + B2 + Underpaid + Historian of Cattle + + + 4,012 + 3-2008 + B2 + Underpaid + Sports Mascot for Eternity + + + 4,013 + 10-2023 + B1 + Fairly Paid + Software Developer for the Environment + + + 4,014 + 11-1994 + A2 + Overpaid + Sports Mascot Trainer + + + 4,015 + 8-2012 + A1 + Massively Overpaid + Food Taster of Parties + + + 4,016 + 2-2021 + B2 + Underpaid + Skydiving Instructor for Schools + + + 4,017 + 9-1998 + C1 + Massively Underpaid + Philosopher for Eternity + + + 4,018 + 4-2018 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 4,019 + 4-2013 + B2 + Underpaid + Food Taster for Schools + + + 4,020 + 4-2011 + A2 + Overpaid + Historian for the Environment + + + 4,021 + 1-2011 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 4,022 + 12-2010 + A2 + Overpaid + Philosopher in Chief + + + 4,023 + 2-2006 + B2 + Underpaid + Philosopher in Chief + + + 4,024 + 2-2004 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 4,025 + 10-1997 + B1 + Fairly Paid + Builder of Doom + + + 4,026 + 11-2002 + B2 + Underpaid + Food Taster of Doom + + + 4,027 + 10-2008 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 4,028 + 9-1994 + B2 + Underpaid + Builder in Chief + + + 4,029 + 5-2008 + B2 + Underpaid + Builder Extraordinaire + + + 4,030 + 2-1991 + C2 + Slave Labour + Food Taster (Trainee) + + + 4,031 + 2-2000 + A1 + Massively Overpaid + Philosopher for the Environment + + + 4,032 + 12-2002 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 4,033 + 11-2023 + C2 + Slave Labour + Assassin Laureate + + + 4,034 + 2-2008 + C2 + Slave Labour + Builder of Parties + + + 4,035 + 2-2023 + A2 + Overpaid + Historian in Chief + + + 4,036 + 3-1997 + A1 + Massively Overpaid + Assassin for the Environment + + + 4,037 + 5-2011 + C2 + Slave Labour + Philosopher Trainer + + + 4,038 + 12-2014 + A1 + Massively Overpaid + Software Developer of Cattle + + + 4,039 + 3-1996 + B2 + Underpaid + Historian of Parties + + + 4,040 + 1-1992 + B1 + Fairly Paid + Vigilante for Eternity + + + 4,041 + 8-1990 + C2 + Slave Labour + Food Taster of Doom + + + 4,042 + 12-2019 + B1 + Fairly Paid + Author of Doom + + + 4,043 + 3-1996 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 4,044 + 6-2019 + B2 + Underpaid + Food Taster Trainer + + + 4,045 + 1-2009 + B1 + Fairly Paid + Food Taster of Cattle + + + 4,046 + 10-2001 + B2 + Underpaid + Food Taster for the Environment + + + 4,047 + 9-2021 + A1 + Massively Overpaid + Software Developer of Cattle + + + 4,048 + 12-2005 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 4,049 + 1-2014 + B1 + Fairly Paid + Historian Laureate + + + 4,050 + 11-2011 + B1 + Fairly Paid + Assassin (Trainee) + + + 4,051 + 7-2000 + B2 + Underpaid + Philosopher of Doom + + + 4,052 + 1-1998 + C1 + Massively Underpaid + Philosopher for Eternity + + + 4,053 + 12-2021 + B1 + Fairly Paid + Food Taster Laureate + + + 4,054 + 7-2007 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 4,055 + 9-2017 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 4,056 + 1-2018 + C1 + Massively Underpaid + Builder of Parties + + + 4,057 + 8-2014 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 4,058 + 11-2022 + B1 + Fairly Paid + Sports Mascot for Schools + + + 4,059 + 2-2004 + A2 + Overpaid + Assassin Laureate + + + 4,060 + 3-2003 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 4,061 + 6-1990 + A2 + Overpaid + Skydiving Instructor of Doom + + + 4,062 + 2-2013 + B1 + Fairly Paid + Builder in Chief + + + 4,063 + 7-1991 + C2 + Slave Labour + Builder for Eternity + + + 4,064 + 1-1991 + A1 + Massively Overpaid + Vigilante Trainer + + + 4,065 + 8-1990 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 4,066 + 12-2015 + C1 + Massively Underpaid + Philosopher of Cattle + + + 4,067 + 7-2005 + B1 + Fairly Paid + Author for Schools + + + 4,068 + 4-2007 + C2 + Slave Labour + Builder for Schools + + + 4,069 + 2-1995 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 4,070 + 10-1993 + A1 + Massively Overpaid + Historian Extraordinaire + + + 4,071 + 10-2013 + A1 + Massively Overpaid + Food Taster in Chief + + + 4,072 + 6-2021 + A1 + Massively Overpaid + Software Developer Trainer + + + 4,073 + 4-2011 + A2 + Overpaid + Assassin for the Environment + + + 4,074 + 11-2000 + A1 + Massively Overpaid + Assassin of Parties + + + 4,075 + 4-1999 + A1 + Massively Overpaid + Philosopher in Chief + + + 4,076 + 12-2022 + C1 + Massively Underpaid + Author for the Environment + + + 4,077 + 1-2003 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 4,078 + 12-2006 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 4,079 + 2-2014 + A1 + Massively Overpaid + Historian of Parties + + + 4,080 + 1-2010 + B1 + Fairly Paid + Author (Trainee) + + + 4,081 + 1-2019 + C1 + Massively Underpaid + Author Trainer + + + 4,082 + 3-1997 + B1 + Fairly Paid + Sports Mascot of Doom + + + 4,083 + 1-2017 + B2 + Underpaid + Builder for Schools + + + 4,084 + 8-1996 + A2 + Overpaid + Philosopher for Schools + + + 4,085 + 12-1995 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 4,086 + 1-1990 + A2 + Overpaid + Author Trainer + + + 4,087 + 1-2013 + A2 + Overpaid + Historian Trainer + + + 4,088 + 6-2002 + A1 + Massively Overpaid + Assassin for the Environment + + + 4,089 + 11-2016 + C1 + Massively Underpaid + Builder Laureate + + + 4,090 + 1-1997 + B2 + Underpaid + Skydiving Instructor of Doom + + + 4,091 + 6-1999 + A1 + Massively Overpaid + Historian for the Environment + + + 4,092 + 8-2016 + A2 + Overpaid + Historian of Parties + + + 4,093 + 9-2017 + B2 + Underpaid + Philosopher for the Environment + + + 4,094 + 2-2008 + B2 + Underpaid + Food Taster for the Environment + + + 4,095 + 12-2003 + B1 + Fairly Paid + Vigilante for the Environment + + + 4,096 + 12-1999 + B2 + Underpaid + Vigilante Extraordinaire + + + 4,097 + 9-2006 + B2 + Underpaid + Author of Cattle + + + 4,098 + 2-1992 + C2 + Slave Labour + Builder for the Environment + + + 4,099 + 4-2001 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 4,100 + 7-2015 + C2 + Slave Labour + Historian (Trainee) + + + 4,101 + 2-1999 + B2 + Underpaid + Historian for Eternity + + + 4,102 + 10-1994 + C2 + Slave Labour + Author (Trainee) + + + 4,103 + 2-2019 + A1 + Massively Overpaid + Assassin for Eternity + + + 4,104 + 11-2010 + B2 + Underpaid + Philosopher Laureate + + + 4,105 + 7-1991 + C1 + Massively Underpaid + Software Developer of Parties + + + 4,106 + 5-2022 + A2 + Overpaid + Philosopher Laureate + + + 4,107 + 4-2007 + C2 + Slave Labour + Sports Mascot Laureate + + + 4,108 + 11-2000 + C1 + Massively Underpaid + Historian Laureate + + + 4,109 + 12-2002 + B1 + Fairly Paid + Author of Doom + + + 4,110 + 4-2011 + C1 + Massively Underpaid + Builder for Schools + + + 4,111 + 2-1991 + B1 + Fairly Paid + Food Taster Laureate + + + 4,112 + 7-2017 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 4,113 + 6-1995 + A1 + Massively Overpaid + Historian for Schools + + + 4,114 + 4-2016 + A1 + Massively Overpaid + Author in Chief + + + 4,115 + 8-2008 + A1 + Massively Overpaid + Philosopher Laureate + + + 4,116 + 3-2015 + C2 + Slave Labour + Author of Cattle + + + 4,117 + 8-2017 + A2 + Overpaid + Builder in Chief + + + 4,118 + 3-1996 + C2 + Slave Labour + Sports Mascot for Schools + + + 4,119 + 4-2017 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 4,120 + 9-2009 + C2 + Slave Labour + Author in Chief + + + 4,121 + 12-2023 + B1 + Fairly Paid + Historian of Doom + + + 4,122 + 5-2020 + B2 + Underpaid + Historian Laureate + + + 4,123 + 10-2018 + C2 + Slave Labour + Assassin Extraordinaire + + + 4,124 + 6-2006 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 4,125 + 2-1999 + A2 + Overpaid + Historian (Trainee) + + + 4,126 + 3-2007 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 4,127 + 7-2008 + C2 + Slave Labour + Philosopher of Doom + + + 4,128 + 2-1997 + B1 + Fairly Paid + Builder for the Environment + + + 4,129 + 11-2021 + C2 + Slave Labour + Builder of Doom + + + 4,130 + 3-1995 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 4,131 + 8-1992 + C1 + Massively Underpaid + Builder Laureate + + + 4,132 + 11-2002 + B1 + Fairly Paid + Assassin Laureate + + + 4,133 + 1-2022 + B2 + Underpaid + Historian Laureate + + + 4,134 + 10-1993 + B2 + Underpaid + Sports Mascot for Schools + + + 4,135 + 9-1994 + C1 + Massively Underpaid + Author of Cattle + + + 4,136 + 4-2018 + C2 + Slave Labour + Software Developer for the Environment + + + 4,137 + 10-2019 + B1 + Fairly Paid + Builder for Eternity + + + 4,138 + 12-1997 + A2 + Overpaid + Vigilante Extraordinaire + + + 4,139 + 10-2016 + B2 + Underpaid + Builder for the Environment + + + 4,140 + 8-2021 + A2 + Overpaid + Philosopher in Chief + + + 4,141 + 3-2000 + A2 + Overpaid + Philosopher for Schools + + + 4,142 + 2-1997 + C2 + Slave Labour + Author of Doom + + + 4,143 + 1-2004 + B2 + Underpaid + Skydiving Instructor in Chief + + + 4,144 + 6-2002 + A2 + Overpaid + Assassin for Schools + + + 4,145 + 7-2016 + B2 + Underpaid + Food Taster in Chief + + + 4,146 + 5-2016 + A1 + Massively Overpaid + Author for Eternity + + + 4,147 + 9-1990 + B1 + Fairly Paid + Assassin (Trainee) + + + 4,148 + 5-1992 + B1 + Fairly Paid + Vigilante Trainer + + + 4,149 + 2-1990 + C2 + Slave Labour + Assassin Extraordinaire + + + 4,150 + 8-2002 + B1 + Fairly Paid + Author in Chief + + + 4,151 + 11-2013 + C2 + Slave Labour + Builder for Schools + + + 4,152 + 9-2003 + A1 + Massively Overpaid + Philosopher Trainer + + + 4,153 + 6-1996 + A1 + Massively Overpaid + Software Developer of Cattle + + + 4,154 + 11-2013 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 4,155 + 4-2005 + C2 + Slave Labour + Vigilante of Doom + + + 4,156 + 12-2012 + B1 + Fairly Paid + Food Taster in Chief + + + 4,157 + 8-2008 + A2 + Overpaid + Author of Parties + + + 4,158 + 8-2023 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 4,159 + 7-2014 + B1 + Fairly Paid + Builder of Doom + + + 4,160 + 2-2020 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 4,161 + 8-1998 + C2 + Slave Labour + Author for the Environment + + + 4,162 + 2-1996 + C2 + Slave Labour + Assassin for Eternity + + + 4,163 + 9-2011 + C2 + Slave Labour + Historian (Trainee) + + + 4,164 + 8-2003 + B2 + Underpaid + Food Taster Trainer + + + 4,165 + 6-2012 + C2 + Slave Labour + Philosopher Trainer + + + 4,166 + 1-2023 + A2 + Overpaid + Philosopher (Trainee) + + + 4,167 + 2-2012 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 4,168 + 8-2017 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 4,169 + 12-1990 + A2 + Overpaid + Software Developer Trainer + + + 4,170 + 6-1995 + C2 + Slave Labour + Builder Trainer + + + 4,171 + 7-2001 + A2 + Overpaid + Assassin of Doom + + + 4,172 + 4-1994 + A2 + Overpaid + Author for the Environment + + + 4,173 + 2-2013 + C1 + Massively Underpaid + Historian of Parties + + + 4,174 + 6-2008 + B2 + Underpaid + Vigilante for the Environment + + + 4,175 + 3-1999 + C1 + Massively Underpaid + Food Taster for Schools + + + 4,176 + 9-2005 + C2 + Slave Labour + Vigilante of Parties + + + 4,177 + 9-2006 + A2 + Overpaid + Sports Mascot (Trainee) + + + 4,178 + 7-2009 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 4,179 + 3-2023 + A2 + Overpaid + Builder in Chief + + + 4,180 + 10-2013 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 4,181 + 5-2016 + C2 + Slave Labour + Food Taster Trainer + + + 4,182 + 12-2008 + A2 + Overpaid + Assassin Laureate + + + 4,183 + 11-1992 + A2 + Overpaid + Author of Doom + + + 4,184 + 6-2023 + A2 + Overpaid + Historian in Chief + + + 4,185 + 9-2002 + A1 + Massively Overpaid + Assassin (Trainee) + + + 4,186 + 6-1998 + A2 + Overpaid + Software Developer Laureate + + + 4,187 + 5-1998 + A2 + Overpaid + Food Taster in Chief + + + 4,188 + 2-1993 + A2 + Overpaid + Skydiving Instructor of Parties + + + 4,189 + 1-2006 + B1 + Fairly Paid + Builder (Trainee) + + + 4,190 + 9-2002 + B1 + Fairly Paid + Food Taster for Eternity + + + 4,191 + 1-1999 + A2 + Overpaid + Philosopher for the Environment + + + 4,192 + 9-2008 + C1 + Massively Underpaid + Software Developer for Eternity + + + 4,193 + 3-2014 + A2 + Overpaid + Builder for Eternity + + + 4,194 + 8-2011 + C2 + Slave Labour + Author in Chief + + + 4,195 + 8-2019 + C1 + Massively Underpaid + Vigilante of Doom + + + 4,196 + 2-2004 + A2 + Overpaid + Software Developer Laureate + + + 4,197 + 3-2005 + B1 + Fairly Paid + Philosopher in Chief + + + 4,198 + 8-2007 + C2 + Slave Labour + Philosopher for Schools + + + 4,199 + 6-2020 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 4,200 + 8-2009 + A1 + Massively Overpaid + Historian of Parties + + + 4,201 + 1-2012 + B1 + Fairly Paid + Author of Parties + + + 4,202 + 12-1994 + A2 + Overpaid + Vigilante Laureate + + + 4,203 + 11-2023 + A1 + Massively Overpaid + Builder of Doom + + + 4,204 + 9-2020 + C1 + Massively Underpaid + Software Developer for the Environment + + + 4,205 + 9-1992 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 4,206 + 1-2018 + A2 + Overpaid + Author Trainer + + + 4,207 + 5-2016 + B2 + Underpaid + Philosopher of Parties + + + 4,208 + 9-2020 + A2 + Overpaid + Historian for the Environment + + + 4,209 + 7-2004 + C2 + Slave Labour + Food Taster in Chief + + + 4,210 + 6-2023 + A1 + Massively Overpaid + Philosopher of Parties + + + 4,211 + 12-1991 + A1 + Massively Overpaid + Builder Extraordinaire + + + 4,212 + 2-2022 + A2 + Overpaid + Philosopher Trainer + + + 4,213 + 9-2002 + A2 + Overpaid + Sports Mascot of Cattle + + + 4,214 + 11-2015 + C2 + Slave Labour + Builder for the Environment + + + 4,215 + 7-2006 + C2 + Slave Labour + Builder of Cattle + + + 4,216 + 5-2003 + B2 + Underpaid + Food Taster for Schools + + + 4,217 + 2-2013 + B1 + Fairly Paid + Philosopher of Cattle + + + 4,218 + 8-2021 + B2 + Underpaid + Food Taster for the Environment + + + 4,219 + 8-2018 + B2 + Underpaid + Historian (Trainee) + + + 4,220 + 12-1995 + C1 + Massively Underpaid + Author Laureate + + + 4,221 + 11-2013 + B1 + Fairly Paid + Assassin for Eternity + + + 4,222 + 10-1991 + A2 + Overpaid + Author Laureate + + + 4,223 + 3-1995 + B1 + Fairly Paid + Vigilante of Parties + + + 4,224 + 3-2013 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 4,225 + 5-1993 + C1 + Massively Underpaid + Software Developer of Cattle + + + 4,226 + 1-1995 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 4,227 + 12-2008 + A2 + Overpaid + Assassin for Schools + + + 4,228 + 5-2002 + A2 + Overpaid + Builder of Cattle + + + 4,229 + 6-2000 + C2 + Slave Labour + Food Taster Extraordinaire + + + 4,230 + 4-2003 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 4,231 + 3-2014 + A1 + Massively Overpaid + Software Developer for Eternity + + + 4,232 + 11-2010 + A1 + Massively Overpaid + Assassin Trainer + + + 4,233 + 10-1993 + C1 + Massively Underpaid + Historian Extraordinaire + + + 4,234 + 9-2006 + C2 + Slave Labour + Philosopher in Chief + + + 4,235 + 1-2013 + C2 + Slave Labour + Food Taster of Parties + + + 4,236 + 8-2015 + A2 + Overpaid + Builder of Doom + + + 4,237 + 12-1999 + B1 + Fairly Paid + Food Taster (Trainee) + + + 4,238 + 10-2022 + C2 + Slave Labour + Sports Mascot in Chief + + + 4,239 + 5-2001 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 4,240 + 9-2023 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 4,241 + 6-2016 + B2 + Underpaid + Historian (Trainee) + + + 4,242 + 10-2007 + B2 + Underpaid + Food Taster for Eternity + + + 4,243 + 4-1995 + B1 + Fairly Paid + Vigilante (Trainee) + + + 4,244 + 5-2007 + B1 + Fairly Paid + Sports Mascot of Doom + + + 4,245 + 1-1996 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 4,246 + 6-1996 + A1 + Massively Overpaid + Historian of Doom + + + 4,247 + 7-1997 + A2 + Overpaid + Assassin in Chief + + + 4,248 + 2-2021 + B2 + Underpaid + Sports Mascot in Chief + + + 4,249 + 12-2018 + C1 + Massively Underpaid + Food Taster for Eternity + + + 4,250 + 11-1995 + B1 + Fairly Paid + Historian of Parties + + + 4,251 + 11-1995 + A2 + Overpaid + Builder (Trainee) + + + 4,252 + 6-1997 + C2 + Slave Labour + Vigilante for Schools + + + 4,253 + 8-2011 + B1 + Fairly Paid + Builder in Chief + + + 4,254 + 8-1999 + C1 + Massively Underpaid + Assassin of Doom + + + 4,255 + 11-1998 + A1 + Massively Overpaid + Author for Eternity + + + 4,256 + 11-2020 + B2 + Underpaid + Builder for Schools + + + 4,257 + 5-2005 + C1 + Massively Underpaid + Author for Eternity + + + 4,258 + 9-2013 + A1 + Massively Overpaid + Historian of Cattle + + + 4,259 + 10-2003 + A2 + Overpaid + Vigilante for Eternity + + + 4,260 + 1-2020 + A2 + Overpaid + Skydiving Instructor Laureate + + + 4,261 + 5-2023 + C2 + Slave Labour + Philosopher of Parties + + + 4,262 + 6-2012 + C2 + Slave Labour + Historian in Chief + + + 4,263 + 7-2017 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 4,264 + 2-1990 + B1 + Fairly Paid + Philosopher for the Environment + + + 4,265 + 7-2010 + A2 + Overpaid + Builder Extraordinaire + + + 4,266 + 11-1994 + C2 + Slave Labour + Vigilante Laureate + + + 4,267 + 6-2008 + C1 + Massively Underpaid + Food Taster of Doom + + + 4,268 + 10-1992 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 4,269 + 11-1993 + C1 + Massively Underpaid + Software Developer of Doom + + + 4,270 + 2-2023 + A1 + Massively Overpaid + Author in Chief + + + 4,271 + 1-2008 + B1 + Fairly Paid + Philosopher of Doom + + + 4,272 + 10-2016 + A2 + Overpaid + Assassin Laureate + + + 4,273 + 8-1995 + B1 + Fairly Paid + Vigilante of Parties + + + 4,274 + 11-1994 + B2 + Underpaid + Assassin (Trainee) + + + 4,275 + 5-2000 + A2 + Overpaid + Vigilante of Cattle + + + 4,276 + 7-2007 + B1 + Fairly Paid + Software Developer for the Environment + + + 4,277 + 6-2022 + C2 + Slave Labour + Philosopher Laureate + + + 4,278 + 8-2013 + A2 + Overpaid + Vigilante Extraordinaire + + + 4,279 + 11-1994 + B2 + Underpaid + Food Taster Trainer + + + 4,280 + 4-2005 + A2 + Overpaid + Food Taster in Chief + + + 4,281 + 11-2020 + A2 + Overpaid + Builder Extraordinaire + + + 4,282 + 12-1995 + A1 + Massively Overpaid + Philosopher for Eternity + + + 4,283 + 3-2000 + C1 + Massively Underpaid + Author for the Environment + + + 4,284 + 8-1996 + B1 + Fairly Paid + Author Extraordinaire + + + 4,285 + 10-2019 + B2 + Underpaid + Philosopher of Parties + + + 4,286 + 2-2000 + A2 + Overpaid + Assassin for the Environment + + + 4,287 + 12-2009 + A2 + Overpaid + Software Developer for the Environment + + + 4,288 + 10-2001 + C2 + Slave Labour + Assassin (Trainee) + + + 4,289 + 12-2008 + C2 + Slave Labour + Vigilante Laureate + + + 4,290 + 11-2016 + C2 + Slave Labour + Sports Mascot for Eternity + + + 4,291 + 6-1996 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 4,292 + 6-2021 + C2 + Slave Labour + Author (Trainee) + + + 4,293 + 2-2012 + C2 + Slave Labour + Philosopher Laureate + + + 4,294 + 2-2005 + C2 + Slave Labour + Assassin Extraordinaire + + + 4,295 + 4-2015 + B1 + Fairly Paid + Philosopher for the Environment + + + 4,296 + 1-2015 + A2 + Overpaid + Builder Trainer + + + 4,297 + 7-1992 + B1 + Fairly Paid + Vigilante (Trainee) + + + 4,298 + 4-2016 + A2 + Overpaid + Software Developer in Chief + + + 4,299 + 10-1995 + B2 + Underpaid + Software Developer for Eternity + + + 4,300 + 1-2019 + C2 + Slave Labour + Vigilante of Doom + + + 4,301 + 10-2022 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 4,302 + 3-1990 + A2 + Overpaid + Author for Eternity + + + 4,303 + 4-1991 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 4,304 + 4-2016 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 4,305 + 8-2010 + A1 + Massively Overpaid + Vigilante Trainer + + + 4,306 + 4-1998 + A1 + Massively Overpaid + Assassin Trainer + + + 4,307 + 1-1990 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 4,308 + 12-2000 + C1 + Massively Underpaid + Author of Parties + + + 4,309 + 1-2001 + A2 + Overpaid + Builder for Schools + + + 4,310 + 1-2011 + B2 + Underpaid + Historian of Doom + + + 4,311 + 3-2001 + A2 + Overpaid + Philosopher for Schools + + + 4,312 + 7-1992 + B1 + Fairly Paid + Sports Mascot Laureate + + + 4,313 + 8-1991 + B2 + Underpaid + Philosopher for the Environment + + + 4,314 + 6-1996 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 4,315 + 4-2004 + B2 + Underpaid + Vigilante Trainer + + + 4,316 + 5-2005 + B2 + Underpaid + Vigilante of Cattle + + + 4,317 + 2-2004 + C1 + Massively Underpaid + Vigilante of Parties + + + 4,318 + 8-2011 + B2 + Underpaid + Author in Chief + + + 4,319 + 6-2015 + C2 + Slave Labour + Builder of Cattle + + + 4,320 + 7-2017 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 4,321 + 1-2000 + B2 + Underpaid + Assassin of Doom + + + 4,322 + 12-1998 + B2 + Underpaid + Sports Mascot Laureate + + + 4,323 + 11-2010 + C2 + Slave Labour + Author for the Environment + + + 4,324 + 4-2018 + B2 + Underpaid + Author of Cattle + + + 4,325 + 11-2023 + C1 + Massively Underpaid + Vigilante Laureate + + + 4,326 + 10-1998 + C2 + Slave Labour + Historian in Chief + + + 4,327 + 4-2023 + A2 + Overpaid + Author of Doom + + + 4,328 + 3-2007 + C2 + Slave Labour + Sports Mascot in Chief + + + 4,329 + 2-2003 + B2 + Underpaid + Philosopher for Schools + + + 4,330 + 5-2007 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 4,331 + 9-2003 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 4,332 + 3-2017 + B2 + Underpaid + Builder (Trainee) + + + 4,333 + 8-2004 + B2 + Underpaid + Author for Eternity + + + 4,334 + 11-2010 + B1 + Fairly Paid + Historian of Cattle + + + 4,335 + 2-2003 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 4,336 + 10-2023 + A2 + Overpaid + Skydiving Instructor Trainer + + + 4,337 + 11-2015 + B2 + Underpaid + Food Taster of Cattle + + + 4,338 + 6-2007 + B1 + Fairly Paid + Sports Mascot in Chief + + + 4,339 + 12-2021 + B2 + Underpaid + Sports Mascot of Doom + + + 4,340 + 5-2012 + B2 + Underpaid + Assassin for Eternity + + + 4,341 + 4-2010 + A2 + Overpaid + Skydiving Instructor for Schools + + + 4,342 + 10-1991 + C2 + Slave Labour + Assassin for Eternity + + + 4,343 + 12-2011 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 4,344 + 1-2016 + A2 + Overpaid + Builder in Chief + + + 4,345 + 11-2009 + B2 + Underpaid + Author Trainer + + + 4,346 + 3-2006 + A2 + Overpaid + Food Taster for Eternity + + + 4,347 + 1-2009 + C1 + Massively Underpaid + Philosopher of Cattle + + + 4,348 + 11-1997 + B2 + Underpaid + Author for Eternity + + + 4,349 + 4-2019 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 4,350 + 3-2013 + C2 + Slave Labour + Assassin for Schools + + + 4,351 + 5-2008 + C2 + Slave Labour + Vigilante Extraordinaire + + + 4,352 + 2-2010 + B2 + Underpaid + Historian Laureate + + + 4,353 + 8-2003 + B1 + Fairly Paid + Assassin Laureate + + + 4,354 + 4-2011 + C1 + Massively Underpaid + Assassin Laureate + + + 4,355 + 6-2023 + B2 + Underpaid + Builder of Cattle + + + 4,356 + 11-2009 + B1 + Fairly Paid + Vigilante in Chief + + + 4,357 + 2-1998 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 4,358 + 4-2012 + A1 + Massively Overpaid + Software Developer of Parties + + + 4,359 + 2-2009 + B1 + Fairly Paid + Author of Cattle + + + 4,360 + 12-2009 + A2 + Overpaid + Sports Mascot of Cattle + + + 4,361 + 4-2022 + B2 + Underpaid + Software Developer for Schools + + + 4,362 + 6-2003 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 4,363 + 2-2010 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 4,364 + 2-2012 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 4,365 + 11-2022 + B2 + Underpaid + Vigilante Extraordinaire + + + 4,366 + 5-2017 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 4,367 + 11-2006 + B1 + Fairly Paid + Philosopher (Trainee) + + + 4,368 + 8-2009 + A2 + Overpaid + Software Developer for Eternity + + + 4,369 + 7-1997 + A2 + Overpaid + Software Developer (Trainee) + + + 4,370 + 9-2021 + A1 + Massively Overpaid + Philosopher for Eternity + + + 4,371 + 8-2010 + B1 + Fairly Paid + Author for the Environment + + + 4,372 + 4-2018 + B2 + Underpaid + Assassin (Trainee) + + + 4,373 + 12-2015 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 4,374 + 6-2004 + C1 + Massively Underpaid + Builder Extraordinaire + + + 4,375 + 8-2009 + B2 + Underpaid + Vigilante of Cattle + + + 4,376 + 11-2014 + C2 + Slave Labour + Vigilante (Trainee) + + + 4,377 + 11-2009 + C1 + Massively Underpaid + Builder of Cattle + + + 4,378 + 5-2010 + A2 + Overpaid + Historian in Chief + + + 4,379 + 11-1996 + B1 + Fairly Paid + Builder Trainer + + + 4,380 + 10-2018 + B2 + Underpaid + Software Developer of Cattle + + + 4,381 + 8-2003 + A1 + Massively Overpaid + Philosopher Laureate + + + 4,382 + 10-2018 + B1 + Fairly Paid + Author for Schools + + + 4,383 + 9-2023 + B1 + Fairly Paid + Historian for Schools + + + 4,384 + 9-2002 + A2 + Overpaid + Skydiving Instructor Laureate + + + 4,385 + 6-2014 + A2 + Overpaid + Software Developer for Schools + + + 4,386 + 11-2017 + C1 + Massively Underpaid + Software Developer Laureate + + + 4,387 + 3-2010 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 4,388 + 12-1999 + B2 + Underpaid + Philosopher of Cattle + + + 4,389 + 8-2009 + B1 + Fairly Paid + Author of Parties + + + 4,390 + 7-2020 + B1 + Fairly Paid + Assassin in Chief + + + 4,391 + 10-1996 + C1 + Massively Underpaid + Builder of Cattle + + + 4,392 + 1-1993 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 4,393 + 2-1994 + B1 + Fairly Paid + Vigilante Laureate + + + 4,394 + 6-1996 + B1 + Fairly Paid + Historian for Schools + + + 4,395 + 1-2022 + C1 + Massively Underpaid + Assassin for Eternity + + + 4,396 + 12-2012 + A2 + Overpaid + Food Taster for the Environment + + + 4,397 + 5-2001 + A2 + Overpaid + Vigilante for Eternity + + + 4,398 + 2-1991 + A1 + Massively Overpaid + Software Developer of Doom + + + 4,399 + 11-2009 + B2 + Underpaid + Builder of Doom + + + 4,400 + 11-1994 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 4,401 + 12-2023 + B1 + Fairly Paid + Philosopher of Cattle + + + 4,402 + 1-2012 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 4,403 + 7-1991 + B2 + Underpaid + Builder for the Environment + + + 4,404 + 6-2023 + C1 + Massively Underpaid + Builder for Schools + + + 4,405 + 5-2014 + A2 + Overpaid + Skydiving Instructor Trainer + + + 4,406 + 5-2014 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 4,407 + 1-2021 + A1 + Massively Overpaid + Assassin in Chief + + + 4,408 + 7-2022 + C1 + Massively Underpaid + Software Developer of Doom + + + 4,409 + 8-2020 + A1 + Massively Overpaid + Author of Parties + + + 4,410 + 1-1999 + C2 + Slave Labour + Vigilante (Trainee) + + + 4,411 + 1-2013 + C2 + Slave Labour + Author for the Environment + + + 4,412 + 8-2007 + C2 + Slave Labour + Historian for Eternity + + + 4,413 + 6-2013 + C2 + Slave Labour + Sports Mascot Laureate + + + 4,414 + 9-2017 + B2 + Underpaid + Author for Eternity + + + 4,415 + 10-2015 + B1 + Fairly Paid + Assassin of Parties + + + 4,416 + 5-2001 + C2 + Slave Labour + Builder Trainer + + + 4,417 + 6-1992 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 4,418 + 9-2012 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 4,419 + 5-2002 + A2 + Overpaid + Author of Doom + + + 4,420 + 5-1994 + C2 + Slave Labour + Food Taster Laureate + + + 4,421 + 9-1997 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 4,422 + 3-2000 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 4,423 + 4-2021 + B2 + Underpaid + Sports Mascot for Schools + + + 4,424 + 1-2019 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 4,425 + 12-2015 + A2 + Overpaid + Author of Cattle + + + 4,426 + 11-2021 + C2 + Slave Labour + Philosopher Laureate + + + 4,427 + 12-2000 + C2 + Slave Labour + Vigilante in Chief + + + 4,428 + 3-2003 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 4,429 + 12-2007 + B2 + Underpaid + Assassin of Parties + + + 4,430 + 9-2005 + A2 + Overpaid + Vigilante in Chief + + + 4,431 + 3-2010 + C2 + Slave Labour + Food Taster (Trainee) + + + 4,432 + 6-2006 + B1 + Fairly Paid + Builder Laureate + + + 4,433 + 1-1995 + B1 + Fairly Paid + Vigilante Trainer + + + 4,434 + 9-2011 + C2 + Slave Labour + Assassin of Doom + + + 4,435 + 1-2002 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 4,436 + 3-2011 + A1 + Massively Overpaid + Historian (Trainee) + + + 4,437 + 7-2000 + A2 + Overpaid + Vigilante of Cattle + + + 4,438 + 11-2007 + B2 + Underpaid + Assassin for Eternity + + + 4,439 + 6-2010 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 4,440 + 5-2016 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 4,441 + 10-1998 + B1 + Fairly Paid + Vigilante Trainer + + + 4,442 + 11-2001 + A2 + Overpaid + Vigilante for Eternity + + + 4,443 + 1-2001 + C2 + Slave Labour + Assassin (Trainee) + + + 4,444 + 2-2001 + B1 + Fairly Paid + Food Taster of Doom + + + 4,445 + 6-2017 + B1 + Fairly Paid + Software Developer (Trainee) + + + 4,446 + 8-2021 + A1 + Massively Overpaid + Philosopher for the Environment + + + 4,447 + 1-2014 + A2 + Overpaid + Author in Chief + + + 4,448 + 5-2010 + B2 + Underpaid + Historian of Doom + + + 4,449 + 12-2002 + A2 + Overpaid + Software Developer Extraordinaire + + + 4,450 + 9-2013 + B1 + Fairly Paid + Food Taster Laureate + + + 4,451 + 1-2002 + C1 + Massively Underpaid + Assassin Trainer + + + 4,452 + 11-2000 + A1 + Massively Overpaid + Vigilante Laureate + + + 4,453 + 5-2020 + B1 + Fairly Paid + Vigilante Trainer + + + 4,454 + 1-2007 + C2 + Slave Labour + Sports Mascot for the Environment + + + 4,455 + 1-2018 + B2 + Underpaid + Historian in Chief + + + 4,456 + 5-1998 + B1 + Fairly Paid + Author of Cattle + + + 4,457 + 8-2023 + C1 + Massively Underpaid + Assassin for Schools + + + 4,458 + 11-2004 + C2 + Slave Labour + Author in Chief + + + 4,459 + 5-2021 + A2 + Overpaid + Builder of Doom + + + 4,460 + 5-2000 + B2 + Underpaid + Vigilante in Chief + + + 4,461 + 10-2016 + B1 + Fairly Paid + Builder Trainer + + + 4,462 + 10-2018 + C1 + Massively Underpaid + Vigilante for Schools + + + 4,463 + 1-2018 + C1 + Massively Underpaid + Author in Chief + + + 4,464 + 6-2022 + A2 + Overpaid + Skydiving Instructor Trainer + + + 4,465 + 2-2003 + C2 + Slave Labour + Philosopher Trainer + + + 4,466 + 6-2014 + C1 + Massively Underpaid + Assassin of Doom + + + 4,467 + 8-2012 + B2 + Underpaid + Author of Parties + + + 4,468 + 11-1996 + C2 + Slave Labour + Vigilante of Parties + + + 4,469 + 2-2003 + C2 + Slave Labour + Software Developer for Schools + + + 4,470 + 5-1993 + A1 + Massively Overpaid + Historian for Schools + + + 4,471 + 11-2003 + C2 + Slave Labour + Software Developer for Eternity + + + 4,472 + 4-2009 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 4,473 + 6-1990 + A2 + Overpaid + Skydiving Instructor in Chief + + + 4,474 + 6-2011 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 4,475 + 12-1993 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 4,476 + 4-2005 + B2 + Underpaid + Food Taster for Eternity + + + 4,477 + 8-2022 + C1 + Massively Underpaid + Food Taster for Eternity + + + 4,478 + 12-2012 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 4,479 + 12-1991 + B1 + Fairly Paid + Software Developer for the Environment + + + 4,480 + 10-2010 + B1 + Fairly Paid + Historian of Parties + + + 4,481 + 5-2012 + C1 + Massively Underpaid + Assassin (Trainee) + + + 4,482 + 4-1997 + B1 + Fairly Paid + Builder Extraordinaire + + + 4,483 + 7-2011 + A2 + Overpaid + Sports Mascot for the Environment + + + 4,484 + 2-1993 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 4,485 + 7-2004 + C2 + Slave Labour + Author for Schools + + + 4,486 + 3-2016 + B2 + Underpaid + Skydiving Instructor of Parties + + + 4,487 + 8-2017 + C2 + Slave Labour + Philosopher of Cattle + + + 4,488 + 5-1993 + C2 + Slave Labour + Food Taster of Parties + + + 4,489 + 9-2009 + C2 + Slave Labour + Assassin for Eternity + + + 4,490 + 11-2013 + C1 + Massively Underpaid + Food Taster Trainer + + + 4,491 + 4-2018 + C1 + Massively Underpaid + Software Developer Laureate + + + 4,492 + 5-2021 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 4,493 + 12-2013 + A1 + Massively Overpaid + Software Developer for Eternity + + + 4,494 + 5-2001 + C1 + Massively Underpaid + Historian of Doom + + + 4,495 + 7-2005 + B2 + Underpaid + Software Developer of Doom + + + 4,496 + 7-2002 + C1 + Massively Underpaid + Philosopher Laureate + + + 4,497 + 4-2010 + A2 + Overpaid + Food Taster for Eternity + + + 4,498 + 4-2005 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 4,499 + 10-2001 + C2 + Slave Labour + Historian for the Environment + + + 4,500 + 3-2006 + C2 + Slave Labour + Builder Laureate + + + 4,501 + 10-2002 + C1 + Massively Underpaid + Builder Extraordinaire + + + 4,502 + 6-2019 + C2 + Slave Labour + Builder in Chief + + + 4,503 + 12-2006 + A2 + Overpaid + Philosopher Extraordinaire + + + 4,504 + 9-2003 + C1 + Massively Underpaid + Historian of Cattle + + + 4,505 + 12-1995 + B2 + Underpaid + Food Taster Extraordinaire + + + 4,506 + 8-1994 + A1 + Massively Overpaid + Author in Chief + + + 4,507 + 1-2002 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 4,508 + 6-2000 + A2 + Overpaid + Philosopher in Chief + + + 4,509 + 11-1990 + A1 + Massively Overpaid + Builder Extraordinaire + + + 4,510 + 8-1993 + B1 + Fairly Paid + Philosopher (Trainee) + + + 4,511 + 5-1994 + B2 + Underpaid + Historian for the Environment + + + 4,512 + 11-2021 + A2 + Overpaid + Builder for Eternity + + + 4,513 + 5-2002 + B1 + Fairly Paid + Vigilante for the Environment + + + 4,514 + 10-2018 + C1 + Massively Underpaid + Historian Trainer + + + 4,515 + 10-2008 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 4,516 + 7-2021 + B2 + Underpaid + Food Taster of Cattle + + + 4,517 + 9-2014 + C2 + Slave Labour + Historian for Eternity + + + 4,518 + 8-1990 + C2 + Slave Labour + Builder Trainer + + + 4,519 + 4-2012 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 4,520 + 8-1992 + A1 + Massively Overpaid + Food Taster for the Environment + + + 4,521 + 4-1992 + C1 + Massively Underpaid + Assassin of Parties + + + 4,522 + 7-2018 + A2 + Overpaid + Food Taster Laureate + + + 4,523 + 11-1993 + B1 + Fairly Paid + Food Taster of Cattle + + + 4,524 + 6-2023 + A2 + Overpaid + Skydiving Instructor of Parties + + + 4,525 + 6-2018 + A1 + Massively Overpaid + Vigilante of Parties + + + 4,526 + 6-2015 + C1 + Massively Underpaid + Assassin of Doom + + + 4,527 + 12-2004 + C2 + Slave Labour + Author Trainer + + + 4,528 + 12-1998 + C1 + Massively Underpaid + Vigilante for Eternity + + + 4,529 + 10-2021 + C1 + Massively Underpaid + Author for Schools + + + 4,530 + 7-1993 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 4,531 + 7-2022 + C2 + Slave Labour + Philosopher of Doom + + + 4,532 + 9-2014 + B1 + Fairly Paid + Food Taster of Cattle + + + 4,533 + 2-1993 + C2 + Slave Labour + Assassin Extraordinaire + + + 4,534 + 2-2019 + B2 + Underpaid + Food Taster for Schools + + + 4,535 + 1-2005 + A2 + Overpaid + Software Developer Extraordinaire + + + 4,536 + 4-1990 + A1 + Massively Overpaid + Assassin for Eternity + + + 4,537 + 7-1991 + B1 + Fairly Paid + Author for Eternity + + + 4,538 + 7-1997 + A1 + Massively Overpaid + Vigilante Trainer + + + 4,539 + 10-1999 + A2 + Overpaid + Author Trainer + + + 4,540 + 4-2004 + A1 + Massively Overpaid + Historian for Eternity + + + 4,541 + 5-1995 + B2 + Underpaid + Vigilante in Chief + + + 4,542 + 4-1991 + A2 + Overpaid + Historian of Cattle + + + 4,543 + 7-1999 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 4,544 + 6-2003 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 4,545 + 3-2016 + A2 + Overpaid + Builder in Chief + + + 4,546 + 11-1990 + A1 + Massively Overpaid + Historian of Cattle + + + 4,547 + 11-1998 + A2 + Overpaid + Historian of Parties + + + 4,548 + 12-2017 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 4,549 + 6-2002 + B2 + Underpaid + Sports Mascot of Cattle + + + 4,550 + 1-2007 + B2 + Underpaid + Philosopher Trainer + + + 4,551 + 7-2019 + C1 + Massively Underpaid + Software Developer for Eternity + + + 4,552 + 7-2022 + C2 + Slave Labour + Historian of Parties + + + 4,553 + 3-2018 + C2 + Slave Labour + Vigilante Trainer + + + 4,554 + 10-2014 + C1 + Massively Underpaid + Assassin Laureate + + + 4,555 + 8-2017 + B2 + Underpaid + Vigilante of Parties + + + 4,556 + 6-1995 + C2 + Slave Labour + Philosopher of Cattle + + + 4,557 + 4-2018 + A2 + Overpaid + Sports Mascot of Parties + + + 4,558 + 2-1990 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 4,559 + 4-2016 + C1 + Massively Underpaid + Author of Parties + + + 4,560 + 6-2000 + A1 + Massively Overpaid + Builder Trainer + + + 4,561 + 12-2022 + C1 + Massively Underpaid + Software Developer Laureate + + + 4,562 + 7-2006 + C1 + Massively Underpaid + Builder for the Environment + + + 4,563 + 5-2013 + B1 + Fairly Paid + Historian of Cattle + + + 4,564 + 2-2013 + B2 + Underpaid + Vigilante of Cattle + + + 4,565 + 12-2008 + C2 + Slave Labour + Software Developer of Parties + + + 4,566 + 6-2019 + B2 + Underpaid + Author for the Environment + + + 4,567 + 7-2000 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 4,568 + 11-1994 + B1 + Fairly Paid + Philosopher of Doom + + + 4,569 + 7-1994 + B1 + Fairly Paid + Sports Mascot Trainer + + + 4,570 + 3-2012 + C2 + Slave Labour + Sports Mascot Trainer + + + 4,571 + 8-1997 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 4,572 + 6-2002 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 4,573 + 8-2012 + C2 + Slave Labour + Food Taster for the Environment + + + 4,574 + 8-2000 + B1 + Fairly Paid + Philosopher in Chief + + + 4,575 + 11-1990 + C1 + Massively Underpaid + Software Developer for Schools + + + 4,576 + 11-2014 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 4,577 + 11-2017 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 4,578 + 2-1993 + B2 + Underpaid + Food Taster (Trainee) + + + 4,579 + 9-2008 + A1 + Massively Overpaid + Vigilante Laureate + + + 4,580 + 2-2007 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 4,581 + 5-1992 + C2 + Slave Labour + Philosopher for the Environment + + + 4,582 + 4-1998 + B2 + Underpaid + Philosopher for Schools + + + 4,583 + 12-2022 + B2 + Underpaid + Skydiving Instructor Trainer + + + 4,584 + 5-2021 + C2 + Slave Labour + Software Developer Laureate + + + 4,585 + 10-1998 + C1 + Massively Underpaid + Vigilante of Parties + + + 4,586 + 9-2021 + A2 + Overpaid + Philosopher Trainer + + + 4,587 + 7-2015 + A2 + Overpaid + Philosopher in Chief + + + 4,588 + 12-2010 + B2 + Underpaid + Software Developer for the Environment + + + 4,589 + 10-2001 + B1 + Fairly Paid + Assassin for the Environment + + + 4,590 + 4-1991 + B2 + Underpaid + Philosopher for Schools + + + 4,591 + 11-1993 + B1 + Fairly Paid + Vigilante of Parties + + + 4,592 + 8-2020 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 4,593 + 7-2015 + B2 + Underpaid + Historian of Cattle + + + 4,594 + 11-2000 + C2 + Slave Labour + Sports Mascot of Cattle + + + 4,595 + 6-2019 + A1 + Massively Overpaid + Builder for Eternity + + + 4,596 + 3-1997 + B1 + Fairly Paid + Vigilante (Trainee) + + + 4,597 + 2-1996 + B2 + Underpaid + Skydiving Instructor in Chief + + + 4,598 + 5-1994 + B1 + Fairly Paid + Food Taster for Schools + + + 4,599 + 8-2017 + A2 + Overpaid + Vigilante for Eternity + + + 4,600 + 1-2019 + B1 + Fairly Paid + Builder for Schools + + + 4,601 + 12-2015 + B2 + Underpaid + Builder in Chief + + + 4,602 + 2-1995 + B2 + Underpaid + Builder of Cattle + + + 4,603 + 6-2011 + B1 + Fairly Paid + Vigilante Trainer + + + 4,604 + 5-2020 + B2 + Underpaid + Historian (Trainee) + + + 4,605 + 2-2005 + C2 + Slave Labour + Assassin Laureate + + + 4,606 + 9-2019 + B2 + Underpaid + Assassin Trainer + + + 4,607 + 7-2007 + C1 + Massively Underpaid + Software Developer in Chief + + + 4,608 + 3-2020 + C1 + Massively Underpaid + Food Taster Laureate + + + 4,609 + 1-2018 + B1 + Fairly Paid + Builder for Eternity + + + 4,610 + 4-2007 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 4,611 + 2-2005 + A1 + Massively Overpaid + Author Extraordinaire + + + 4,612 + 2-2002 + A2 + Overpaid + Historian for Schools + + + 4,613 + 4-1991 + A1 + Massively Overpaid + Builder for the Environment + + + 4,614 + 4-2022 + B1 + Fairly Paid + Historian Laureate + + + 4,615 + 12-1995 + B1 + Fairly Paid + Author Extraordinaire + + + 4,616 + 6-2022 + B2 + Underpaid + Software Developer Extraordinaire + + + 4,617 + 3-2004 + C2 + Slave Labour + Author of Doom + + + 4,618 + 12-1999 + B1 + Fairly Paid + Author Extraordinaire + + + 4,619 + 2-2002 + B2 + Underpaid + Assassin of Doom + + + 4,620 + 7-1990 + C2 + Slave Labour + Sports Mascot for Eternity + + + 4,621 + 12-2017 + C2 + Slave Labour + Historian of Cattle + + + 4,622 + 9-2013 + A1 + Massively Overpaid + Food Taster of Cattle + + + 4,623 + 10-2007 + B2 + Underpaid + Author for Schools + + + 4,624 + 2-1993 + C2 + Slave Labour + Food Taster of Parties + + + 4,625 + 10-1995 + C1 + Massively Underpaid + Assassin of Doom + + + 4,626 + 12-2014 + B2 + Underpaid + Assassin of Cattle + + + 4,627 + 6-2016 + B2 + Underpaid + Food Taster in Chief + + + 4,628 + 2-2010 + C1 + Massively Underpaid + Historian (Trainee) + + + 4,629 + 6-2001 + B2 + Underpaid + Historian of Cattle + + + 4,630 + 7-2009 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 4,631 + 2-2022 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 4,632 + 8-2001 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 4,633 + 11-2009 + A2 + Overpaid + Skydiving Instructor in Chief + + + 4,634 + 3-1999 + B1 + Fairly Paid + Skydiving Instructor (Trainee) + + + 4,635 + 1-2013 + A1 + Massively Overpaid + Builder (Trainee) + + + 4,636 + 7-1998 + B1 + Fairly Paid + Philosopher for the Environment + + + 4,637 + 1-2019 + C2 + Slave Labour + Builder for the Environment + + + 4,638 + 10-2009 + C2 + Slave Labour + Software Developer Extraordinaire + + + 4,639 + 12-2022 + B1 + Fairly Paid + Builder for Eternity + + + 4,640 + 5-1994 + C1 + Massively Underpaid + Builder in Chief + + + 4,641 + 10-2002 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 4,642 + 5-1993 + A1 + Massively Overpaid + Author of Cattle + + + 4,643 + 11-2014 + A1 + Massively Overpaid + Software Developer Laureate + + + 4,644 + 2-2007 + C2 + Slave Labour + Food Taster of Parties + + + 4,645 + 9-2020 + A2 + Overpaid + Assassin for Schools + + + 4,646 + 9-2023 + A2 + Overpaid + Philosopher (Trainee) + + + 4,647 + 1-2012 + C2 + Slave Labour + Author for the Environment + + + 4,648 + 4-2011 + A2 + Overpaid + Assassin of Parties + + + 4,649 + 1-2018 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 4,650 + 3-2015 + A2 + Overpaid + Author Laureate + + + 4,651 + 10-2016 + B1 + Fairly Paid + Author of Cattle + + + 4,652 + 5-2006 + C2 + Slave Labour + Vigilante of Doom + + + 4,653 + 9-2021 + A1 + Massively Overpaid + Assassin in Chief + + + 4,654 + 12-2000 + A1 + Massively Overpaid + Historian in Chief + + + 4,655 + 3-2016 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 4,656 + 10-2003 + C1 + Massively Underpaid + Builder of Parties + + + 4,657 + 6-2013 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 4,658 + 2-2000 + C1 + Massively Underpaid + Historian of Doom + + + 4,659 + 6-2003 + C2 + Slave Labour + Vigilante for Eternity + + + 4,660 + 12-1995 + A2 + Overpaid + Vigilante for Eternity + + + 4,661 + 10-2008 + A1 + Massively Overpaid + Author Laureate + + + 4,662 + 12-2015 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 4,663 + 12-2007 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 4,664 + 8-1994 + C1 + Massively Underpaid + Historian for Eternity + + + 4,665 + 8-1998 + C1 + Massively Underpaid + Assassin for Schools + + + 4,666 + 1-2010 + B2 + Underpaid + Philosopher of Doom + + + 4,667 + 1-2012 + B1 + Fairly Paid + Assassin Laureate + + + 4,668 + 1-2012 + B1 + Fairly Paid + Food Taster of Doom + + + 4,669 + 6-2020 + C1 + Massively Underpaid + Assassin for Schools + + + 4,670 + 12-2005 + B2 + Underpaid + Skydiving Instructor for Schools + + + 4,671 + 11-2015 + B1 + Fairly Paid + Vigilante (Trainee) + + + 4,672 + 10-2020 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 4,673 + 2-2023 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 4,674 + 1-1995 + A1 + Massively Overpaid + Builder of Parties + + + 4,675 + 3-1996 + A1 + Massively Overpaid + Software Developer of Doom + + + 4,676 + 4-1991 + A2 + Overpaid + Author for Schools + + + 4,677 + 5-2009 + B2 + Underpaid + Food Taster of Doom + + + 4,678 + 4-2022 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 4,679 + 9-2015 + A2 + Overpaid + Philosopher for the Environment + + + 4,680 + 8-2013 + C1 + Massively Underpaid + Builder of Doom + + + 4,681 + 1-2020 + A2 + Overpaid + Author for the Environment + + + 4,682 + 6-1997 + C1 + Massively Underpaid + Philosopher in Chief + + + 4,683 + 2-1991 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 4,684 + 1-2011 + B2 + Underpaid + Author Trainer + + + 4,685 + 3-2012 + C1 + Massively Underpaid + Assassin for Schools + + + 4,686 + 9-2007 + C2 + Slave Labour + Author for the Environment + + + 4,687 + 8-1998 + C1 + Massively Underpaid + Philosopher of Parties + + + 4,688 + 1-2018 + B1 + Fairly Paid + Assassin Laureate + + + 4,689 + 6-2021 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 4,690 + 9-2022 + C1 + Massively Underpaid + Author of Doom + + + 4,691 + 2-1993 + C2 + Slave Labour + Author in Chief + + + 4,692 + 10-2012 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 4,693 + 3-2020 + A1 + Massively Overpaid + Vigilante of Cattle + + + 4,694 + 9-1999 + A2 + Overpaid + Sports Mascot (Trainee) + + + 4,695 + 2-2007 + A1 + Massively Overpaid + Philosopher of Doom + + + 4,696 + 10-1991 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 4,697 + 6-2006 + B2 + Underpaid + Builder Extraordinaire + + + 4,698 + 1-2012 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 4,699 + 6-2002 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 4,700 + 8-1998 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 4,701 + 1-1990 + A1 + Massively Overpaid + Philosopher in Chief + + + 4,702 + 9-2002 + A1 + Massively Overpaid + Vigilante of Doom + + + 4,703 + 7-2016 + A2 + Overpaid + Historian of Parties + + + 4,704 + 6-1996 + A1 + Massively Overpaid + Assassin in Chief + + + 4,705 + 4-2017 + C1 + Massively Underpaid + Author (Trainee) + + + 4,706 + 4-2013 + B1 + Fairly Paid + Software Developer (Trainee) + + + 4,707 + 8-2005 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 4,708 + 10-1991 + A2 + Overpaid + Sports Mascot of Cattle + + + 4,709 + 8-2012 + B2 + Underpaid + Vigilante of Doom + + + 4,710 + 11-1994 + B2 + Underpaid + Sports Mascot for Schools + + + 4,711 + 6-1991 + A2 + Overpaid + Skydiving Instructor in Chief + + + 4,712 + 9-2011 + C1 + Massively Underpaid + Author of Cattle + + + 4,713 + 5-1996 + B1 + Fairly Paid + Builder of Doom + + + 4,714 + 12-2001 + C1 + Massively Underpaid + Builder for Schools + + + 4,715 + 12-2022 + C2 + Slave Labour + Assassin (Trainee) + + + 4,716 + 7-2000 + B2 + Underpaid + Vigilante in Chief + + + 4,717 + 12-2002 + C1 + Massively Underpaid + Builder of Doom + + + 4,718 + 12-2007 + C1 + Massively Underpaid + Food Taster for Schools + + + 4,719 + 4-2011 + A2 + Overpaid + Philosopher for the Environment + + + 4,720 + 10-1998 + A2 + Overpaid + Philosopher Extraordinaire + + + 4,721 + 7-1992 + A1 + Massively Overpaid + Assassin Laureate + + + 4,722 + 4-2013 + C1 + Massively Underpaid + Author Laureate + + + 4,723 + 2-2023 + C1 + Massively Underpaid + Historian of Doom + + + 4,724 + 10-2017 + A1 + Massively Overpaid + Historian Extraordinaire + + + 4,725 + 5-2005 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 4,726 + 5-2005 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 4,727 + 5-1997 + B2 + Underpaid + Assassin of Parties + + + 4,728 + 6-2002 + A1 + Massively Overpaid + Food Taster for Eternity + + + 4,729 + 5-2010 + A2 + Overpaid + Author (Trainee) + + + 4,730 + 7-2004 + A1 + Massively Overpaid + Author of Parties + + + 4,731 + 7-2020 + C2 + Slave Labour + Software Developer for Schools + + + 4,732 + 6-2005 + C1 + Massively Underpaid + Philosopher Laureate + + + 4,733 + 2-1995 + B1 + Fairly Paid + Historian of Cattle + + + 4,734 + 11-2008 + A2 + Overpaid + Assassin Extraordinaire + + + 4,735 + 4-2003 + B2 + Underpaid + Philosopher in Chief + + + 4,736 + 4-2018 + A2 + Overpaid + Author in Chief + + + 4,737 + 4-1998 + C2 + Slave Labour + Food Taster for Eternity + + + 4,738 + 5-1996 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 4,739 + 7-2020 + A1 + Massively Overpaid + Author of Cattle + + + 4,740 + 1-1999 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 4,741 + 3-1992 + B1 + Fairly Paid + Author Laureate + + + 4,742 + 9-2021 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 4,743 + 10-2011 + C1 + Massively Underpaid + Philosopher Laureate + + + 4,744 + 8-2011 + B1 + Fairly Paid + Builder for Eternity + + + 4,745 + 5-1996 + B1 + Fairly Paid + Builder of Cattle + + + 4,746 + 10-2003 + C2 + Slave Labour + Assassin for the Environment + + + 4,747 + 9-2001 + B1 + Fairly Paid + Builder for Schools + + + 4,748 + 7-2000 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 4,749 + 10-2003 + C1 + Massively Underpaid + Vigilante Laureate + + + 4,750 + 1-1990 + B2 + Underpaid + Software Developer for Eternity + + + 4,751 + 3-2023 + C2 + Slave Labour + Assassin for Schools + + + 4,752 + 2-2015 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 4,753 + 12-2011 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 4,754 + 8-2019 + C1 + Massively Underpaid + Vigilante for the Environment + + + 4,755 + 5-2002 + C2 + Slave Labour + Assassin (Trainee) + + + 4,756 + 6-2002 + C1 + Massively Underpaid + Builder for the Environment + + + 4,757 + 8-2021 + B1 + Fairly Paid + Builder for Schools + + + 4,758 + 8-2023 + B1 + Fairly Paid + Software Developer in Chief + + + 4,759 + 3-2013 + A1 + Massively Overpaid + Builder Trainer + + + 4,760 + 3-2019 + B1 + Fairly Paid + Sports Mascot in Chief + + + 4,761 + 2-2021 + B1 + Fairly Paid + Builder of Doom + + + 4,762 + 2-2019 + B1 + Fairly Paid + Builder of Cattle + + + 4,763 + 8-1995 + A2 + Overpaid + Software Developer of Parties + + + 4,764 + 4-1995 + C2 + Slave Labour + Builder for Eternity + + + 4,765 + 5-1997 + A1 + Massively Overpaid + Software Developer of Parties + + + 4,766 + 9-2001 + A2 + Overpaid + Historian for Eternity + + + 4,767 + 2-1998 + A2 + Overpaid + Vigilante Laureate + + + 4,768 + 12-2022 + A2 + Overpaid + Food Taster of Cattle + + + 4,769 + 6-2012 + A2 + Overpaid + Historian Laureate + + + 4,770 + 9-2008 + B2 + Underpaid + Assassin in Chief + + + 4,771 + 8-2011 + B1 + Fairly Paid + Assassin of Parties + + + 4,772 + 10-2022 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 4,773 + 7-2012 + A2 + Overpaid + Builder of Doom + + + 4,774 + 7-2018 + C1 + Massively Underpaid + Historian Laureate + + + 4,775 + 12-2009 + A2 + Overpaid + Philosopher of Doom + + + 4,776 + 4-2001 + C1 + Massively Underpaid + Software Developer Trainer + + + 4,777 + 12-1998 + A2 + Overpaid + Skydiving Instructor of Doom + + + 4,778 + 4-2016 + B2 + Underpaid + Builder Trainer + + + 4,779 + 1-1996 + C1 + Massively Underpaid + Author of Parties + + + 4,780 + 9-1990 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 4,781 + 3-2020 + C1 + Massively Underpaid + Assassin Laureate + + + 4,782 + 9-2003 + C1 + Massively Underpaid + Vigilante of Cattle + + + 4,783 + 5-2005 + C2 + Slave Labour + Author for Eternity + + + 4,784 + 8-2002 + B1 + Fairly Paid + Builder for Eternity + + + 4,785 + 9-2002 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 4,786 + 1-1998 + B2 + Underpaid + Assassin of Cattle + + + 4,787 + 8-2021 + B2 + Underpaid + Sports Mascot in Chief + + + 4,788 + 8-2014 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 4,789 + 4-2016 + B2 + Underpaid + Vigilante for Eternity + + + 4,790 + 11-2016 + C2 + Slave Labour + Historian in Chief + + + 4,791 + 6-2018 + A1 + Massively Overpaid + Author in Chief + + + 4,792 + 5-2013 + C1 + Massively Underpaid + Assassin for Eternity + + + 4,793 + 4-2014 + B2 + Underpaid + Food Taster for Schools + + + 4,794 + 4-1998 + A2 + Overpaid + Philosopher of Parties + + + 4,795 + 3-2002 + B2 + Underpaid + Software Developer for Eternity + + + 4,796 + 11-2023 + C1 + Massively Underpaid + Author for the Environment + + + 4,797 + 6-2015 + C2 + Slave Labour + Builder Trainer + + + 4,798 + 12-1992 + C2 + Slave Labour + Food Taster for the Environment + + + 4,799 + 9-1997 + A2 + Overpaid + Builder of Cattle + + + 4,800 + 3-2007 + B2 + Underpaid + Philosopher of Doom + + + 4,801 + 11-2005 + A2 + Overpaid + Author of Parties + + + 4,802 + 4-2013 + B1 + Fairly Paid + Historian of Parties + + + 4,803 + 2-1995 + C1 + Massively Underpaid + Assassin of Parties + + + 4,804 + 4-1993 + A2 + Overpaid + Philosopher (Trainee) + + + 4,805 + 3-2004 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 4,806 + 7-1998 + C1 + Massively Underpaid + Author for Eternity + + + 4,807 + 8-2004 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 4,808 + 11-2008 + B1 + Fairly Paid + Assassin (Trainee) + + + 4,809 + 2-2014 + A1 + Massively Overpaid + Philosopher of Cattle + + + 4,810 + 10-1995 + B2 + Underpaid + Author for Eternity + + + 4,811 + 7-2023 + B1 + Fairly Paid + Vigilante Trainer + + + 4,812 + 1-2006 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 4,813 + 12-1991 + A2 + Overpaid + Assassin (Trainee) + + + 4,814 + 7-2011 + A2 + Overpaid + Historian for Eternity + + + 4,815 + 6-2012 + A1 + Massively Overpaid + Philosopher for Eternity + + + 4,816 + 2-2011 + C1 + Massively Underpaid + Vigilante for Eternity + + + 4,817 + 11-1993 + B1 + Fairly Paid + Builder for the Environment + + + 4,818 + 3-1991 + C1 + Massively Underpaid + Philosopher Trainer + + + 4,819 + 10-2017 + C1 + Massively Underpaid + Builder Extraordinaire + + + 4,820 + 2-1997 + B2 + Underpaid + Skydiving Instructor Trainer + + + 4,821 + 1-2005 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 4,822 + 1-2023 + B1 + Fairly Paid + Assassin in Chief + + + 4,823 + 8-2018 + A1 + Massively Overpaid + Philosopher in Chief + + + 4,824 + 2-1999 + B1 + Fairly Paid + Builder Extraordinaire + + + 4,825 + 11-2023 + B1 + Fairly Paid + Builder Laureate + + + 4,826 + 7-2018 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 4,827 + 10-2003 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 4,828 + 9-1992 + A2 + Overpaid + Historian in Chief + + + 4,829 + 4-2014 + B2 + Underpaid + Sports Mascot (Trainee) + + + 4,830 + 7-2018 + B2 + Underpaid + Assassin for Schools + + + 4,831 + 10-1996 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 4,832 + 1-2007 + B2 + Underpaid + Software Developer in Chief + + + 4,833 + 7-2016 + B2 + Underpaid + Author Trainer + + + 4,834 + 2-2008 + A2 + Overpaid + Philosopher (Trainee) + + + 4,835 + 10-2014 + B2 + Underpaid + Historian for Eternity + + + 4,836 + 10-1998 + A1 + Massively Overpaid + Builder for Eternity + + + 4,837 + 10-1998 + B1 + Fairly Paid + Food Taster (Trainee) + + + 4,838 + 12-1995 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 4,839 + 3-2014 + C2 + Slave Labour + Software Developer of Doom + + + 4,840 + 5-1994 + B1 + Fairly Paid + Builder in Chief + + + 4,841 + 8-2014 + A2 + Overpaid + Food Taster of Cattle + + + 4,842 + 4-2012 + A1 + Massively Overpaid + Author of Doom + + + 4,843 + 12-2012 + C1 + Massively Underpaid + Author for the Environment + + + 4,844 + 7-2013 + C1 + Massively Underpaid + Builder Extraordinaire + + + 4,845 + 1-2000 + A1 + Massively Overpaid + Assassin of Doom + + + 4,846 + 4-2010 + B2 + Underpaid + Philosopher Laureate + + + 4,847 + 3-2014 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 4,848 + 4-2020 + B1 + Fairly Paid + Philosopher of Doom + + + 4,849 + 9-1991 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 4,850 + 2-1990 + C1 + Massively Underpaid + Philosopher for Schools + + + 4,851 + 5-2015 + B1 + Fairly Paid + Assassin for Schools + + + 4,852 + 3-2012 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 4,853 + 11-2005 + B1 + Fairly Paid + Software Developer Trainer + + + 4,854 + 6-2005 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 4,855 + 3-2009 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 4,856 + 3-1997 + B1 + Fairly Paid + Builder in Chief + + + 4,857 + 12-1994 + C1 + Massively Underpaid + Food Taster of Doom + + + 4,858 + 4-2002 + C2 + Slave Labour + Author (Trainee) + + + 4,859 + 6-2011 + A1 + Massively Overpaid + Vigilante Laureate + + + 4,860 + 8-2016 + C1 + Massively Underpaid + Vigilante for the Environment + + + 4,861 + 9-1991 + C2 + Slave Labour + Historian (Trainee) + + + 4,862 + 2-2010 + C1 + Massively Underpaid + Vigilante of Cattle + + + 4,863 + 7-2002 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 4,864 + 5-1994 + C1 + Massively Underpaid + Historian of Cattle + + + 4,865 + 9-1991 + B2 + Underpaid + Historian of Cattle + + + 4,866 + 10-2015 + C1 + Massively Underpaid + Historian of Doom + + + 4,867 + 7-2009 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 4,868 + 6-1998 + A2 + Overpaid + Skydiving Instructor Laureate + + + 4,869 + 4-2001 + C2 + Slave Labour + Sports Mascot for Eternity + + + 4,870 + 10-1994 + C1 + Massively Underpaid + Builder Extraordinaire + + + 4,871 + 3-1993 + B1 + Fairly Paid + Food Taster of Parties + + + 4,872 + 11-2012 + A2 + Overpaid + Builder for Schools + + + 4,873 + 9-1999 + A2 + Overpaid + Software Developer of Doom + + + 4,874 + 5-2019 + A1 + Massively Overpaid + Builder for Eternity + + + 4,875 + 3-2022 + B1 + Fairly Paid + Software Developer in Chief + + + 4,876 + 7-2005 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 4,877 + 6-1994 + B1 + Fairly Paid + Historian Trainer + + + 4,878 + 3-2019 + C1 + Massively Underpaid + Historian Trainer + + + 4,879 + 11-2020 + B2 + Underpaid + Skydiving Instructor of Doom + + + 4,880 + 11-2005 + C2 + Slave Labour + Food Taster for Eternity + + + 4,881 + 11-2009 + C1 + Massively Underpaid + Philosopher for Schools + + + 4,882 + 5-1999 + C1 + Massively Underpaid + Historian for the Environment + + + 4,883 + 6-1990 + B2 + Underpaid + Skydiving Instructor Trainer + + + 4,884 + 2-2014 + A1 + Massively Overpaid + Food Taster for Eternity + + + 4,885 + 9-1994 + C1 + Massively Underpaid + Philosopher for Eternity + + + 4,886 + 2-1993 + B1 + Fairly Paid + Vigilante for Schools + + + 4,887 + 10-2000 + C2 + Slave Labour + Software Developer for Schools + + + 4,888 + 2-2022 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 4,889 + 6-2022 + A2 + Overpaid + Software Developer for Schools + + + 4,890 + 6-2001 + B2 + Underpaid + Food Taster of Parties + + + 4,891 + 2-2021 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 4,892 + 2-2004 + B2 + Underpaid + Philosopher of Doom + + + 4,893 + 3-2018 + B1 + Fairly Paid + Food Taster of Cattle + + + 4,894 + 9-2019 + B2 + Underpaid + Philosopher for Schools + + + 4,895 + 10-2019 + A2 + Overpaid + Author Laureate + + + 4,896 + 8-1992 + C2 + Slave Labour + Historian Laureate + + + 4,897 + 9-2017 + B2 + Underpaid + Assassin for Eternity + + + 4,898 + 1-2000 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 4,899 + 10-2001 + B2 + Underpaid + Assassin of Doom + + + 4,900 + 11-2013 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 4,901 + 8-2021 + A1 + Massively Overpaid + Philosopher Laureate + + + 4,902 + 5-1990 + C1 + Massively Underpaid + Food Taster of Parties + + + 4,903 + 2-2016 + A2 + Overpaid + Builder for Schools + + + 4,904 + 8-2002 + C1 + Massively Underpaid + Food Taster for Eternity + + + 4,905 + 1-2015 + C1 + Massively Underpaid + Historian of Cattle + + + 4,906 + 10-1992 + A1 + Massively Overpaid + Assassin Laureate + + + 4,907 + 10-2013 + B2 + Underpaid + Author of Doom + + + 4,908 + 1-2008 + B2 + Underpaid + Vigilante for the Environment + + + 4,909 + 3-2018 + C2 + Slave Labour + Author for the Environment + + + 4,910 + 3-2015 + B2 + Underpaid + Historian of Doom + + + 4,911 + 9-2015 + A2 + Overpaid + Sports Mascot Trainer + + + 4,912 + 6-2010 + A2 + Overpaid + Philosopher Laureate + + + 4,913 + 6-2000 + B1 + Fairly Paid + Historian Laureate + + + 4,914 + 5-2019 + C1 + Massively Underpaid + Historian Laureate + + + 4,915 + 4-2002 + A1 + Massively Overpaid + Historian in Chief + + + 4,916 + 9-2016 + B1 + Fairly Paid + Assassin of Parties + + + 4,917 + 9-2016 + B2 + Underpaid + Sports Mascot for Eternity + + + 4,918 + 2-2011 + B2 + Underpaid + Assassin Extraordinaire + + + 4,919 + 6-2007 + C2 + Slave Labour + Software Developer of Doom + + + 4,920 + 3-2004 + A2 + Overpaid + Builder for the Environment + + + 4,921 + 2-1990 + C1 + Massively Underpaid + Food Taster Laureate + + + 4,922 + 7-2022 + A1 + Massively Overpaid + Assassin of Doom + + + 4,923 + 7-2004 + A1 + Massively Overpaid + Software Developer for the Environment + + + 4,924 + 1-2003 + C1 + Massively Underpaid + Builder for the Environment + + + 4,925 + 4-2016 + C2 + Slave Labour + Builder for Eternity + + + 4,926 + 1-2013 + B1 + Fairly Paid + Historian for Eternity + + + 4,927 + 10-2012 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 4,928 + 1-1999 + C2 + Slave Labour + Philosopher of Cattle + + + 4,929 + 12-2001 + B2 + Underpaid + Assassin of Cattle + + + 4,930 + 4-2008 + B2 + Underpaid + Software Developer in Chief + + + 4,931 + 7-1997 + C1 + Massively Underpaid + Historian Extraordinaire + + + 4,932 + 8-2016 + B2 + Underpaid + Historian Laureate + + + 4,933 + 8-1996 + A2 + Overpaid + Skydiving Instructor Laureate + + + 4,934 + 2-1994 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 4,935 + 3-2009 + B1 + Fairly Paid + Philosopher for Eternity + + + 4,936 + 9-1994 + B1 + Fairly Paid + Food Taster for Eternity + + + 4,937 + 5-2013 + C2 + Slave Labour + Assassin Trainer + + + 4,938 + 7-1997 + A1 + Massively Overpaid + Assassin Trainer + + + 4,939 + 7-1999 + B1 + Fairly Paid + Assassin Trainer + + + 4,940 + 9-2000 + C2 + Slave Labour + Builder (Trainee) + + + 4,941 + 12-2016 + C1 + Massively Underpaid + Philosopher for the Environment + + + 4,942 + 3-1998 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 4,943 + 3-2015 + B2 + Underpaid + Sports Mascot in Chief + + + 4,944 + 2-2017 + A2 + Overpaid + Assassin Trainer + + + 4,945 + 2-1996 + A1 + Massively Overpaid + Food Taster for the Environment + + + 4,946 + 2-1996 + B2 + Underpaid + Assassin of Doom + + + 4,947 + 9-2001 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 4,948 + 7-2022 + A2 + Overpaid + Software Developer for Schools + + + 4,949 + 5-2003 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 4,950 + 1-2018 + C2 + Slave Labour + Sports Mascot of Parties + + + 4,951 + 1-1997 + A1 + Massively Overpaid + Philosopher of Parties + + + 4,952 + 1-2018 + C2 + Slave Labour + Software Developer Trainer + + + 4,953 + 11-1993 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 4,954 + 9-2001 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 4,955 + 5-2007 + A2 + Overpaid + Historian of Parties + + + 4,956 + 3-1990 + B1 + Fairly Paid + Assassin for the Environment + + + 4,957 + 2-1994 + B2 + Underpaid + Software Developer for Eternity + + + 4,958 + 8-2014 + C1 + Massively Underpaid + Author of Doom + + + 4,959 + 7-2015 + C2 + Slave Labour + Food Taster Trainer + + + 4,960 + 5-2014 + C2 + Slave Labour + Sports Mascot Trainer + + + 4,961 + 1-1999 + B2 + Underpaid + Food Taster Trainer + + + 4,962 + 12-2003 + B2 + Underpaid + Food Taster for Schools + + + 4,963 + 10-2008 + C2 + Slave Labour + Sports Mascot of Doom + + + 4,964 + 11-2023 + C2 + Slave Labour + Assassin Extraordinaire + + + 4,965 + 6-2007 + A2 + Overpaid + Historian (Trainee) + + + 4,966 + 8-2018 + B1 + Fairly Paid + Vigilante of Doom + + + 4,967 + 2-2001 + C1 + Massively Underpaid + Philosopher of Parties + + + 4,968 + 6-2014 + A2 + Overpaid + Historian of Doom + + + 4,969 + 4-2018 + C2 + Slave Labour + Assassin Extraordinaire + + + 4,970 + 12-2015 + C1 + Massively Underpaid + Philosopher of Parties + + + 4,971 + 10-2021 + C2 + Slave Labour + Builder for Eternity + + + 4,972 + 3-1994 + A1 + Massively Overpaid + Software Developer for the Environment + + + 4,973 + 1-2020 + C2 + Slave Labour + Historian of Cattle + + + 4,974 + 4-2013 + B2 + Underpaid + Food Taster of Cattle + + + 4,975 + 4-2005 + A1 + Massively Overpaid + Author Extraordinaire + + + 4,976 + 11-2021 + C2 + Slave Labour + Philosopher in Chief + + + 4,977 + 5-1997 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 4,978 + 12-2018 + A2 + Overpaid + Philosopher for Eternity + + + 4,979 + 6-1994 + A1 + Massively Overpaid + Food Taster for Schools + + + 4,980 + 11-1995 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 4,981 + 11-2004 + B1 + Fairly Paid + Software Developer Trainer + + + 4,982 + 11-2015 + C2 + Slave Labour + Builder for Schools + + + 4,983 + 5-2004 + A2 + Overpaid + Vigilante Laureate + + + 4,984 + 5-2016 + B1 + Fairly Paid + Historian for Eternity + + + 4,985 + 1-1997 + B1 + Fairly Paid + Historian of Doom + + + 4,986 + 5-2022 + C2 + Slave Labour + Historian of Doom + + + 4,987 + 11-2015 + A2 + Overpaid + Builder Extraordinaire + + + 4,988 + 6-2022 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 4,989 + 2-2005 + B2 + Underpaid + Historian of Parties + + + 4,990 + 1-2017 + C1 + Massively Underpaid + Historian for Schools + + + 4,991 + 5-2003 + A2 + Overpaid + Vigilante Laureate + + + 4,992 + 3-2007 + B2 + Underpaid + Software Developer for Schools + + + 4,993 + 4-2000 + B2 + Underpaid + Author of Cattle + + + 4,994 + 4-1997 + B1 + Fairly Paid + Software Developer of Parties + + + 4,995 + 6-2007 + B1 + Fairly Paid + Author for the Environment + + + 4,996 + 11-2022 + C1 + Massively Underpaid + Food Taster of Parties + + + 4,997 + 4-1993 + C1 + Massively Underpaid + Builder for the Environment + + + 4,998 + 8-2011 + B2 + Underpaid + Food Taster Extraordinaire + + + 4,999 + 7-1998 + C1 + Massively Underpaid + Software Developer for Eternity + + + 5,000 + 6-2007 + A2 + Overpaid + Software Developer Extraordinaire + + + 5,001 + 3-2016 + B1 + Fairly Paid + Vigilante of Parties + + + 5,002 + 8-2008 + A2 + Overpaid + Author in Chief + + + 5,003 + 8-2017 + B1 + Fairly Paid + Vigilante for Eternity + + + 5,004 + 7-2019 + C1 + Massively Underpaid + Software Developer of Cattle + + + 5,005 + 2-2023 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 5,006 + 8-2021 + B1 + Fairly Paid + Philosopher (Trainee) + + + 5,007 + 5-1999 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 5,008 + 12-2000 + C1 + Massively Underpaid + Builder Laureate + + + 5,009 + 10-2018 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 5,010 + 11-1999 + A1 + Massively Overpaid + Historian Trainer + + + 5,011 + 10-2003 + A1 + Massively Overpaid + Philosopher for the Environment + + + 5,012 + 3-2011 + B2 + Underpaid + Builder Laureate + + + 5,013 + 5-2002 + C1 + Massively Underpaid + Builder of Parties + + + 5,014 + 12-2004 + B1 + Fairly Paid + Assassin of Cattle + + + 5,015 + 12-2020 + B1 + Fairly Paid + Author for Eternity + + + 5,016 + 2-2005 + C2 + Slave Labour + Food Taster Laureate + + + 5,017 + 1-2008 + B2 + Underpaid + Software Developer for the Environment + + + 5,018 + 11-2007 + B2 + Underpaid + Historian for Eternity + + + 5,019 + 6-2023 + C1 + Massively Underpaid + Assassin for Schools + + + 5,020 + 1-2008 + A1 + Massively Overpaid + Software Developer of Parties + + + 5,021 + 4-2000 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 5,022 + 9-2013 + C2 + Slave Labour + Builder for Schools + + + 5,023 + 4-1992 + C1 + Massively Underpaid + Author of Parties + + + 5,024 + 8-1998 + B2 + Underpaid + Philosopher for Eternity + + + 5,025 + 6-2019 + C2 + Slave Labour + Builder Extraordinaire + + + 5,026 + 2-2021 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 5,027 + 4-1991 + A2 + Overpaid + Food Taster for the Environment + + + 5,028 + 3-2000 + A1 + Massively Overpaid + Philosopher of Parties + + + 5,029 + 8-1998 + C2 + Slave Labour + Software Developer for Eternity + + + 5,030 + 4-2018 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 5,031 + 6-1990 + A2 + Overpaid + Food Taster Laureate + + + 5,032 + 5-2012 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 5,033 + 1-1992 + A1 + Massively Overpaid + Software Developer Trainer + + + 5,034 + 12-1992 + A2 + Overpaid + Historian of Cattle + + + 5,035 + 5-2002 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 5,036 + 9-2012 + A2 + Overpaid + Vigilante Extraordinaire + + + 5,037 + 2-1993 + B1 + Fairly Paid + Builder for Schools + + + 5,038 + 2-2012 + A1 + Massively Overpaid + Food Taster for the Environment + + + 5,039 + 12-2015 + B2 + Underpaid + Vigilante of Doom + + + 5,040 + 2-1992 + A2 + Overpaid + Software Developer Extraordinaire + + + 5,041 + 6-1997 + A1 + Massively Overpaid + Builder Extraordinaire + + + 5,042 + 2-1997 + C2 + Slave Labour + Historian of Cattle + + + 5,043 + 10-2010 + C1 + Massively Underpaid + Author of Cattle + + + 5,044 + 6-2002 + A2 + Overpaid + Assassin Extraordinaire + + + 5,045 + 12-2009 + A2 + Overpaid + Philosopher Extraordinaire + + + 5,046 + 9-2015 + C2 + Slave Labour + Sports Mascot of Doom + + + 5,047 + 12-2007 + C1 + Massively Underpaid + Assassin (Trainee) + + + 5,048 + 5-2014 + A2 + Overpaid + Software Developer for the Environment + + + 5,049 + 11-2001 + B1 + Fairly Paid + Vigilante Laureate + + + 5,050 + 10-2004 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 5,051 + 2-2004 + B1 + Fairly Paid + Software Developer of Cattle + + + 5,052 + 7-2003 + B2 + Underpaid + Sports Mascot of Doom + + + 5,053 + 8-2006 + B1 + Fairly Paid + Builder for Schools + + + 5,054 + 10-2017 + B1 + Fairly Paid + Vigilante of Doom + + + 5,055 + 1-2001 + C1 + Massively Underpaid + Vigilante Trainer + + + 5,056 + 7-2017 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 5,057 + 10-1994 + C2 + Slave Labour + Historian for Schools + + + 5,058 + 8-1993 + C2 + Slave Labour + Software Developer Extraordinaire + + + 5,059 + 11-2005 + C2 + Slave Labour + Software Developer of Cattle + + + 5,060 + 5-2009 + A1 + Massively Overpaid + Vigilante of Cattle + + + 5,061 + 11-2004 + B1 + Fairly Paid + Builder in Chief + + + 5,062 + 2-2015 + A1 + Massively Overpaid + Philosopher of Parties + + + 5,063 + 3-2023 + C2 + Slave Labour + Food Taster (Trainee) + + + 5,064 + 9-2009 + A2 + Overpaid + Philosopher for Eternity + + + 5,065 + 3-2006 + B2 + Underpaid + Philosopher of Doom + + + 5,066 + 12-1994 + B2 + Underpaid + Sports Mascot Laureate + + + 5,067 + 12-2022 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 5,068 + 2-2010 + C1 + Massively Underpaid + Software Developer for the Environment + + + 5,069 + 7-2012 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 5,070 + 3-2013 + B2 + Underpaid + Vigilante Extraordinaire + + + 5,071 + 7-1991 + C1 + Massively Underpaid + Food Taster in Chief + + + 5,072 + 4-1993 + B2 + Underpaid + Builder of Cattle + + + 5,073 + 3-2021 + C2 + Slave Labour + Food Taster for Schools + + + 5,074 + 6-2011 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 5,075 + 6-2017 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 5,076 + 4-2015 + A1 + Massively Overpaid + Philosopher of Cattle + + + 5,077 + 12-2006 + B1 + Fairly Paid + Assassin Laureate + + + 5,078 + 5-2007 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 5,079 + 4-2003 + B1 + Fairly Paid + Historian for the Environment + + + 5,080 + 6-1999 + C1 + Massively Underpaid + Author for the Environment + + + 5,081 + 1-1993 + B2 + Underpaid + Builder for Eternity + + + 5,082 + 4-1996 + A2 + Overpaid + Philosopher (Trainee) + + + 5,083 + 6-1997 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 5,084 + 2-2007 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 5,085 + 2-2004 + C1 + Massively Underpaid + Builder in Chief + + + 5,086 + 7-2014 + A1 + Massively Overpaid + Software Developer Laureate + + + 5,087 + 10-2009 + B1 + Fairly Paid + Philosopher Trainer + + + 5,088 + 1-1990 + C2 + Slave Labour + Vigilante for Schools + + + 5,089 + 9-2007 + A1 + Massively Overpaid + Software Developer Laureate + + + 5,090 + 3-2007 + B1 + Fairly Paid + Philosopher of Doom + + + 5,091 + 4-2020 + B1 + Fairly Paid + Assassin of Parties + + + 5,092 + 8-2000 + B1 + Fairly Paid + Historian for the Environment + + + 5,093 + 1-1998 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 5,094 + 9-2019 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 5,095 + 7-1995 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 5,096 + 7-2002 + A2 + Overpaid + Author Trainer + + + 5,097 + 3-2022 + C2 + Slave Labour + Food Taster Trainer + + + 5,098 + 12-2022 + C1 + Massively Underpaid + Software Developer for the Environment + + + 5,099 + 11-2008 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 5,100 + 4-2010 + A1 + Massively Overpaid + Vigilante for Eternity + + + 5,101 + 7-2010 + C2 + Slave Labour + Author Laureate + + + 5,102 + 9-1997 + A1 + Massively Overpaid + Historian in Chief + + + 5,103 + 1-1999 + A2 + Overpaid + Assassin (Trainee) + + + 5,104 + 6-2015 + C2 + Slave Labour + Vigilante of Parties + + + 5,105 + 8-2019 + B1 + Fairly Paid + Vigilante Laureate + + + 5,106 + 3-2000 + C1 + Massively Underpaid + Software Developer in Chief + + + 5,107 + 2-2001 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 5,108 + 1-1995 + A1 + Massively Overpaid + Food Taster for Eternity + + + 5,109 + 9-1995 + C2 + Slave Labour + Historian (Trainee) + + + 5,110 + 1-2012 + C1 + Massively Underpaid + Historian Trainer + + + 5,111 + 8-2002 + A1 + Massively Overpaid + Historian of Parties + + + 5,112 + 4-2020 + A2 + Overpaid + Food Taster of Doom + + + 5,113 + 5-2006 + C1 + Massively Underpaid + Author Trainer + + + 5,114 + 7-2007 + A2 + Overpaid + Philosopher for the Environment + + + 5,115 + 7-2011 + C1 + Massively Underpaid + Builder (Trainee) + + + 5,116 + 9-2007 + A1 + Massively Overpaid + Historian for Schools + + + 5,117 + 6-2001 + B1 + Fairly Paid + Historian of Cattle + + + 5,118 + 12-1994 + C1 + Massively Underpaid + Vigilante of Doom + + + 5,119 + 9-2008 + B2 + Underpaid + Builder of Doom + + + 5,120 + 10-2013 + A2 + Overpaid + Author Trainer + + + 5,121 + 2-2001 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 5,122 + 6-2018 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 5,123 + 7-2017 + B2 + Underpaid + Builder Laureate + + + 5,124 + 12-2015 + C1 + Massively Underpaid + Assassin in Chief + + + 5,125 + 10-2006 + B1 + Fairly Paid + Author for Eternity + + + 5,126 + 2-2019 + A1 + Massively Overpaid + Builder for Schools + + + 5,127 + 1-2018 + B1 + Fairly Paid + Builder Laureate + + + 5,128 + 9-1993 + A2 + Overpaid + Historian of Parties + + + 5,129 + 12-1994 + A1 + Massively Overpaid + Vigilante of Parties + + + 5,130 + 4-1992 + A1 + Massively Overpaid + Philosopher of Parties + + + 5,131 + 7-1992 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 5,132 + 4-1995 + A1 + Massively Overpaid + Builder for Schools + + + 5,133 + 3-1993 + B2 + Underpaid + Food Taster Extraordinaire + + + 5,134 + 10-1993 + C1 + Massively Underpaid + Author (Trainee) + + + 5,135 + 5-2015 + C1 + Massively Underpaid + Assassin for Schools + + + 5,136 + 10-2017 + A2 + Overpaid + Builder Trainer + + + 5,137 + 6-2021 + B2 + Underpaid + Assassin Laureate + + + 5,138 + 8-1990 + B1 + Fairly Paid + Author of Cattle + + + 5,139 + 1-2000 + C1 + Massively Underpaid + Assassin of Doom + + + 5,140 + 9-1994 + A1 + Massively Overpaid + Author for the Environment + + + 5,141 + 7-2004 + C2 + Slave Labour + Historian Extraordinaire + + + 5,142 + 11-2018 + B2 + Underpaid + Software Developer Extraordinaire + + + 5,143 + 1-1992 + A1 + Massively Overpaid + Food Taster Laureate + + + 5,144 + 7-2002 + A2 + Overpaid + Food Taster Extraordinaire + + + 5,145 + 7-2012 + C2 + Slave Labour + Sports Mascot of Doom + + + 5,146 + 4-2017 + A2 + Overpaid + Assassin for Schools + + + 5,147 + 9-1991 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 5,148 + 8-2020 + C2 + Slave Labour + Philosopher in Chief + + + 5,149 + 8-1996 + A2 + Overpaid + Historian (Trainee) + + + 5,150 + 10-2004 + B1 + Fairly Paid + Assassin Trainer + + + 5,151 + 5-1994 + C2 + Slave Labour + Food Taster Trainer + + + 5,152 + 1-1991 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 5,153 + 2-2006 + C1 + Massively Underpaid + Historian in Chief + + + 5,154 + 12-2014 + C1 + Massively Underpaid + Author of Cattle + + + 5,155 + 11-2005 + B2 + Underpaid + Software Developer of Cattle + + + 5,156 + 9-2015 + A2 + Overpaid + Sports Mascot Extraordinaire + + + 5,157 + 6-2018 + A2 + Overpaid + Food Taster Laureate + + + 5,158 + 8-2013 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 5,159 + 5-2005 + A2 + Overpaid + Historian of Doom + + + 5,160 + 11-2014 + B2 + Underpaid + Vigilante Laureate + + + 5,161 + 8-2012 + B1 + Fairly Paid + Food Taster (Trainee) + + + 5,162 + 3-1995 + A2 + Overpaid + Philosopher of Parties + + + 5,163 + 11-2005 + B1 + Fairly Paid + Philosopher in Chief + + + 5,164 + 10-1993 + C2 + Slave Labour + Vigilante for Eternity + + + 5,165 + 12-2000 + A1 + Massively Overpaid + Assassin of Parties + + + 5,166 + 9-2007 + B1 + Fairly Paid + Vigilante of Parties + + + 5,167 + 5-2011 + C1 + Massively Underpaid + Assassin of Parties + + + 5,168 + 1-2013 + B2 + Underpaid + Philosopher Laureate + + + 5,169 + 8-2000 + C2 + Slave Labour + Builder for the Environment + + + 5,170 + 3-1995 + B2 + Underpaid + Food Taster for Schools + + + 5,171 + 11-1992 + C2 + Slave Labour + Vigilante for Eternity + + + 5,172 + 4-2015 + B1 + Fairly Paid + Builder for Schools + + + 5,173 + 2-1998 + A1 + Massively Overpaid + Philosopher Laureate + + + 5,174 + 9-2011 + A1 + Massively Overpaid + Food Taster of Doom + + + 5,175 + 9-1999 + B1 + Fairly Paid + Builder Trainer + + + 5,176 + 8-2000 + B2 + Underpaid + Software Developer of Parties + + + 5,177 + 10-2006 + A1 + Massively Overpaid + Builder in Chief + + + 5,178 + 7-2023 + C1 + Massively Underpaid + Builder (Trainee) + + + 5,179 + 7-1998 + B1 + Fairly Paid + Food Taster for Eternity + + + 5,180 + 6-2019 + B2 + Underpaid + Historian (Trainee) + + + 5,181 + 11-2023 + B2 + Underpaid + Author in Chief + + + 5,182 + 4-1999 + C2 + Slave Labour + Vigilante of Parties + + + 5,183 + 10-2011 + A1 + Massively Overpaid + Vigilante of Parties + + + 5,184 + 6-1998 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 5,185 + 3-2005 + B1 + Fairly Paid + Philosopher for Schools + + + 5,186 + 8-2019 + B2 + Underpaid + Philosopher in Chief + + + 5,187 + 9-2007 + C1 + Massively Underpaid + Historian Extraordinaire + + + 5,188 + 11-2023 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 5,189 + 8-2021 + A1 + Massively Overpaid + Food Taster of Parties + + + 5,190 + 7-1990 + A2 + Overpaid + Builder of Doom + + + 5,191 + 3-1990 + B1 + Fairly Paid + Sports Mascot for Schools + + + 5,192 + 8-2002 + B2 + Underpaid + Philosopher Trainer + + + 5,193 + 11-2012 + A1 + Massively Overpaid + Builder for Schools + + + 5,194 + 10-1995 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 5,195 + 9-2018 + B2 + Underpaid + Historian of Cattle + + + 5,196 + 11-2001 + B1 + Fairly Paid + Philosopher Laureate + + + 5,197 + 12-2012 + B2 + Underpaid + Philosopher Laureate + + + 5,198 + 11-2012 + C1 + Massively Underpaid + Philosopher for the Environment + + + 5,199 + 9-1991 + C1 + Massively Underpaid + Food Taster Laureate + + + 5,200 + 5-2019 + A1 + Massively Overpaid + Author of Doom + + + 5,201 + 3-2011 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 5,202 + 10-2005 + A1 + Massively Overpaid + Author of Cattle + + + 5,203 + 6-2018 + A2 + Overpaid + Assassin for the Environment + + + 5,204 + 10-2007 + C1 + Massively Underpaid + Historian of Doom + + + 5,205 + 6-2011 + A2 + Overpaid + Philosopher for Schools + + + 5,206 + 6-2005 + C2 + Slave Labour + Software Developer for the Environment + + + 5,207 + 10-1992 + B2 + Underpaid + Philosopher Laureate + + + 5,208 + 6-2016 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 5,209 + 6-2003 + C2 + Slave Labour + Builder for the Environment + + + 5,210 + 3-2021 + C1 + Massively Underpaid + Author (Trainee) + + + 5,211 + 2-2003 + B2 + Underpaid + Sports Mascot Laureate + + + 5,212 + 1-2023 + C1 + Massively Underpaid + Author of Parties + + + 5,213 + 1-2009 + A2 + Overpaid + Philosopher for Eternity + + + 5,214 + 10-2019 + A1 + Massively Overpaid + Food Taster of Parties + + + 5,215 + 1-2023 + B2 + Underpaid + Philosopher for Eternity + + + 5,216 + 2-1994 + C2 + Slave Labour + Historian for Eternity + + + 5,217 + 4-2001 + B1 + Fairly Paid + Vigilante of Cattle + + + 5,218 + 3-2021 + B1 + Fairly Paid + Vigilante in Chief + + + 5,219 + 9-1996 + A2 + Overpaid + Historian in Chief + + + 5,220 + 10-2005 + B1 + Fairly Paid + Author for Eternity + + + 5,221 + 6-2014 + C1 + Massively Underpaid + Historian of Doom + + + 5,222 + 3-2021 + A1 + Massively Overpaid + Software Developer Trainer + + + 5,223 + 3-1998 + A2 + Overpaid + Philosopher in Chief + + + 5,224 + 1-2015 + C2 + Slave Labour + Historian of Doom + + + 5,225 + 1-1999 + C2 + Slave Labour + Historian in Chief + + + 5,226 + 9-2014 + A2 + Overpaid + Philosopher Laureate + + + 5,227 + 6-1992 + C2 + Slave Labour + Food Taster Laureate + + + 5,228 + 1-2016 + C1 + Massively Underpaid + Vigilante in Chief + + + 5,229 + 8-2020 + C2 + Slave Labour + Vigilante of Cattle + + + 5,230 + 3-1997 + A2 + Overpaid + Author of Cattle + + + 5,231 + 3-2001 + B1 + Fairly Paid + Builder in Chief + + + 5,232 + 2-2013 + C2 + Slave Labour + Vigilante for the Environment + + + 5,233 + 10-2006 + A2 + Overpaid + Food Taster Trainer + + + 5,234 + 2-2000 + B2 + Underpaid + Sports Mascot for Eternity + + + 5,235 + 4-2023 + B2 + Underpaid + Author for Schools + + + 5,236 + 3-1995 + C1 + Massively Underpaid + Assassin (Trainee) + + + 5,237 + 2-1990 + C1 + Massively Underpaid + Philosopher in Chief + + + 5,238 + 7-2018 + C1 + Massively Underpaid + Food Taster for the Environment + + + 5,239 + 2-2011 + C2 + Slave Labour + Assassin Extraordinaire + + + 5,240 + 7-2015 + A1 + Massively Overpaid + Builder (Trainee) + + + 5,241 + 8-2005 + A1 + Massively Overpaid + Historian of Doom + + + 5,242 + 5-1992 + C1 + Massively Underpaid + Food Taster Trainer + + + 5,243 + 1-1998 + B1 + Fairly Paid + Assassin for the Environment + + + 5,244 + 8-2018 + C1 + Massively Underpaid + Philosopher of Parties + + + 5,245 + 4-1998 + C2 + Slave Labour + Historian Laureate + + + 5,246 + 12-1993 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 5,247 + 8-1996 + B1 + Fairly Paid + Sports Mascot of Doom + + + 5,248 + 5-2018 + B1 + Fairly Paid + Assassin of Doom + + + 5,249 + 7-1998 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 5,250 + 1-2019 + A1 + Massively Overpaid + Philosopher of Doom + + + 5,251 + 10-2012 + C2 + Slave Labour + Philosopher (Trainee) + + + 5,252 + 6-1996 + C2 + Slave Labour + Author of Cattle + + + 5,253 + 3-2015 + A1 + Massively Overpaid + Builder (Trainee) + + + 5,254 + 9-2006 + C2 + Slave Labour + Author of Cattle + + + 5,255 + 5-2015 + B2 + Underpaid + Sports Mascot for the Environment + + + 5,256 + 9-2009 + B2 + Underpaid + Vigilante for Eternity + + + 5,257 + 11-1990 + A2 + Overpaid + Philosopher for Eternity + + + 5,258 + 8-1999 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 5,259 + 9-2004 + B1 + Fairly Paid + Author of Doom + + + 5,260 + 5-2009 + C1 + Massively Underpaid + Vigilante Laureate + + + 5,261 + 12-2013 + A2 + Overpaid + Vigilante Extraordinaire + + + 5,262 + 9-2000 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 5,263 + 1-2019 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 5,264 + 8-2003 + A2 + Overpaid + Author for Eternity + + + 5,265 + 10-2013 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 5,266 + 3-2021 + C2 + Slave Labour + Philosopher of Parties + + + 5,267 + 2-2006 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 5,268 + 7-1990 + A1 + Massively Overpaid + Philosopher for Schools + + + 5,269 + 8-1993 + B1 + Fairly Paid + Assassin of Doom + + + 5,270 + 7-1998 + A2 + Overpaid + Historian Extraordinaire + + + 5,271 + 11-2003 + A1 + Massively Overpaid + Historian (Trainee) + + + 5,272 + 4-2013 + A2 + Overpaid + Vigilante Trainer + + + 5,273 + 7-2001 + A1 + Massively Overpaid + Software Developer of Doom + + + 5,274 + 6-2000 + B2 + Underpaid + Software Developer of Doom + + + 5,275 + 2-2008 + C1 + Massively Underpaid + Author of Cattle + + + 5,276 + 11-1997 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 5,277 + 6-1992 + A1 + Massively Overpaid + Food Taster Laureate + + + 5,278 + 3-2022 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 5,279 + 9-2012 + C2 + Slave Labour + Software Developer of Cattle + + + 5,280 + 10-2017 + A2 + Overpaid + Food Taster in Chief + + + 5,281 + 4-1997 + B2 + Underpaid + Food Taster of Doom + + + 5,282 + 2-1991 + C2 + Slave Labour + Philosopher for Eternity + + + 5,283 + 1-2004 + A1 + Massively Overpaid + Food Taster for Schools + + + 5,284 + 3-2005 + A1 + Massively Overpaid + Historian of Parties + + + 5,285 + 2-1992 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 5,286 + 3-2004 + C1 + Massively Underpaid + Software Developer Trainer + + + 5,287 + 10-2021 + C1 + Massively Underpaid + Builder Trainer + + + 5,288 + 1-1994 + C2 + Slave Labour + Assassin Extraordinaire + + + 5,289 + 1-2015 + A2 + Overpaid + Author for the Environment + + + 5,290 + 2-2018 + C2 + Slave Labour + Sports Mascot of Cattle + + + 5,291 + 11-2011 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 5,292 + 4-2013 + C1 + Massively Underpaid + Software Developer for Schools + + + 5,293 + 3-2021 + A1 + Massively Overpaid + Builder in Chief + + + 5,294 + 9-1996 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 5,295 + 1-1993 + C2 + Slave Labour + Assassin for Schools + + + 5,296 + 9-2017 + C2 + Slave Labour + Builder in Chief + + + 5,297 + 5-2001 + C1 + Massively Underpaid + Author of Doom + + + 5,298 + 4-2003 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 5,299 + 3-2002 + A1 + Massively Overpaid + Philosopher for Eternity + + + 5,300 + 6-2019 + C2 + Slave Labour + Philosopher (Trainee) + + + 5,301 + 10-2006 + B1 + Fairly Paid + Builder in Chief + + + 5,302 + 2-1995 + A2 + Overpaid + Vigilante of Cattle + + + 5,303 + 1-2018 + B1 + Fairly Paid + Food Taster of Cattle + + + 5,304 + 7-1991 + A1 + Massively Overpaid + Author Laureate + + + 5,305 + 4-1996 + C2 + Slave Labour + Author Laureate + + + 5,306 + 3-2021 + C2 + Slave Labour + Sports Mascot for Eternity + + + 5,307 + 6-2005 + C2 + Slave Labour + Software Developer of Doom + + + 5,308 + 6-2007 + B2 + Underpaid + Assassin Trainer + + + 5,309 + 5-2002 + B1 + Fairly Paid + Vigilante for the Environment + + + 5,310 + 6-2016 + A1 + Massively Overpaid + Food Taster of Parties + + + 5,311 + 11-2020 + B1 + Fairly Paid + Builder in Chief + + + 5,312 + 9-1993 + C2 + Slave Labour + Software Developer of Cattle + + + 5,313 + 3-2016 + C2 + Slave Labour + Assassin of Doom + + + 5,314 + 5-1991 + A2 + Overpaid + Software Developer of Parties + + + 5,315 + 10-1990 + B1 + Fairly Paid + Food Taster Trainer + + + 5,316 + 5-2011 + C2 + Slave Labour + Assassin of Parties + + + 5,317 + 8-2002 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 5,318 + 4-2008 + A2 + Overpaid + Author (Trainee) + + + 5,319 + 4-2021 + C1 + Massively Underpaid + Historian Laureate + + + 5,320 + 9-1998 + C2 + Slave Labour + Vigilante Trainer + + + 5,321 + 1-2002 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 5,322 + 12-2017 + B1 + Fairly Paid + Vigilante Laureate + + + 5,323 + 8-2014 + A2 + Overpaid + Philosopher Trainer + + + 5,324 + 4-1994 + A1 + Massively Overpaid + Builder Trainer + + + 5,325 + 11-1992 + A1 + Massively Overpaid + Philosopher Laureate + + + 5,326 + 4-2013 + A1 + Massively Overpaid + Philosopher in Chief + + + 5,327 + 9-2015 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 5,328 + 12-2002 + B2 + Underpaid + Food Taster for Schools + + + 5,329 + 6-1995 + B2 + Underpaid + Assassin for the Environment + + + 5,330 + 2-2007 + B1 + Fairly Paid + Philosopher Trainer + + + 5,331 + 12-2014 + C1 + Massively Underpaid + Vigilante in Chief + + + 5,332 + 4-1996 + B1 + Fairly Paid + Food Taster for the Environment + + + 5,333 + 1-2016 + B2 + Underpaid + Historian Extraordinaire + + + 5,334 + 4-2006 + C1 + Massively Underpaid + Vigilante Laureate + + + 5,335 + 10-2009 + B2 + Underpaid + Vigilante for Eternity + + + 5,336 + 4-2013 + C1 + Massively Underpaid + Vigilante of Doom + + + 5,337 + 6-2010 + C1 + Massively Underpaid + Food Taster Trainer + + + 5,338 + 3-2009 + B1 + Fairly Paid + Author for the Environment + + + 5,339 + 12-2017 + A2 + Overpaid + Historian of Cattle + + + 5,340 + 5-2007 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 5,341 + 2-2015 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 5,342 + 5-2000 + C2 + Slave Labour + Philosopher (Trainee) + + + 5,343 + 1-1995 + C2 + Slave Labour + Builder Laureate + + + 5,344 + 7-1997 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 5,345 + 1-1992 + C1 + Massively Underpaid + Food Taster of Doom + + + 5,346 + 11-2016 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 5,347 + 5-1994 + C1 + Massively Underpaid + Philosopher for Eternity + + + 5,348 + 4-2005 + B1 + Fairly Paid + Vigilante for Eternity + + + 5,349 + 2-2001 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 5,350 + 5-1996 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 5,351 + 9-2013 + B1 + Fairly Paid + Food Taster of Doom + + + 5,352 + 7-2020 + C2 + Slave Labour + Builder of Cattle + + + 5,353 + 5-2019 + C1 + Massively Underpaid + Author for Eternity + + + 5,354 + 2-1990 + A2 + Overpaid + Sports Mascot in Chief + + + 5,355 + 3-1996 + C2 + Slave Labour + Author of Parties + + + 5,356 + 11-2017 + C2 + Slave Labour + Philosopher Extraordinaire + + + 5,357 + 11-2009 + A1 + Massively Overpaid + Author Trainer + + + 5,358 + 12-2018 + B2 + Underpaid + Vigilante of Doom + + + 5,359 + 8-1991 + C2 + Slave Labour + Philosopher of Cattle + + + 5,360 + 11-1998 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 5,361 + 6-2007 + B2 + Underpaid + Philosopher of Doom + + + 5,362 + 10-2008 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 5,363 + 9-1993 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 5,364 + 1-2002 + C2 + Slave Labour + Author of Parties + + + 5,365 + 2-1990 + B2 + Underpaid + Vigilante for Eternity + + + 5,366 + 10-2003 + A2 + Overpaid + Food Taster of Parties + + + 5,367 + 7-1990 + B2 + Underpaid + Historian of Parties + + + 5,368 + 6-2014 + C1 + Massively Underpaid + Author for Schools + + + 5,369 + 9-2007 + A2 + Overpaid + Philosopher for Eternity + + + 5,370 + 12-2010 + C2 + Slave Labour + Food Taster (Trainee) + + + 5,371 + 10-2011 + B2 + Underpaid + Vigilante for the Environment + + + 5,372 + 8-1992 + C1 + Massively Underpaid + Historian for Schools + + + 5,373 + 9-1991 + B2 + Underpaid + Philosopher for Eternity + + + 5,374 + 9-1993 + A1 + Massively Overpaid + Software Developer for the Environment + + + 5,375 + 6-2005 + A1 + Massively Overpaid + Philosopher of Parties + + + 5,376 + 12-1997 + C2 + Slave Labour + Food Taster of Parties + + + 5,377 + 9-2018 + B1 + Fairly Paid + Philosopher in Chief + + + 5,378 + 8-1996 + C1 + Massively Underpaid + Food Taster of Parties + + + 5,379 + 1-2015 + A2 + Overpaid + Philosopher of Parties + + + 5,380 + 8-1999 + A1 + Massively Overpaid + Food Taster Trainer + + + 5,381 + 10-2002 + B2 + Underpaid + Historian for the Environment + + + 5,382 + 12-1999 + C1 + Massively Underpaid + Builder Laureate + + + 5,383 + 12-2012 + A1 + Massively Overpaid + Assassin of Cattle + + + 5,384 + 2-2010 + A1 + Massively Overpaid + Author Laureate + + + 5,385 + 1-2022 + B1 + Fairly Paid + Assassin for the Environment + + + 5,386 + 4-2008 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 5,387 + 5-2022 + C1 + Massively Underpaid + Software Developer for Schools + + + 5,388 + 7-2015 + B2 + Underpaid + Skydiving Instructor of Parties + + + 5,389 + 10-2004 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 5,390 + 4-1991 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 5,391 + 6-2005 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 5,392 + 6-2021 + C2 + Slave Labour + Philosopher for the Environment + + + 5,393 + 3-1994 + B1 + Fairly Paid + Philosopher Trainer + + + 5,394 + 1-2021 + A2 + Overpaid + Assassin of Cattle + + + 5,395 + 1-2022 + B1 + Fairly Paid + Philosopher of Cattle + + + 5,396 + 9-2022 + A1 + Massively Overpaid + Philosopher Trainer + + + 5,397 + 11-1994 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 5,398 + 6-2020 + B2 + Underpaid + Skydiving Instructor of Parties + + + 5,399 + 5-1994 + B2 + Underpaid + Sports Mascot of Cattle + + + 5,400 + 6-1993 + C2 + Slave Labour + Historian of Cattle + + + 5,401 + 6-2022 + B2 + Underpaid + Philosopher (Trainee) + + + 5,402 + 8-2005 + A1 + Massively Overpaid + Historian of Doom + + + 5,403 + 5-2021 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 5,404 + 7-1992 + B1 + Fairly Paid + Vigilante Trainer + + + 5,405 + 10-2014 + A1 + Massively Overpaid + Vigilante for Eternity + + + 5,406 + 8-1995 + C1 + Massively Underpaid + Builder Trainer + + + 5,407 + 7-2013 + A2 + Overpaid + Skydiving Instructor of Parties + + + 5,408 + 3-2001 + C1 + Massively Underpaid + Builder Trainer + + + 5,409 + 12-1998 + C2 + Slave Labour + Builder (Trainee) + + + 5,410 + 4-1997 + A1 + Massively Overpaid + Vigilante in Chief + + + 5,411 + 10-1995 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 5,412 + 1-2000 + A2 + Overpaid + Food Taster in Chief + + + 5,413 + 5-2017 + C1 + Massively Underpaid + Vigilante of Parties + + + 5,414 + 8-2008 + A2 + Overpaid + Builder Extraordinaire + + + 5,415 + 9-2017 + C2 + Slave Labour + Sports Mascot for the Environment + + + 5,416 + 4-1998 + C1 + Massively Underpaid + Vigilante of Doom + + + 5,417 + 8-2003 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 5,418 + 10-2004 + B1 + Fairly Paid + Assassin of Parties + + + 5,419 + 10-2001 + C2 + Slave Labour + Builder of Doom + + + 5,420 + 10-2001 + A2 + Overpaid + Builder for Schools + + + 5,421 + 6-2009 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 5,422 + 1-2016 + A2 + Overpaid + Historian Extraordinaire + + + 5,423 + 12-2020 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 5,424 + 2-2012 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 5,425 + 9-1998 + B1 + Fairly Paid + Food Taster for Schools + + + 5,426 + 10-2021 + A2 + Overpaid + Vigilante for Eternity + + + 5,427 + 5-1990 + C1 + Massively Underpaid + Assassin of Cattle + + + 5,428 + 8-1999 + C2 + Slave Labour + Historian of Parties + + + 5,429 + 9-2008 + C2 + Slave Labour + Food Taster Extraordinaire + + + 5,430 + 7-2014 + C2 + Slave Labour + Assassin of Doom + + + 5,431 + 7-2012 + A2 + Overpaid + Assassin of Doom + + + 5,432 + 3-2010 + C2 + Slave Labour + Author of Cattle + + + 5,433 + 4-2020 + B1 + Fairly Paid + Assassin Extraordinaire + + + 5,434 + 9-1997 + C2 + Slave Labour + Philosopher for Schools + + + 5,435 + 1-2023 + A2 + Overpaid + Vigilante for the Environment + + + 5,436 + 2-2004 + B2 + Underpaid + Food Taster for Schools + + + 5,437 + 9-2013 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 5,438 + 4-2013 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 5,439 + 5-2021 + C1 + Massively Underpaid + Assassin for the Environment + + + 5,440 + 5-2003 + B2 + Underpaid + Vigilante Laureate + + + 5,441 + 5-1994 + C2 + Slave Labour + Builder of Parties + + + 5,442 + 10-2018 + B1 + Fairly Paid + Author Trainer + + + 5,443 + 9-2016 + B1 + Fairly Paid + Software Developer Trainer + + + 5,444 + 8-1996 + B2 + Underpaid + Software Developer of Cattle + + + 5,445 + 11-1996 + C2 + Slave Labour + Author of Doom + + + 5,446 + 1-1993 + B1 + Fairly Paid + Builder of Doom + + + 5,447 + 9-2008 + B1 + Fairly Paid + Sports Mascot for Schools + + + 5,448 + 5-2000 + C2 + Slave Labour + Software Developer for Schools + + + 5,449 + 2-1995 + B1 + Fairly Paid + Food Taster of Doom + + + 5,450 + 3-1995 + C1 + Massively Underpaid + Historian of Doom + + + 5,451 + 9-1996 + A2 + Overpaid + Assassin Extraordinaire + + + 5,452 + 6-2016 + C2 + Slave Labour + Philosopher in Chief + + + 5,453 + 9-1999 + A2 + Overpaid + Software Developer of Doom + + + 5,454 + 5-2013 + B2 + Underpaid + Sports Mascot Laureate + + + 5,455 + 12-2004 + C2 + Slave Labour + Assassin for the Environment + + + 5,456 + 3-2020 + C1 + Massively Underpaid + Software Developer for Schools + + + 5,457 + 11-1990 + B2 + Underpaid + Historian of Parties + + + 5,458 + 8-1996 + A2 + Overpaid + Assassin for the Environment + + + 5,459 + 9-2015 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 5,460 + 10-2020 + C1 + Massively Underpaid + Builder for the Environment + + + 5,461 + 4-2013 + B2 + Underpaid + Food Taster of Cattle + + + 5,462 + 2-2002 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 5,463 + 4-1996 + A2 + Overpaid + Sports Mascot for Eternity + + + 5,464 + 6-1995 + B2 + Underpaid + Author of Cattle + + + 5,465 + 12-2019 + A2 + Overpaid + Software Developer for the Environment + + + 5,466 + 11-1998 + B1 + Fairly Paid + Software Developer in Chief + + + 5,467 + 1-2002 + C1 + Massively Underpaid + Philosopher in Chief + + + 5,468 + 1-2005 + A1 + Massively Overpaid + Philosopher of Doom + + + 5,469 + 2-1994 + C1 + Massively Underpaid + Software Developer Trainer + + + 5,470 + 5-2022 + C1 + Massively Underpaid + Builder in Chief + + + 5,471 + 1-2004 + A2 + Overpaid + Historian of Cattle + + + 5,472 + 5-2020 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 5,473 + 9-2013 + C1 + Massively Underpaid + Software Developer of Parties + + + 5,474 + 12-2017 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 5,475 + 6-2011 + B2 + Underpaid + Vigilante for Eternity + + + 5,476 + 7-2021 + C2 + Slave Labour + Author in Chief + + + 5,477 + 8-2022 + C1 + Massively Underpaid + Historian (Trainee) + + + 5,478 + 8-2001 + C1 + Massively Underpaid + Food Taster for the Environment + + + 5,479 + 6-2020 + C1 + Massively Underpaid + Vigilante of Cattle + + + 5,480 + 10-1998 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 5,481 + 4-1993 + C1 + Massively Underpaid + Author of Doom + + + 5,482 + 4-2007 + C2 + Slave Labour + Builder in Chief + + + 5,483 + 3-2023 + A1 + Massively Overpaid + Assassin of Cattle + + + 5,484 + 5-2000 + C2 + Slave Labour + Software Developer for the Environment + + + 5,485 + 8-2008 + B2 + Underpaid + Philosopher for the Environment + + + 5,486 + 3-2023 + B2 + Underpaid + Vigilante of Parties + + + 5,487 + 12-1998 + C2 + Slave Labour + Food Taster Laureate + + + 5,488 + 12-2023 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 5,489 + 5-2005 + B2 + Underpaid + Sports Mascot for the Environment + + + 5,490 + 2-2019 + B2 + Underpaid + Historian Trainer + + + 5,491 + 12-2008 + B1 + Fairly Paid + Builder for Schools + + + 5,492 + 12-2011 + C1 + Massively Underpaid + Food Taster of Doom + + + 5,493 + 8-2023 + A1 + Massively Overpaid + Philosopher of Doom + + + 5,494 + 1-2000 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 5,495 + 6-2022 + A1 + Massively Overpaid + Software Developer Trainer + + + 5,496 + 2-1991 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 5,497 + 9-1996 + B2 + Underpaid + Philosopher of Parties + + + 5,498 + 12-2010 + B2 + Underpaid + Sports Mascot (Trainee) + + + 5,499 + 10-2013 + A2 + Overpaid + Philosopher in Chief + + + 5,500 + 8-1996 + B1 + Fairly Paid + Historian of Doom + + + 5,501 + 9-2013 + C2 + Slave Labour + Software Developer (Trainee) + + + 5,502 + 6-2002 + A2 + Overpaid + Assassin Extraordinaire + + + 5,503 + 1-2010 + B1 + Fairly Paid + Software Developer Laureate + + + 5,504 + 8-1998 + C1 + Massively Underpaid + Food Taster Laureate + + + 5,505 + 3-2012 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 5,506 + 4-2020 + B1 + Fairly Paid + Food Taster for Schools + + + 5,507 + 1-2007 + A2 + Overpaid + Assassin for the Environment + + + 5,508 + 11-2017 + A2 + Overpaid + Food Taster Laureate + + + 5,509 + 4-2010 + A1 + Massively Overpaid + Software Developer for Schools + + + 5,510 + 4-2005 + B1 + Fairly Paid + Historian for Eternity + + + 5,511 + 2-2012 + C1 + Massively Underpaid + Assassin for the Environment + + + 5,512 + 11-2001 + A1 + Massively Overpaid + Author Trainer + + + 5,513 + 1-2001 + B1 + Fairly Paid + Vigilante in Chief + + + 5,514 + 9-1992 + A2 + Overpaid + Food Taster of Parties + + + 5,515 + 4-2013 + A1 + Massively Overpaid + Food Taster Laureate + + + 5,516 + 6-1992 + A2 + Overpaid + Builder Extraordinaire + + + 5,517 + 3-2020 + C2 + Slave Labour + Sports Mascot for Schools + + + 5,518 + 3-1995 + B2 + Underpaid + Vigilante (Trainee) + + + 5,519 + 6-1991 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 5,520 + 1-2020 + C1 + Massively Underpaid + Historian Trainer + + + 5,521 + 4-2003 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 5,522 + 5-1999 + C2 + Slave Labour + Sports Mascot in Chief + + + 5,523 + 7-1993 + C2 + Slave Labour + Assassin Trainer + + + 5,524 + 4-2009 + C2 + Slave Labour + Food Taster of Doom + + + 5,525 + 8-2007 + B1 + Fairly Paid + Food Taster of Cattle + + + 5,526 + 11-2023 + A1 + Massively Overpaid + Historian of Cattle + + + 5,527 + 1-2002 + C1 + Massively Underpaid + Author Extraordinaire + + + 5,528 + 4-1992 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 5,529 + 7-2014 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 5,530 + 6-1997 + A1 + Massively Overpaid + Assassin (Trainee) + + + 5,531 + 3-1991 + C2 + Slave Labour + Food Taster of Parties + + + 5,532 + 11-2004 + B1 + Fairly Paid + Assassin Extraordinaire + + + 5,533 + 9-1995 + C1 + Massively Underpaid + Software Developer for Eternity + + + 5,534 + 10-2020 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 5,535 + 11-1998 + C2 + Slave Labour + Vigilante Extraordinaire + + + 5,536 + 9-2007 + C1 + Massively Underpaid + Assassin in Chief + + + 5,537 + 3-1993 + A2 + Overpaid + Builder Extraordinaire + + + 5,538 + 3-2004 + A1 + Massively Overpaid + Vigilante for Eternity + + + 5,539 + 5-2011 + B1 + Fairly Paid + Builder of Cattle + + + 5,540 + 8-1996 + A2 + Overpaid + Builder Extraordinaire + + + 5,541 + 2-1992 + B1 + Fairly Paid + Builder of Doom + + + 5,542 + 5-2004 + C2 + Slave Labour + Builder of Cattle + + + 5,543 + 8-1996 + A2 + Overpaid + Sports Mascot Laureate + + + 5,544 + 7-1995 + C2 + Slave Labour + Philosopher for Schools + + + 5,545 + 10-2003 + C1 + Massively Underpaid + Author for Schools + + + 5,546 + 10-1996 + B2 + Underpaid + Philosopher for Eternity + + + 5,547 + 3-2020 + A2 + Overpaid + Historian (Trainee) + + + 5,548 + 12-1991 + B2 + Underpaid + Sports Mascot for the Environment + + + 5,549 + 2-2021 + B2 + Underpaid + Food Taster (Trainee) + + + 5,550 + 10-1996 + A1 + Massively Overpaid + Vigilante for the Environment + + + 5,551 + 12-2017 + B1 + Fairly Paid + Builder (Trainee) + + + 5,552 + 7-2009 + C2 + Slave Labour + Author of Cattle + + + 5,553 + 3-1992 + A1 + Massively Overpaid + Author of Doom + + + 5,554 + 5-2022 + B2 + Underpaid + Software Developer Trainer + + + 5,555 + 5-1999 + A1 + Massively Overpaid + Philosopher of Doom + + + 5,556 + 3-1997 + A2 + Overpaid + Sports Mascot of Parties + + + 5,557 + 2-2003 + B1 + Fairly Paid + Software Developer for Schools + + + 5,558 + 8-1992 + C2 + Slave Labour + Food Taster in Chief + + + 5,559 + 7-2002 + C2 + Slave Labour + Author for the Environment + + + 5,560 + 11-2016 + A1 + Massively Overpaid + Assassin of Parties + + + 5,561 + 3-1997 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 5,562 + 6-2021 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 5,563 + 5-1991 + B2 + Underpaid + Food Taster Extraordinaire + + + 5,564 + 3-1993 + B1 + Fairly Paid + Builder Laureate + + + 5,565 + 4-2017 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 5,566 + 8-2011 + A1 + Massively Overpaid + Historian for Eternity + + + 5,567 + 2-1998 + B2 + Underpaid + Sports Mascot Laureate + + + 5,568 + 9-2021 + A1 + Massively Overpaid + Author for Eternity + + + 5,569 + 10-2019 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 5,570 + 8-2017 + B2 + Underpaid + Author Extraordinaire + + + 5,571 + 3-2014 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 5,572 + 7-2013 + A1 + Massively Overpaid + Author of Cattle + + + 5,573 + 4-2006 + B2 + Underpaid + Assassin (Trainee) + + + 5,574 + 11-2016 + A2 + Overpaid + Builder Trainer + + + 5,575 + 11-2010 + A2 + Overpaid + Builder of Parties + + + 5,576 + 11-1994 + B1 + Fairly Paid + Philosopher (Trainee) + + + 5,577 + 10-1991 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 5,578 + 6-1994 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 5,579 + 1-2007 + A2 + Overpaid + Food Taster Laureate + + + 5,580 + 1-2004 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 5,581 + 1-1995 + C1 + Massively Underpaid + Assassin of Cattle + + + 5,582 + 3-1992 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 5,583 + 1-2003 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 5,584 + 2-2010 + A1 + Massively Overpaid + Vigilante Trainer + + + 5,585 + 10-2007 + A2 + Overpaid + Assassin of Doom + + + 5,586 + 1-2008 + A1 + Massively Overpaid + Assassin of Doom + + + 5,587 + 12-2000 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 5,588 + 5-2018 + C2 + Slave Labour + Philosopher (Trainee) + + + 5,589 + 5-2003 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 5,590 + 9-1998 + B2 + Underpaid + Food Taster of Parties + + + 5,591 + 1-1992 + A1 + Massively Overpaid + Builder of Parties + + + 5,592 + 12-2003 + B1 + Fairly Paid + Author for Schools + + + 5,593 + 8-2003 + B2 + Underpaid + Assassin Laureate + + + 5,594 + 4-2013 + B2 + Underpaid + Philosopher Trainer + + + 5,595 + 8-1992 + B2 + Underpaid + Builder for the Environment + + + 5,596 + 10-2020 + A1 + Massively Overpaid + Author for the Environment + + + 5,597 + 12-2003 + B2 + Underpaid + Philosopher Extraordinaire + + + 5,598 + 1-2006 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 5,599 + 6-2002 + C1 + Massively Underpaid + Author of Doom + + + 5,600 + 12-2006 + A2 + Overpaid + Sports Mascot Trainer + + + 5,601 + 11-2005 + C1 + Massively Underpaid + Builder for Eternity + + + 5,602 + 9-2017 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 5,603 + 7-2021 + A2 + Overpaid + Sports Mascot for the Environment + + + 5,604 + 6-2000 + B1 + Fairly Paid + Philosopher Trainer + + + 5,605 + 8-2005 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 5,606 + 11-1998 + A2 + Overpaid + Assassin Extraordinaire + + + 5,607 + 2-1990 + C1 + Massively Underpaid + Builder Trainer + + + 5,608 + 6-1994 + C1 + Massively Underpaid + Vigilante Laureate + + + 5,609 + 1-2009 + C1 + Massively Underpaid + Builder Extraordinaire + + + 5,610 + 11-1999 + C2 + Slave Labour + Author for Schools + + + 5,611 + 2-2020 + C1 + Massively Underpaid + Builder of Doom + + + 5,612 + 12-1994 + C2 + Slave Labour + Author Extraordinaire + + + 5,613 + 4-2010 + B1 + Fairly Paid + Food Taster (Trainee) + + + 5,614 + 10-1990 + B2 + Underpaid + Philosopher Extraordinaire + + + 5,615 + 6-2012 + B2 + Underpaid + Food Taster Trainer + + + 5,616 + 1-2018 + C2 + Slave Labour + Food Taster of Cattle + + + 5,617 + 2-1991 + C2 + Slave Labour + Historian for the Environment + + + 5,618 + 6-2003 + A1 + Massively Overpaid + Vigilante of Cattle + + + 5,619 + 2-1997 + B2 + Underpaid + Author of Parties + + + 5,620 + 7-2023 + C2 + Slave Labour + Historian Laureate + + + 5,621 + 4-2012 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 5,622 + 4-2017 + A2 + Overpaid + Philosopher of Parties + + + 5,623 + 4-2018 + B1 + Fairly Paid + Vigilante of Cattle + + + 5,624 + 2-2023 + A2 + Overpaid + Historian in Chief + + + 5,625 + 11-1993 + C2 + Slave Labour + Software Developer (Trainee) + + + 5,626 + 4-2011 + A2 + Overpaid + Author for Eternity + + + 5,627 + 12-2007 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 5,628 + 9-2010 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 5,629 + 2-1991 + A1 + Massively Overpaid + Vigilante Laureate + + + 5,630 + 7-2009 + B1 + Fairly Paid + Philosopher of Doom + + + 5,631 + 8-2020 + C1 + Massively Underpaid + Philosopher for Schools + + + 5,632 + 8-1991 + A1 + Massively Overpaid + Software Developer of Doom + + + 5,633 + 11-1991 + A1 + Massively Overpaid + Philosopher for Eternity + + + 5,634 + 8-2007 + B2 + Underpaid + Assassin of Parties + + + 5,635 + 4-2001 + C1 + Massively Underpaid + Software Developer for the Environment + + + 5,636 + 10-2020 + C2 + Slave Labour + Philosopher of Doom + + + 5,637 + 12-2011 + B1 + Fairly Paid + Sports Mascot of Parties + + + 5,638 + 4-1997 + C1 + Massively Underpaid + Historian Extraordinaire + + + 5,639 + 2-2016 + B1 + Fairly Paid + Historian of Cattle + + + 5,640 + 4-2011 + C1 + Massively Underpaid + Assassin Laureate + + + 5,641 + 12-1995 + B1 + Fairly Paid + Builder of Parties + + + 5,642 + 3-1998 + B2 + Underpaid + Software Developer Laureate + + + 5,643 + 1-2014 + A2 + Overpaid + Historian Laureate + + + 5,644 + 5-2005 + B1 + Fairly Paid + Historian of Doom + + + 5,645 + 7-2001 + C2 + Slave Labour + Builder for Schools + + + 5,646 + 11-1990 + B2 + Underpaid + Historian of Doom + + + 5,647 + 9-1994 + A2 + Overpaid + Vigilante in Chief + + + 5,648 + 1-2013 + A2 + Overpaid + Builder Trainer + + + 5,649 + 6-1994 + C1 + Massively Underpaid + Historian of Parties + + + 5,650 + 9-2010 + B2 + Underpaid + Sports Mascot (Trainee) + + + 5,651 + 7-2017 + C2 + Slave Labour + Author in Chief + + + 5,652 + 11-2010 + B1 + Fairly Paid + Food Taster for Eternity + + + 5,653 + 4-2017 + A2 + Overpaid + Skydiving Instructor of Parties + + + 5,654 + 8-1998 + A2 + Overpaid + Author in Chief + + + 5,655 + 11-2000 + B1 + Fairly Paid + Vigilante Trainer + + + 5,656 + 10-2002 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 5,657 + 11-2016 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 5,658 + 5-1992 + C2 + Slave Labour + Historian of Cattle + + + 5,659 + 12-2013 + A1 + Massively Overpaid + Software Developer of Cattle + + + 5,660 + 8-1998 + C2 + Slave Labour + Vigilante of Parties + + + 5,661 + 11-2016 + A2 + Overpaid + Sports Mascot of Parties + + + 5,662 + 5-1998 + C2 + Slave Labour + Philosopher of Doom + + + 5,663 + 6-2006 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 5,664 + 12-2022 + A2 + Overpaid + Historian for Schools + + + 5,665 + 5-2001 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 5,666 + 2-1999 + A1 + Massively Overpaid + Food Taster of Parties + + + 5,667 + 7-2022 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 5,668 + 8-2016 + B1 + Fairly Paid + Software Developer for the Environment + + + 5,669 + 10-2022 + C2 + Slave Labour + Historian (Trainee) + + + 5,670 + 2-2021 + C1 + Massively Underpaid + Assassin of Cattle + + + 5,671 + 4-2021 + B2 + Underpaid + Vigilante for Schools + + + 5,672 + 7-1993 + C1 + Massively Underpaid + Philosopher for Schools + + + 5,673 + 6-1998 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 5,674 + 2-2019 + B2 + Underpaid + Builder Extraordinaire + + + 5,675 + 3-2000 + A1 + Massively Overpaid + Food Taster for Schools + + + 5,676 + 7-1996 + A2 + Overpaid + Builder Trainer + + + 5,677 + 6-2018 + B1 + Fairly Paid + Assassin for the Environment + + + 5,678 + 3-2021 + B1 + Fairly Paid + Vigilante for Schools + + + 5,679 + 4-2009 + A2 + Overpaid + Philosopher Extraordinaire + + + 5,680 + 5-1994 + C2 + Slave Labour + Software Developer for the Environment + + + 5,681 + 7-2021 + C2 + Slave Labour + Vigilante Extraordinaire + + + 5,682 + 11-2021 + C2 + Slave Labour + Builder Trainer + + + 5,683 + 12-2003 + C2 + Slave Labour + Philosopher for Schools + + + 5,684 + 7-1994 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 5,685 + 2-2000 + B2 + Underpaid + Vigilante of Doom + + + 5,686 + 2-1992 + C1 + Massively Underpaid + Food Taster Laureate + + + 5,687 + 2-1998 + C2 + Slave Labour + Historian of Parties + + + 5,688 + 8-2004 + A2 + Overpaid + Sports Mascot in Chief + + + 5,689 + 8-2008 + A1 + Massively Overpaid + Author in Chief + + + 5,690 + 9-1998 + C1 + Massively Underpaid + Assassin for the Environment + + + 5,691 + 4-2023 + B2 + Underpaid + Builder Extraordinaire + + + 5,692 + 12-2003 + C1 + Massively Underpaid + Food Taster for the Environment + + + 5,693 + 5-2018 + B2 + Underpaid + Software Developer for Eternity + + + 5,694 + 7-2013 + A1 + Massively Overpaid + Software Developer Trainer + + + 5,695 + 10-2000 + A2 + Overpaid + Philosopher for the Environment + + + 5,696 + 2-1999 + A2 + Overpaid + Philosopher of Cattle + + + 5,697 + 5-2018 + A1 + Massively Overpaid + Assassin of Cattle + + + 5,698 + 3-2001 + A2 + Overpaid + Food Taster for Schools + + + 5,699 + 12-2005 + B1 + Fairly Paid + Vigilante (Trainee) + + + 5,700 + 10-2021 + C2 + Slave Labour + Sports Mascot of Cattle + + + 5,701 + 11-2013 + C2 + Slave Labour + Builder of Doom + + + 5,702 + 2-2011 + B1 + Fairly Paid + Historian of Parties + + + 5,703 + 1-2004 + A2 + Overpaid + Philosopher Trainer + + + 5,704 + 4-1995 + A2 + Overpaid + Software Developer for Eternity + + + 5,705 + 8-2000 + A1 + Massively Overpaid + Vigilante of Cattle + + + 5,706 + 10-2004 + B2 + Underpaid + Software Developer for the Environment + + + 5,707 + 8-1995 + C1 + Massively Underpaid + Vigilante Laureate + + + 5,708 + 4-2005 + B1 + Fairly Paid + Food Taster of Cattle + + + 5,709 + 4-2013 + A1 + Massively Overpaid + Author for Eternity + + + 5,710 + 9-2000 + C2 + Slave Labour + Sports Mascot in Chief + + + 5,711 + 1-2011 + A1 + Massively Overpaid + Food Taster for the Environment + + + 5,712 + 10-1996 + C2 + Slave Labour + Software Developer Laureate + + + 5,713 + 8-1994 + C1 + Massively Underpaid + Assassin of Doom + + + 5,714 + 11-1994 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 5,715 + 1-2003 + B2 + Underpaid + Author of Cattle + + + 5,716 + 5-2012 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 5,717 + 12-2013 + B1 + Fairly Paid + Author of Doom + + + 5,718 + 12-2021 + C1 + Massively Underpaid + Software Developer in Chief + + + 5,719 + 1-1995 + B2 + Underpaid + Vigilante for Eternity + + + 5,720 + 3-2020 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 5,721 + 3-2000 + B1 + Fairly Paid + Builder of Parties + + + 5,722 + 6-2017 + B1 + Fairly Paid + Vigilante (Trainee) + + + 5,723 + 6-2019 + C1 + Massively Underpaid + Food Taster of Cattle + + + 5,724 + 7-2010 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 5,725 + 1-1995 + B1 + Fairly Paid + Food Taster of Cattle + + + 5,726 + 2-2019 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 5,727 + 3-2000 + B2 + Underpaid + Author of Doom + + + 5,728 + 6-1998 + A2 + Overpaid + Builder of Cattle + + + 5,729 + 12-2011 + A2 + Overpaid + Philosopher (Trainee) + + + 5,730 + 3-2006 + A2 + Overpaid + Historian of Cattle + + + 5,731 + 2-2022 + B2 + Underpaid + Assassin (Trainee) + + + 5,732 + 10-2018 + B1 + Fairly Paid + Philosopher of Cattle + + + 5,733 + 4-2023 + A1 + Massively Overpaid + Builder in Chief + + + 5,734 + 2-2004 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 5,735 + 7-2019 + C1 + Massively Underpaid + Food Taster of Parties + + + 5,736 + 2-2015 + A1 + Massively Overpaid + Historian in Chief + + + 5,737 + 9-2014 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 5,738 + 1-2003 + C1 + Massively Underpaid + Philosopher for Eternity + + + 5,739 + 7-2020 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 5,740 + 7-2000 + B2 + Underpaid + Author Extraordinaire + + + 5,741 + 3-2006 + C1 + Massively Underpaid + Food Taster Trainer + + + 5,742 + 5-1997 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 5,743 + 10-2000 + C1 + Massively Underpaid + Author of Parties + + + 5,744 + 7-1997 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 5,745 + 6-2002 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 5,746 + 8-1994 + A1 + Massively Overpaid + Assassin Trainer + + + 5,747 + 6-2001 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 5,748 + 7-2009 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 5,749 + 3-2005 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 5,750 + 1-1998 + B2 + Underpaid + Historian Extraordinaire + + + 5,751 + 8-2020 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 5,752 + 7-2018 + A2 + Overpaid + Sports Mascot for the Environment + + + 5,753 + 3-1994 + B2 + Underpaid + Philosopher of Doom + + + 5,754 + 4-2022 + B1 + Fairly Paid + Sports Mascot Laureate + + + 5,755 + 8-2022 + C1 + Massively Underpaid + Author for Schools + + + 5,756 + 6-2019 + B2 + Underpaid + Builder Laureate + + + 5,757 + 3-2004 + A2 + Overpaid + Skydiving Instructor in Chief + + + 5,758 + 1-2002 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 5,759 + 9-1994 + A1 + Massively Overpaid + Assassin in Chief + + + 5,760 + 1-2001 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 5,761 + 5-2010 + A2 + Overpaid + Software Developer for the Environment + + + 5,762 + 9-1999 + A1 + Massively Overpaid + Historian Trainer + + + 5,763 + 2-2006 + A2 + Overpaid + Historian for Eternity + + + 5,764 + 6-2003 + B1 + Fairly Paid + Assassin Trainer + + + 5,765 + 7-2020 + C1 + Massively Underpaid + Author for Schools + + + 5,766 + 5-2008 + B2 + Underpaid + Assassin for Eternity + + + 5,767 + 4-1993 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 5,768 + 10-2017 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 5,769 + 6-2007 + A1 + Massively Overpaid + Builder of Doom + + + 5,770 + 1-1994 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 5,771 + 1-1990 + B1 + Fairly Paid + Historian Trainer + + + 5,772 + 8-2001 + C2 + Slave Labour + Builder for Eternity + + + 5,773 + 5-2010 + C2 + Slave Labour + Author of Doom + + + 5,774 + 4-2016 + B1 + Fairly Paid + Sports Mascot Laureate + + + 5,775 + 6-2014 + C2 + Slave Labour + Philosopher Laureate + + + 5,776 + 1-1993 + C2 + Slave Labour + Assassin Trainer + + + 5,777 + 8-2020 + B2 + Underpaid + Food Taster for the Environment + + + 5,778 + 5-2015 + B1 + Fairly Paid + Historian in Chief + + + 5,779 + 10-2010 + B2 + Underpaid + Sports Mascot of Doom + + + 5,780 + 11-2006 + B1 + Fairly Paid + Assassin (Trainee) + + + 5,781 + 11-2002 + B1 + Fairly Paid + Assassin of Parties + + + 5,782 + 5-1992 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 5,783 + 12-2013 + B1 + Fairly Paid + Historian Extraordinaire + + + 5,784 + 9-1994 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 5,785 + 5-2010 + C2 + Slave Labour + Sports Mascot of Cattle + + + 5,786 + 5-2009 + C1 + Massively Underpaid + Assassin for the Environment + + + 5,787 + 3-1990 + C1 + Massively Underpaid + Vigilante for Eternity + + + 5,788 + 6-2021 + C2 + Slave Labour + Historian for Schools + + + 5,789 + 11-2016 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 5,790 + 4-2018 + C2 + Slave Labour + Vigilante for Eternity + + + 5,791 + 12-2022 + B1 + Fairly Paid + Vigilante (Trainee) + + + 5,792 + 3-2000 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 5,793 + 5-2020 + A2 + Overpaid + Assassin of Parties + + + 5,794 + 6-1991 + A2 + Overpaid + Historian Laureate + + + 5,795 + 7-2017 + A2 + Overpaid + Vigilante Laureate + + + 5,796 + 4-2003 + A2 + Overpaid + Historian Laureate + + + 5,797 + 1-2016 + C1 + Massively Underpaid + Software Developer in Chief + + + 5,798 + 4-2012 + A2 + Overpaid + Philosopher for the Environment + + + 5,799 + 11-1997 + A1 + Massively Overpaid + Builder of Cattle + + + 5,800 + 7-2023 + C2 + Slave Labour + Software Developer (Trainee) + + + 5,801 + 9-2021 + B1 + Fairly Paid + Vigilante for the Environment + + + 5,802 + 12-1998 + B2 + Underpaid + Software Developer for Schools + + + 5,803 + 2-2018 + A2 + Overpaid + Vigilante (Trainee) + + + 5,804 + 11-1996 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 5,805 + 8-2010 + B2 + Underpaid + Food Taster Trainer + + + 5,806 + 9-1995 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 5,807 + 10-2005 + A1 + Massively Overpaid + Food Taster for the Environment + + + 5,808 + 3-2021 + C1 + Massively Underpaid + Builder for the Environment + + + 5,809 + 6-1990 + C1 + Massively Underpaid + Software Developer Trainer + + + 5,810 + 11-2019 + B1 + Fairly Paid + Author (Trainee) + + + 5,811 + 2-2015 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 5,812 + 11-2018 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 5,813 + 8-1992 + A1 + Massively Overpaid + Historian Trainer + + + 5,814 + 8-2004 + C2 + Slave Labour + Author Trainer + + + 5,815 + 7-1997 + B2 + Underpaid + Author Laureate + + + 5,816 + 1-1997 + C2 + Slave Labour + Assassin Laureate + + + 5,817 + 8-2006 + B2 + Underpaid + Vigilante of Parties + + + 5,818 + 7-2008 + C1 + Massively Underpaid + Software Developer of Doom + + + 5,819 + 2-2002 + B1 + Fairly Paid + Builder Trainer + + + 5,820 + 5-2019 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 5,821 + 8-2003 + A2 + Overpaid + Sports Mascot of Doom + + + 5,822 + 8-2018 + C1 + Massively Underpaid + Builder in Chief + + + 5,823 + 4-2010 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 5,824 + 7-1995 + C2 + Slave Labour + Software Developer of Parties + + + 5,825 + 7-2019 + A2 + Overpaid + Assassin Extraordinaire + + + 5,826 + 10-1999 + A1 + Massively Overpaid + Historian of Doom + + + 5,827 + 6-1992 + C1 + Massively Underpaid + Assassin for Schools + + + 5,828 + 6-1999 + A2 + Overpaid + Philosopher in Chief + + + 5,829 + 9-1996 + A2 + Overpaid + Philosopher of Cattle + + + 5,830 + 5-1997 + C1 + Massively Underpaid + Author in Chief + + + 5,831 + 4-2023 + B2 + Underpaid + Software Developer (Trainee) + + + 5,832 + 10-2017 + B2 + Underpaid + Author for Schools + + + 5,833 + 5-2023 + B2 + Underpaid + Builder in Chief + + + 5,834 + 6-2007 + B1 + Fairly Paid + Vigilante for Schools + + + 5,835 + 5-2019 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 5,836 + 8-2008 + C2 + Slave Labour + Historian of Doom + + + 5,837 + 6-1998 + A2 + Overpaid + Vigilante of Parties + + + 5,838 + 8-2018 + C2 + Slave Labour + Philosopher for Eternity + + + 5,839 + 4-1996 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 5,840 + 6-2007 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 5,841 + 1-2018 + C2 + Slave Labour + Vigilante of Parties + + + 5,842 + 2-2020 + C1 + Massively Underpaid + Food Taster of Doom + + + 5,843 + 9-1992 + B1 + Fairly Paid + Sports Mascot Laureate + + + 5,844 + 1-2021 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 5,845 + 6-2022 + A1 + Massively Overpaid + Builder of Doom + + + 5,846 + 7-2020 + A2 + Overpaid + Vigilante Laureate + + + 5,847 + 7-1996 + C1 + Massively Underpaid + Historian of Parties + + + 5,848 + 2-2006 + B2 + Underpaid + Vigilante of Parties + + + 5,849 + 8-1994 + C2 + Slave Labour + Builder for Schools + + + 5,850 + 3-2007 + B1 + Fairly Paid + Historian Laureate + + + 5,851 + 2-2008 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 5,852 + 5-2015 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 5,853 + 4-2001 + A2 + Overpaid + Assassin for Schools + + + 5,854 + 3-2015 + B2 + Underpaid + Historian of Parties + + + 5,855 + 9-1992 + B2 + Underpaid + Assassin of Parties + + + 5,856 + 10-1993 + B1 + Fairly Paid + Sports Mascot for Schools + + + 5,857 + 7-2013 + A2 + Overpaid + Philosopher Trainer + + + 5,858 + 5-2010 + A2 + Overpaid + Author for the Environment + + + 5,859 + 11-2013 + B1 + Fairly Paid + Philosopher Trainer + + + 5,860 + 2-2016 + B1 + Fairly Paid + Sports Mascot Laureate + + + 5,861 + 9-2022 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 5,862 + 3-2003 + C2 + Slave Labour + Author (Trainee) + + + 5,863 + 8-2002 + A2 + Overpaid + Author of Doom + + + 5,864 + 2-2020 + B1 + Fairly Paid + Assassin for Schools + + + 5,865 + 3-1990 + C2 + Slave Labour + Assassin Extraordinaire + + + 5,866 + 5-2015 + C1 + Massively Underpaid + Assassin for Eternity + + + 5,867 + 7-2014 + C2 + Slave Labour + Historian for Schools + + + 5,868 + 11-1993 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 5,869 + 4-2001 + C2 + Slave Labour + Builder for the Environment + + + 5,870 + 5-2009 + C1 + Massively Underpaid + Assassin Trainer + + + 5,871 + 9-2018 + A2 + Overpaid + Sports Mascot Trainer + + + 5,872 + 1-1995 + B1 + Fairly Paid + Food Taster Laureate + + + 5,873 + 12-2017 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 5,874 + 1-2015 + B2 + Underpaid + Vigilante Trainer + + + 5,875 + 1-2017 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 5,876 + 7-1998 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 5,877 + 12-2015 + B2 + Underpaid + Food Taster Trainer + + + 5,878 + 5-2006 + B1 + Fairly Paid + Vigilante for Eternity + + + 5,879 + 7-2007 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 5,880 + 9-1999 + A1 + Massively Overpaid + Author of Parties + + + 5,881 + 6-1992 + B1 + Fairly Paid + Software Developer of Doom + + + 5,882 + 1-1997 + C2 + Slave Labour + Philosopher Trainer + + + 5,883 + 11-1991 + C2 + Slave Labour + Builder in Chief + + + 5,884 + 12-2013 + B1 + Fairly Paid + Builder Trainer + + + 5,885 + 6-1999 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 5,886 + 3-2016 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 5,887 + 4-1991 + B2 + Underpaid + Assassin for the Environment + + + 5,888 + 11-2020 + B1 + Fairly Paid + Vigilante for Schools + + + 5,889 + 6-2008 + A2 + Overpaid + Philosopher for Schools + + + 5,890 + 2-1995 + B2 + Underpaid + Software Developer for the Environment + + + 5,891 + 2-1993 + B2 + Underpaid + Author of Cattle + + + 5,892 + 7-2008 + A1 + Massively Overpaid + Vigilante in Chief + + + 5,893 + 12-2004 + C2 + Slave Labour + Philosopher of Parties + + + 5,894 + 2-1997 + B1 + Fairly Paid + Vigilante Laureate + + + 5,895 + 1-2020 + B1 + Fairly Paid + Software Developer of Cattle + + + 5,896 + 5-1996 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 5,897 + 9-2014 + A2 + Overpaid + Builder for the Environment + + + 5,898 + 7-2001 + B2 + Underpaid + Assassin of Cattle + + + 5,899 + 11-2016 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 5,900 + 12-2004 + A2 + Overpaid + Sports Mascot for Eternity + + + 5,901 + 6-2003 + A1 + Massively Overpaid + Philosopher in Chief + + + 5,902 + 9-2012 + A2 + Overpaid + Sports Mascot in Chief + + + 5,903 + 2-2001 + A2 + Overpaid + Vigilante in Chief + + + 5,904 + 1-1991 + C1 + Massively Underpaid + Vigilante of Doom + + + 5,905 + 11-2000 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 5,906 + 10-2023 + A1 + Massively Overpaid + Vigilante Laureate + + + 5,907 + 11-2003 + B2 + Underpaid + Builder Extraordinaire + + + 5,908 + 12-1998 + A1 + Massively Overpaid + Software Developer for Eternity + + + 5,909 + 8-2017 + C1 + Massively Underpaid + Food Taster of Cattle + + + 5,910 + 3-2007 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 5,911 + 11-2000 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 5,912 + 1-2009 + A1 + Massively Overpaid + Vigilante for the Environment + + + 5,913 + 1-2006 + C1 + Massively Underpaid + Author (Trainee) + + + 5,914 + 5-2020 + A2 + Overpaid + Sports Mascot of Parties + + + 5,915 + 12-1990 + C1 + Massively Underpaid + Author Laureate + + + 5,916 + 8-1992 + B2 + Underpaid + Sports Mascot Laureate + + + 5,917 + 5-2017 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 5,918 + 4-2002 + A2 + Overpaid + Philosopher for the Environment + + + 5,919 + 4-2023 + A1 + Massively Overpaid + Author of Cattle + + + 5,920 + 11-2018 + B1 + Fairly Paid + Food Taster (Trainee) + + + 5,921 + 3-2022 + B1 + Fairly Paid + Software Developer for Eternity + + + 5,922 + 2-2005 + A2 + Overpaid + Vigilante for Eternity + + + 5,923 + 2-2006 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 5,924 + 8-2016 + B2 + Underpaid + Author for Eternity + + + 5,925 + 5-1995 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 5,926 + 6-1990 + B1 + Fairly Paid + Philosopher (Trainee) + + + 5,927 + 9-2005 + B1 + Fairly Paid + Historian of Cattle + + + 5,928 + 3-2003 + C2 + Slave Labour + Food Taster for Schools + + + 5,929 + 11-2021 + A1 + Massively Overpaid + Builder for Eternity + + + 5,930 + 1-2011 + B1 + Fairly Paid + Software Developer (Trainee) + + + 5,931 + 2-1994 + A1 + Massively Overpaid + Historian of Cattle + + + 5,932 + 3-2016 + B1 + Fairly Paid + Vigilante of Doom + + + 5,933 + 12-2012 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 5,934 + 3-2005 + B2 + Underpaid + Author of Parties + + + 5,935 + 10-2000 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 5,936 + 10-2005 + A2 + Overpaid + Author of Cattle + + + 5,937 + 3-2015 + B1 + Fairly Paid + Assassin of Doom + + + 5,938 + 11-1997 + A2 + Overpaid + Builder in Chief + + + 5,939 + 10-2021 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 5,940 + 11-2013 + B2 + Underpaid + Software Developer of Doom + + + 5,941 + 5-2020 + C1 + Massively Underpaid + Author in Chief + + + 5,942 + 3-2016 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 5,943 + 11-1996 + C1 + Massively Underpaid + Historian for Eternity + + + 5,944 + 3-1996 + C1 + Massively Underpaid + Author of Parties + + + 5,945 + 10-1996 + A1 + Massively Overpaid + Software Developer of Parties + + + 5,946 + 10-1992 + A1 + Massively Overpaid + Software Developer for Eternity + + + 5,947 + 6-2008 + C2 + Slave Labour + Food Taster for Eternity + + + 5,948 + 3-2020 + A2 + Overpaid + Historian Extraordinaire + + + 5,949 + 10-2014 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 5,950 + 8-2013 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 5,951 + 5-2023 + B2 + Underpaid + Assassin (Trainee) + + + 5,952 + 9-2002 + C2 + Slave Labour + Historian Extraordinaire + + + 5,953 + 3-2003 + C2 + Slave Labour + Assassin in Chief + + + 5,954 + 1-2004 + C2 + Slave Labour + Software Developer of Parties + + + 5,955 + 4-1999 + A1 + Massively Overpaid + Vigilante in Chief + + + 5,956 + 7-2012 + B2 + Underpaid + Philosopher Laureate + + + 5,957 + 10-2006 + B1 + Fairly Paid + Author (Trainee) + + + 5,958 + 8-2003 + C2 + Slave Labour + Software Developer (Trainee) + + + 5,959 + 11-2009 + B2 + Underpaid + Builder for the Environment + + + 5,960 + 9-2015 + A1 + Massively Overpaid + Author Laureate + + + 5,961 + 12-1992 + B1 + Fairly Paid + Vigilante in Chief + + + 5,962 + 11-1996 + B2 + Underpaid + Software Developer for Schools + + + 5,963 + 4-2000 + A1 + Massively Overpaid + Author Trainer + + + 5,964 + 4-1999 + A2 + Overpaid + Food Taster in Chief + + + 5,965 + 10-2017 + A1 + Massively Overpaid + Historian for Eternity + + + 5,966 + 11-1992 + B2 + Underpaid + Vigilante for the Environment + + + 5,967 + 5-1993 + B2 + Underpaid + Software Developer for the Environment + + + 5,968 + 3-2013 + C1 + Massively Underpaid + Philosopher in Chief + + + 5,969 + 12-2013 + B2 + Underpaid + Skydiving Instructor of Parties + + + 5,970 + 1-2005 + B2 + Underpaid + Builder in Chief + + + 5,971 + 8-2014 + A1 + Massively Overpaid + Vigilante for Schools + + + 5,972 + 2-2009 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 5,973 + 10-2011 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 5,974 + 3-2020 + B1 + Fairly Paid + Philosopher of Cattle + + + 5,975 + 8-2001 + C1 + Massively Underpaid + Historian Trainer + + + 5,976 + 2-2004 + B2 + Underpaid + Historian for Eternity + + + 5,977 + 8-2000 + C1 + Massively Underpaid + Author of Cattle + + + 5,978 + 10-1990 + A1 + Massively Overpaid + Food Taster of Parties + + + 5,979 + 9-2010 + C2 + Slave Labour + Author in Chief + + + 5,980 + 11-2018 + B1 + Fairly Paid + Philosopher of Cattle + + + 5,981 + 2-2016 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 5,982 + 5-2011 + A1 + Massively Overpaid + Software Developer for Eternity + + + 5,983 + 3-2005 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 5,984 + 10-2012 + B1 + Fairly Paid + Historian of Cattle + + + 5,985 + 1-2009 + C1 + Massively Underpaid + Author in Chief + + + 5,986 + 11-2012 + A1 + Massively Overpaid + Author in Chief + + + 5,987 + 11-2011 + A2 + Overpaid + Software Developer for Eternity + + + 5,988 + 11-2022 + C2 + Slave Labour + Sports Mascot Laureate + + + 5,989 + 5-2009 + A2 + Overpaid + Food Taster (Trainee) + + + 5,990 + 11-1994 + C1 + Massively Underpaid + Philosopher Trainer + + + 5,991 + 7-2020 + A2 + Overpaid + Food Taster Trainer + + + 5,992 + 7-2016 + A2 + Overpaid + Software Developer Extraordinaire + + + 5,993 + 8-1997 + C1 + Massively Underpaid + Philosopher of Doom + + + 5,994 + 1-2020 + C1 + Massively Underpaid + Author Trainer + + + 5,995 + 1-2001 + C1 + Massively Underpaid + Vigilante for Schools + + + 5,996 + 3-2004 + A1 + Massively Overpaid + Author of Parties + + + 5,997 + 11-2023 + B1 + Fairly Paid + Historian for the Environment + + + 5,998 + 6-2012 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 5,999 + 3-2022 + C2 + Slave Labour + Food Taster of Doom + + + 6,000 + 4-2008 + B1 + Fairly Paid + Food Taster of Doom + + + 6,001 + 8-2019 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 6,002 + 4-2007 + B1 + Fairly Paid + Software Developer Trainer + + + 6,003 + 10-2006 + C2 + Slave Labour + Philosopher in Chief + + + 6,004 + 4-1995 + A2 + Overpaid + Vigilante Laureate + + + 6,005 + 4-2002 + C2 + Slave Labour + Builder for the Environment + + + 6,006 + 3-2008 + A2 + Overpaid + Assassin for Schools + + + 6,007 + 8-1999 + B1 + Fairly Paid + Historian Trainer + + + 6,008 + 3-1994 + C2 + Slave Labour + Food Taster Extraordinaire + + + 6,009 + 12-2007 + C1 + Massively Underpaid + Builder Laureate + + + 6,010 + 7-1990 + B2 + Underpaid + Vigilante of Cattle + + + 6,011 + 12-2013 + B2 + Underpaid + Software Developer Extraordinaire + + + 6,012 + 7-2001 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 6,013 + 7-1999 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 6,014 + 2-1993 + C2 + Slave Labour + Software Developer Laureate + + + 6,015 + 10-2018 + B2 + Underpaid + Assassin of Cattle + + + 6,016 + 5-1991 + B2 + Underpaid + Vigilante of Cattle + + + 6,017 + 10-2002 + C2 + Slave Labour + Software Developer for the Environment + + + 6,018 + 4-1993 + B1 + Fairly Paid + Historian in Chief + + + 6,019 + 4-2013 + B1 + Fairly Paid + Author (Trainee) + + + 6,020 + 11-2000 + C1 + Massively Underpaid + Author of Doom + + + 6,021 + 4-1998 + B1 + Fairly Paid + Assassin of Parties + + + 6,022 + 12-2012 + B1 + Fairly Paid + Historian (Trainee) + + + 6,023 + 1-2010 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 6,024 + 8-2023 + C1 + Massively Underpaid + Assassin (Trainee) + + + 6,025 + 4-2013 + C2 + Slave Labour + Vigilante for Eternity + + + 6,026 + 10-1999 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 6,027 + 4-2015 + B1 + Fairly Paid + Software Developer for Eternity + + + 6,028 + 4-2019 + B2 + Underpaid + Food Taster of Cattle + + + 6,029 + 1-1990 + B2 + Underpaid + Software Developer for Schools + + + 6,030 + 10-1997 + C2 + Slave Labour + Historian for the Environment + + + 6,031 + 12-2004 + C1 + Massively Underpaid + Assassin (Trainee) + + + 6,032 + 2-2000 + C1 + Massively Underpaid + Vigilante for Schools + + + 6,033 + 6-2021 + A2 + Overpaid + Food Taster of Parties + + + 6,034 + 7-2005 + A1 + Massively Overpaid + Historian Trainer + + + 6,035 + 11-2000 + A2 + Overpaid + Sports Mascot of Cattle + + + 6,036 + 12-2009 + A1 + Massively Overpaid + Author of Cattle + + + 6,037 + 8-2021 + C1 + Massively Underpaid + Food Taster Trainer + + + 6,038 + 1-2008 + B2 + Underpaid + Author for the Environment + + + 6,039 + 9-2002 + C1 + Massively Underpaid + Food Taster for Schools + + + 6,040 + 5-2016 + C2 + Slave Labour + Historian Laureate + + + 6,041 + 9-2023 + B1 + Fairly Paid + Author Laureate + + + 6,042 + 6-1990 + A1 + Massively Overpaid + Author of Doom + + + 6,043 + 4-1994 + C2 + Slave Labour + Philosopher Laureate + + + 6,044 + 2-1996 + B2 + Underpaid + Author (Trainee) + + + 6,045 + 2-2008 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 6,046 + 8-2007 + B2 + Underpaid + Historian for Eternity + + + 6,047 + 10-1998 + A1 + Massively Overpaid + Builder Laureate + + + 6,048 + 3-2023 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 6,049 + 9-2016 + B2 + Underpaid + Builder (Trainee) + + + 6,050 + 7-2005 + A1 + Massively Overpaid + Author of Cattle + + + 6,051 + 7-2002 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 6,052 + 1-1992 + B2 + Underpaid + Builder for Schools + + + 6,053 + 1-2000 + C1 + Massively Underpaid + Builder (Trainee) + + + 6,054 + 9-1992 + A2 + Overpaid + Author Laureate + + + 6,055 + 5-1993 + B2 + Underpaid + Builder for Schools + + + 6,056 + 8-2012 + B2 + Underpaid + Vigilante for the Environment + + + 6,057 + 11-1991 + B2 + Underpaid + Historian for Eternity + + + 6,058 + 3-2002 + B2 + Underpaid + Philosopher Trainer + + + 6,059 + 10-1993 + C2 + Slave Labour + Philosopher (Trainee) + + + 6,060 + 7-1998 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 6,061 + 11-2022 + C2 + Slave Labour + Sports Mascot of Parties + + + 6,062 + 9-2005 + B1 + Fairly Paid + Software Developer for the Environment + + + 6,063 + 5-2005 + C1 + Massively Underpaid + Historian for Schools + + + 6,064 + 9-2009 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 6,065 + 11-1997 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 6,066 + 11-2016 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 6,067 + 4-2010 + A2 + Overpaid + Assassin Extraordinaire + + + 6,068 + 8-2002 + B1 + Fairly Paid + Builder Laureate + + + 6,069 + 12-1997 + A1 + Massively Overpaid + Builder for the Environment + + + 6,070 + 11-1993 + C2 + Slave Labour + Sports Mascot Trainer + + + 6,071 + 10-1996 + B2 + Underpaid + Philosopher of Parties + + + 6,072 + 5-2002 + C1 + Massively Underpaid + Builder Laureate + + + 6,073 + 7-2011 + C1 + Massively Underpaid + Assassin (Trainee) + + + 6,074 + 10-2004 + C2 + Slave Labour + Sports Mascot for the Environment + + + 6,075 + 9-1996 + B2 + Underpaid + Philosopher (Trainee) + + + 6,076 + 10-2008 + C1 + Massively Underpaid + Software Developer Laureate + + + 6,077 + 7-1994 + A2 + Overpaid + Author Trainer + + + 6,078 + 10-1999 + C2 + Slave Labour + Software Developer in Chief + + + 6,079 + 4-2000 + C2 + Slave Labour + Philosopher (Trainee) + + + 6,080 + 3-2020 + B1 + Fairly Paid + Vigilante Laureate + + + 6,081 + 10-2008 + C2 + Slave Labour + Software Developer for the Environment + + + 6,082 + 1-2011 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 6,083 + 8-2015 + C2 + Slave Labour + Author of Cattle + + + 6,084 + 12-2007 + C1 + Massively Underpaid + Builder in Chief + + + 6,085 + 5-2018 + C1 + Massively Underpaid + Philosopher Trainer + + + 6,086 + 8-2002 + C1 + Massively Underpaid + Author Trainer + + + 6,087 + 4-2021 + B1 + Fairly Paid + Builder for Schools + + + 6,088 + 12-2022 + C2 + Slave Labour + Historian Trainer + + + 6,089 + 9-1997 + B1 + Fairly Paid + Vigilante of Cattle + + + 6,090 + 4-2009 + B1 + Fairly Paid + Food Taster for Schools + + + 6,091 + 10-2009 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 6,092 + 9-1996 + C2 + Slave Labour + Philosopher for Schools + + + 6,093 + 4-2016 + A2 + Overpaid + Builder of Doom + + + 6,094 + 11-2001 + B1 + Fairly Paid + Vigilante of Doom + + + 6,095 + 12-2022 + B1 + Fairly Paid + Sports Mascot of Doom + + + 6,096 + 8-2019 + B1 + Fairly Paid + Philosopher in Chief + + + 6,097 + 9-2008 + B2 + Underpaid + Skydiving Instructor Laureate + + + 6,098 + 8-1991 + A1 + Massively Overpaid + Builder of Cattle + + + 6,099 + 1-2001 + A1 + Massively Overpaid + Software Developer in Chief + + + 6,100 + 3-2011 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 6,101 + 1-1991 + B1 + Fairly Paid + Historian of Doom + + + 6,102 + 4-1990 + B1 + Fairly Paid + Philosopher (Trainee) + + + 6,103 + 3-2001 + B2 + Underpaid + Food Taster in Chief + + + 6,104 + 2-2002 + A2 + Overpaid + Author (Trainee) + + + 6,105 + 3-2004 + C1 + Massively Underpaid + Assassin of Doom + + + 6,106 + 1-2019 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 6,107 + 2-2016 + C1 + Massively Underpaid + Historian (Trainee) + + + 6,108 + 10-2011 + A2 + Overpaid + Vigilante in Chief + + + 6,109 + 2-1997 + B1 + Fairly Paid + Food Taster of Doom + + + 6,110 + 8-1993 + C1 + Massively Underpaid + Historian in Chief + + + 6,111 + 2-2019 + B1 + Fairly Paid + Food Taster (Trainee) + + + 6,112 + 10-2022 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 6,113 + 9-1992 + C2 + Slave Labour + Software Developer of Parties + + + 6,114 + 10-2021 + C2 + Slave Labour + Software Developer in Chief + + + 6,115 + 2-2023 + C2 + Slave Labour + Builder of Doom + + + 6,116 + 12-2007 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 6,117 + 4-1994 + B2 + Underpaid + Assassin Laureate + + + 6,118 + 12-2008 + B1 + Fairly Paid + Assassin for the Environment + + + 6,119 + 4-2008 + A2 + Overpaid + Vigilante for Schools + + + 6,120 + 12-2020 + C2 + Slave Labour + Vigilante (Trainee) + + + 6,121 + 7-1992 + B1 + Fairly Paid + Vigilante of Cattle + + + 6,122 + 5-1998 + A1 + Massively Overpaid + Historian in Chief + + + 6,123 + 5-2004 + C2 + Slave Labour + Philosopher in Chief + + + 6,124 + 4-2020 + C1 + Massively Underpaid + Historian for Eternity + + + 6,125 + 1-1992 + C1 + Massively Underpaid + Software Developer of Cattle + + + 6,126 + 7-1996 + A2 + Overpaid + Vigilante Trainer + + + 6,127 + 3-2013 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 6,128 + 8-2004 + A2 + Overpaid + Software Developer for the Environment + + + 6,129 + 7-2021 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 6,130 + 2-2015 + B2 + Underpaid + Assassin of Cattle + + + 6,131 + 2-2003 + A2 + Overpaid + Software Developer Laureate + + + 6,132 + 9-1995 + B2 + Underpaid + Builder Extraordinaire + + + 6,133 + 3-2001 + B1 + Fairly Paid + Assassin of Parties + + + 6,134 + 4-2017 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 6,135 + 4-1993 + C1 + Massively Underpaid + Software Developer in Chief + + + 6,136 + 5-1994 + A1 + Massively Overpaid + Vigilante in Chief + + + 6,137 + 8-1996 + C1 + Massively Underpaid + Assassin of Parties + + + 6,138 + 9-2012 + C2 + Slave Labour + Assassin of Doom + + + 6,139 + 12-2021 + B2 + Underpaid + Vigilante Trainer + + + 6,140 + 1-1990 + A1 + Massively Overpaid + Assassin of Cattle + + + 6,141 + 11-2001 + C2 + Slave Labour + Vigilante Extraordinaire + + + 6,142 + 6-2010 + C2 + Slave Labour + Historian Trainer + + + 6,143 + 6-2007 + A2 + Overpaid + Food Taster of Doom + + + 6,144 + 7-2006 + B2 + Underpaid + Sports Mascot (Trainee) + + + 6,145 + 12-2023 + C1 + Massively Underpaid + Vigilante of Cattle + + + 6,146 + 6-1991 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 6,147 + 5-2015 + A2 + Overpaid + Sports Mascot for Schools + + + 6,148 + 9-1991 + B1 + Fairly Paid + Author Extraordinaire + + + 6,149 + 2-2008 + C2 + Slave Labour + Historian Laureate + + + 6,150 + 6-1993 + A1 + Massively Overpaid + Author for Schools + + + 6,151 + 11-1998 + B2 + Underpaid + Author of Cattle + + + 6,152 + 11-1995 + C1 + Massively Underpaid + Builder Extraordinaire + + + 6,153 + 9-2020 + B2 + Underpaid + Sports Mascot for Schools + + + 6,154 + 10-2013 + B1 + Fairly Paid + Builder Trainer + + + 6,155 + 7-2002 + B2 + Underpaid + Assassin of Cattle + + + 6,156 + 1-2010 + B2 + Underpaid + Author Extraordinaire + + + 6,157 + 3-1992 + B2 + Underpaid + Sports Mascot in Chief + + + 6,158 + 12-1994 + C2 + Slave Labour + Food Taster of Cattle + + + 6,159 + 1-2016 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 6,160 + 4-2017 + C1 + Massively Underpaid + Author Trainer + + + 6,161 + 2-2004 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 6,162 + 9-1992 + A1 + Massively Overpaid + Vigilante for Schools + + + 6,163 + 10-1995 + A1 + Massively Overpaid + Author (Trainee) + + + 6,164 + 10-1991 + A1 + Massively Overpaid + Philosopher in Chief + + + 6,165 + 6-1991 + B2 + Underpaid + Skydiving Instructor of Parties + + + 6,166 + 4-2023 + B1 + Fairly Paid + Philosopher for the Environment + + + 6,167 + 1-1992 + A2 + Overpaid + Historian in Chief + + + 6,168 + 5-2020 + B1 + Fairly Paid + Philosopher for Schools + + + 6,169 + 10-2017 + A1 + Massively Overpaid + Assassin for Eternity + + + 6,170 + 3-2016 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 6,171 + 3-2004 + B1 + Fairly Paid + Historian Laureate + + + 6,172 + 9-2016 + A1 + Massively Overpaid + Builder for Schools + + + 6,173 + 6-2023 + A2 + Overpaid + Vigilante Trainer + + + 6,174 + 9-2017 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 6,175 + 3-1994 + C2 + Slave Labour + Sports Mascot of Parties + + + 6,176 + 11-2022 + B1 + Fairly Paid + Author Laureate + + + 6,177 + 4-2008 + C2 + Slave Labour + Assassin in Chief + + + 6,178 + 9-2006 + C2 + Slave Labour + Historian of Parties + + + 6,179 + 11-2022 + B1 + Fairly Paid + Builder Extraordinaire + + + 6,180 + 7-1994 + C2 + Slave Labour + Historian in Chief + + + 6,181 + 11-1997 + A1 + Massively Overpaid + Author for the Environment + + + 6,182 + 5-1991 + C2 + Slave Labour + Sports Mascot of Cattle + + + 6,183 + 4-1993 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 6,184 + 2-2022 + B1 + Fairly Paid + Historian of Cattle + + + 6,185 + 5-2010 + A2 + Overpaid + Skydiving Instructor Laureate + + + 6,186 + 6-2016 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 6,187 + 4-2002 + A2 + Overpaid + Vigilante for Schools + + + 6,188 + 4-2015 + B1 + Fairly Paid + Historian of Parties + + + 6,189 + 4-1995 + A2 + Overpaid + Historian in Chief + + + 6,190 + 1-1994 + C1 + Massively Underpaid + Author Laureate + + + 6,191 + 10-1997 + C2 + Slave Labour + Historian for the Environment + + + 6,192 + 12-2010 + B1 + Fairly Paid + Historian (Trainee) + + + 6,193 + 1-2006 + A1 + Massively Overpaid + Builder for Schools + + + 6,194 + 8-2022 + C2 + Slave Labour + Historian for Eternity + + + 6,195 + 5-2000 + B1 + Fairly Paid + Vigilante for Eternity + + + 6,196 + 2-2015 + C2 + Slave Labour + Builder for the Environment + + + 6,197 + 10-2014 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 6,198 + 7-1991 + A2 + Overpaid + Food Taster of Parties + + + 6,199 + 3-2020 + C2 + Slave Labour + Assassin of Doom + + + 6,200 + 7-1994 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 6,201 + 10-2010 + A1 + Massively Overpaid + Historian Extraordinaire + + + 6,202 + 6-1999 + A2 + Overpaid + Vigilante Laureate + + + 6,203 + 2-2014 + A1 + Massively Overpaid + Software Developer of Cattle + + + 6,204 + 5-1993 + B2 + Underpaid + Builder (Trainee) + + + 6,205 + 10-1993 + C1 + Massively Underpaid + Food Taster of Doom + + + 6,206 + 7-2011 + B1 + Fairly Paid + Assassin Extraordinaire + + + 6,207 + 5-2006 + C2 + Slave Labour + Historian for the Environment + + + 6,208 + 2-2019 + C2 + Slave Labour + Sports Mascot for Schools + + + 6,209 + 11-1991 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 6,210 + 2-2016 + B1 + Fairly Paid + Historian for Schools + + + 6,211 + 6-1994 + B1 + Fairly Paid + Software Developer Laureate + + + 6,212 + 6-2017 + C2 + Slave Labour + Food Taster Extraordinaire + + + 6,213 + 11-1995 + A2 + Overpaid + Assassin for Eternity + + + 6,214 + 5-2016 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 6,215 + 3-1991 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 6,216 + 12-1993 + C1 + Massively Underpaid + Software Developer of Parties + + + 6,217 + 5-1993 + A1 + Massively Overpaid + Assassin of Parties + + + 6,218 + 10-2010 + A2 + Overpaid + Vigilante of Parties + + + 6,219 + 7-1994 + A2 + Overpaid + Sports Mascot of Cattle + + + 6,220 + 6-2008 + B2 + Underpaid + Author of Doom + + + 6,221 + 5-2016 + A2 + Overpaid + Assassin of Cattle + + + 6,222 + 7-2005 + C1 + Massively Underpaid + Historian (Trainee) + + + 6,223 + 2-1990 + B1 + Fairly Paid + Author Laureate + + + 6,224 + 6-2015 + B1 + Fairly Paid + Sports Mascot of Parties + + + 6,225 + 1-2012 + B1 + Fairly Paid + Food Taster for the Environment + + + 6,226 + 12-2013 + A1 + Massively Overpaid + Vigilante of Doom + + + 6,227 + 5-2007 + C2 + Slave Labour + Author of Cattle + + + 6,228 + 1-2008 + A1 + Massively Overpaid + Food Taster of Parties + + + 6,229 + 10-2012 + A2 + Overpaid + Author of Doom + + + 6,230 + 1-2021 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 6,231 + 8-2015 + B1 + Fairly Paid + Software Developer for the Environment + + + 6,232 + 7-2003 + A1 + Massively Overpaid + Philosopher for Schools + + + 6,233 + 4-1994 + A1 + Massively Overpaid + Builder for Schools + + + 6,234 + 12-1991 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 6,235 + 2-2012 + C2 + Slave Labour + Author of Parties + + + 6,236 + 9-2004 + C2 + Slave Labour + Philosopher for Schools + + + 6,237 + 1-2018 + A2 + Overpaid + Sports Mascot of Doom + + + 6,238 + 11-2014 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 6,239 + 1-1999 + A1 + Massively Overpaid + Philosopher for Eternity + + + 6,240 + 11-2005 + B1 + Fairly Paid + Vigilante Laureate + + + 6,241 + 4-2008 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 6,242 + 6-2008 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 6,243 + 11-2018 + B2 + Underpaid + Food Taster for Eternity + + + 6,244 + 2-2008 + A2 + Overpaid + Author Extraordinaire + + + 6,245 + 5-2000 + B2 + Underpaid + Sports Mascot of Parties + + + 6,246 + 10-1992 + C2 + Slave Labour + Vigilante for Schools + + + 6,247 + 12-1990 + A1 + Massively Overpaid + Author of Parties + + + 6,248 + 2-2003 + C2 + Slave Labour + Software Developer of Cattle + + + 6,249 + 9-1993 + C2 + Slave Labour + Philosopher (Trainee) + + + 6,250 + 2-2022 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 6,251 + 5-1997 + B2 + Underpaid + Vigilante of Parties + + + 6,252 + 5-2000 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 6,253 + 6-2019 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 6,254 + 4-2022 + A1 + Massively Overpaid + Assassin for Eternity + + + 6,255 + 2-2005 + A2 + Overpaid + Skydiving Instructor Laureate + + + 6,256 + 3-2014 + C1 + Massively Underpaid + Author of Cattle + + + 6,257 + 9-2016 + C2 + Slave Labour + Builder (Trainee) + + + 6,258 + 9-1995 + A1 + Massively Overpaid + Vigilante of Doom + + + 6,259 + 8-1995 + B1 + Fairly Paid + Assassin Trainer + + + 6,260 + 3-1995 + B2 + Underpaid + Assassin for Eternity + + + 6,261 + 4-2006 + B2 + Underpaid + Vigilante Laureate + + + 6,262 + 2-1996 + B1 + Fairly Paid + Vigilante in Chief + + + 6,263 + 6-2008 + C1 + Massively Underpaid + Builder Trainer + + + 6,264 + 12-2009 + B2 + Underpaid + Author Laureate + + + 6,265 + 6-2013 + C1 + Massively Underpaid + Assassin for Eternity + + + 6,266 + 11-2010 + B1 + Fairly Paid + Software Developer Laureate + + + 6,267 + 11-1992 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 6,268 + 5-2023 + C2 + Slave Labour + Vigilante for Schools + + + 6,269 + 1-1994 + C1 + Massively Underpaid + Builder in Chief + + + 6,270 + 10-2021 + B2 + Underpaid + Assassin of Cattle + + + 6,271 + 9-2009 + A2 + Overpaid + Author for the Environment + + + 6,272 + 6-2022 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 6,273 + 8-2004 + C2 + Slave Labour + Philosopher of Parties + + + 6,274 + 1-2022 + C1 + Massively Underpaid + Philosopher of Parties + + + 6,275 + 5-2008 + A1 + Massively Overpaid + Philosopher Trainer + + + 6,276 + 11-2004 + A1 + Massively Overpaid + Assassin of Doom + + + 6,277 + 3-2001 + C1 + Massively Underpaid + Assassin of Parties + + + 6,278 + 9-2017 + B2 + Underpaid + Vigilante of Doom + + + 6,279 + 7-1998 + B2 + Underpaid + Philosopher (Trainee) + + + 6,280 + 3-2002 + A2 + Overpaid + Builder for the Environment + + + 6,281 + 4-1998 + B2 + Underpaid + Philosopher Trainer + + + 6,282 + 2-2008 + B2 + Underpaid + Vigilante of Parties + + + 6,283 + 9-2006 + A1 + Massively Overpaid + Philosopher in Chief + + + 6,284 + 10-1992 + C2 + Slave Labour + Historian for Schools + + + 6,285 + 11-2014 + A2 + Overpaid + Builder (Trainee) + + + 6,286 + 3-2001 + C1 + Massively Underpaid + Assassin Laureate + + + 6,287 + 8-2016 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 6,288 + 2-1991 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 6,289 + 10-2006 + C2 + Slave Labour + Historian in Chief + + + 6,290 + 11-2015 + C2 + Slave Labour + Sports Mascot in Chief + + + 6,291 + 3-1993 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 6,292 + 4-1990 + C2 + Slave Labour + Software Developer Extraordinaire + + + 6,293 + 6-2006 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 6,294 + 3-2005 + B1 + Fairly Paid + Sports Mascot for Schools + + + 6,295 + 8-2021 + B1 + Fairly Paid + Sports Mascot for Schools + + + 6,296 + 6-2012 + A1 + Massively Overpaid + Vigilante Laureate + + + 6,297 + 2-2018 + B2 + Underpaid + Software Developer Trainer + + + 6,298 + 8-2013 + C2 + Slave Labour + Historian for Schools + + + 6,299 + 12-2019 + A1 + Massively Overpaid + Historian Extraordinaire + + + 6,300 + 5-2013 + B2 + Underpaid + Sports Mascot of Parties + + + 6,301 + 4-1997 + C2 + Slave Labour + Assassin in Chief + + + 6,302 + 10-2001 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 6,303 + 3-1990 + A2 + Overpaid + Sports Mascot of Parties + + + 6,304 + 4-2017 + B1 + Fairly Paid + Author of Cattle + + + 6,305 + 10-1998 + A1 + Massively Overpaid + Historian in Chief + + + 6,306 + 11-2020 + A1 + Massively Overpaid + Philosopher Trainer + + + 6,307 + 1-2019 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 6,308 + 5-2018 + C1 + Massively Underpaid + Author for Schools + + + 6,309 + 11-2020 + A1 + Massively Overpaid + Assassin of Parties + + + 6,310 + 1-2002 + C2 + Slave Labour + Builder for the Environment + + + 6,311 + 9-2010 + B2 + Underpaid + Food Taster Trainer + + + 6,312 + 4-2010 + C2 + Slave Labour + Assassin of Doom + + + 6,313 + 6-2017 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 6,314 + 9-1996 + C2 + Slave Labour + Software Developer Trainer + + + 6,315 + 12-2014 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 6,316 + 6-1995 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 6,317 + 8-2015 + C2 + Slave Labour + Philosopher in Chief + + + 6,318 + 4-2017 + A2 + Overpaid + Builder for Eternity + + + 6,319 + 6-1991 + B1 + Fairly Paid + Software Developer for Schools + + + 6,320 + 9-2021 + C2 + Slave Labour + Sports Mascot for Eternity + + + 6,321 + 10-2023 + C1 + Massively Underpaid + Philosopher Trainer + + + 6,322 + 10-1990 + B2 + Underpaid + Food Taster (Trainee) + + + 6,323 + 5-1990 + A2 + Overpaid + Sports Mascot (Trainee) + + + 6,324 + 12-2020 + C1 + Massively Underpaid + Builder of Doom + + + 6,325 + 10-1994 + A1 + Massively Overpaid + Software Developer Trainer + + + 6,326 + 11-2014 + B2 + Underpaid + Skydiving Instructor of Doom + + + 6,327 + 6-2007 + B1 + Fairly Paid + Sports Mascot in Chief + + + 6,328 + 3-2020 + A2 + Overpaid + Vigilante in Chief + + + 6,329 + 8-2006 + A2 + Overpaid + Author in Chief + + + 6,330 + 7-2021 + B2 + Underpaid + Vigilante Trainer + + + 6,331 + 2-2011 + C2 + Slave Labour + Philosopher of Parties + + + 6,332 + 1-2022 + C2 + Slave Labour + Vigilante Trainer + + + 6,333 + 4-2004 + A2 + Overpaid + Food Taster of Parties + + + 6,334 + 2-1994 + A2 + Overpaid + Sports Mascot for Schools + + + 6,335 + 6-2017 + A1 + Massively Overpaid + Vigilante of Cattle + + + 6,336 + 1-2022 + B2 + Underpaid + Software Developer for Eternity + + + 6,337 + 3-2005 + B2 + Underpaid + Philosopher for Schools + + + 6,338 + 11-2017 + A2 + Overpaid + Author for the Environment + + + 6,339 + 3-1995 + B1 + Fairly Paid + Assassin of Doom + + + 6,340 + 8-2009 + C2 + Slave Labour + Software Developer Trainer + + + 6,341 + 7-1997 + C1 + Massively Underpaid + Assassin for Eternity + + + 6,342 + 10-1993 + B1 + Fairly Paid + Philosopher for the Environment + + + 6,343 + 5-2004 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 6,344 + 8-2020 + B1 + Fairly Paid + Assassin Laureate + + + 6,345 + 8-1995 + C2 + Slave Labour + Software Developer Laureate + + + 6,346 + 8-2010 + A2 + Overpaid + Author of Parties + + + 6,347 + 2-2010 + B2 + Underpaid + Skydiving Instructor of Parties + + + 6,348 + 11-1991 + C2 + Slave Labour + Software Developer (Trainee) + + + 6,349 + 4-2019 + A2 + Overpaid + Food Taster Extraordinaire + + + 6,350 + 8-2023 + A1 + Massively Overpaid + Historian Laureate + + + 6,351 + 3-2015 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 6,352 + 12-2004 + C2 + Slave Labour + Vigilante for Eternity + + + 6,353 + 12-1994 + B1 + Fairly Paid + Author in Chief + + + 6,354 + 9-1997 + A1 + Massively Overpaid + Assassin (Trainee) + + + 6,355 + 7-1995 + B1 + Fairly Paid + Author Laureate + + + 6,356 + 3-1993 + C1 + Massively Underpaid + Author Laureate + + + 6,357 + 10-1991 + B2 + Underpaid + Author Trainer + + + 6,358 + 3-1998 + C2 + Slave Labour + Builder in Chief + + + 6,359 + 8-2008 + B2 + Underpaid + Sports Mascot in Chief + + + 6,360 + 12-2012 + A2 + Overpaid + Author for Schools + + + 6,361 + 7-2015 + C2 + Slave Labour + Assassin of Parties + + + 6,362 + 4-2021 + A2 + Overpaid + Software Developer in Chief + + + 6,363 + 4-2009 + B1 + Fairly Paid + Builder (Trainee) + + + 6,364 + 1-2004 + C1 + Massively Underpaid + Author for Schools + + + 6,365 + 10-2001 + B1 + Fairly Paid + Food Taster of Cattle + + + 6,366 + 6-2021 + C2 + Slave Labour + Food Taster Trainer + + + 6,367 + 9-1999 + B1 + Fairly Paid + Software Developer of Parties + + + 6,368 + 8-2011 + A1 + Massively Overpaid + Software Developer for Schools + + + 6,369 + 2-1993 + B2 + Underpaid + Historian for Eternity + + + 6,370 + 10-2017 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 6,371 + 2-2013 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 6,372 + 12-1998 + C2 + Slave Labour + Author in Chief + + + 6,373 + 5-2002 + C2 + Slave Labour + Vigilante of Doom + + + 6,374 + 12-2022 + A2 + Overpaid + Assassin Trainer + + + 6,375 + 6-2007 + A2 + Overpaid + Builder of Parties + + + 6,376 + 8-2014 + B1 + Fairly Paid + Philosopher of Doom + + + 6,377 + 5-2006 + A1 + Massively Overpaid + Vigilante of Cattle + + + 6,378 + 8-1996 + A2 + Overpaid + Skydiving Instructor Laureate + + + 6,379 + 10-1998 + B1 + Fairly Paid + Historian for the Environment + + + 6,380 + 7-2019 + C2 + Slave Labour + Builder of Parties + + + 6,381 + 9-2009 + A1 + Massively Overpaid + Software Developer for Schools + + + 6,382 + 10-1991 + A2 + Overpaid + Assassin in Chief + + + 6,383 + 3-2006 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 6,384 + 8-2013 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 6,385 + 1-2019 + C2 + Slave Labour + Assassin Trainer + + + 6,386 + 1-1996 + B1 + Fairly Paid + Sports Mascot Laureate + + + 6,387 + 5-2023 + B2 + Underpaid + Author of Doom + + + 6,388 + 5-2023 + B1 + Fairly Paid + Historian Extraordinaire + + + 6,389 + 3-2014 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 6,390 + 5-2002 + A2 + Overpaid + Software Developer (Trainee) + + + 6,391 + 11-1995 + A2 + Overpaid + Skydiving Instructor of Doom + + + 6,392 + 7-2018 + A2 + Overpaid + Historian for Schools + + + 6,393 + 7-2011 + A2 + Overpaid + Vigilante in Chief + + + 6,394 + 6-1995 + A2 + Overpaid + Software Developer for Schools + + + 6,395 + 4-2015 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 6,396 + 9-2020 + C1 + Massively Underpaid + Philosopher Trainer + + + 6,397 + 2-1993 + A1 + Massively Overpaid + Builder Laureate + + + 6,398 + 10-2019 + A2 + Overpaid + Software Developer Extraordinaire + + + 6,399 + 11-2012 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 6,400 + 6-1998 + A2 + Overpaid + Vigilante of Doom + + + 6,401 + 7-1994 + A1 + Massively Overpaid + Software Developer for Eternity + + + 6,402 + 8-2000 + A2 + Overpaid + Software Developer (Trainee) + + + 6,403 + 3-1991 + B1 + Fairly Paid + Food Taster Laureate + + + 6,404 + 1-2019 + C2 + Slave Labour + Historian (Trainee) + + + 6,405 + 10-2007 + B1 + Fairly Paid + Software Developer for Eternity + + + 6,406 + 12-2008 + B1 + Fairly Paid + Food Taster for Schools + + + 6,407 + 2-2018 + A2 + Overpaid + Philosopher of Doom + + + 6,408 + 12-1992 + B1 + Fairly Paid + Philosopher of Parties + + + 6,409 + 2-2013 + B2 + Underpaid + Food Taster Extraordinaire + + + 6,410 + 6-2023 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 6,411 + 1-2000 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 6,412 + 4-2020 + C1 + Massively Underpaid + Assassin for the Environment + + + 6,413 + 2-1997 + B1 + Fairly Paid + Software Developer of Parties + + + 6,414 + 6-2004 + C2 + Slave Labour + Food Taster for Eternity + + + 6,415 + 10-2007 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 6,416 + 8-2003 + C1 + Massively Underpaid + Vigilante Trainer + + + 6,417 + 11-2021 + C1 + Massively Underpaid + Vigilante in Chief + + + 6,418 + 6-2015 + C2 + Slave Labour + Assassin Trainer + + + 6,419 + 10-1999 + B2 + Underpaid + Builder for the Environment + + + 6,420 + 10-1993 + A1 + Massively Overpaid + Software Developer in Chief + + + 6,421 + 1-2003 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 6,422 + 3-1998 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 6,423 + 5-2020 + B1 + Fairly Paid + Author Laureate + + + 6,424 + 4-2010 + B2 + Underpaid + Assassin Trainer + + + 6,425 + 9-2023 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 6,426 + 2-2017 + C2 + Slave Labour + Assassin Laureate + + + 6,427 + 3-2010 + A1 + Massively Overpaid + Vigilante for Schools + + + 6,428 + 1-1990 + C2 + Slave Labour + Philosopher of Cattle + + + 6,429 + 12-1995 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 6,430 + 3-2006 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 6,431 + 9-1995 + C2 + Slave Labour + Software Developer for the Environment + + + 6,432 + 6-1991 + A2 + Overpaid + Food Taster (Trainee) + + + 6,433 + 3-2009 + A1 + Massively Overpaid + Historian for the Environment + + + 6,434 + 5-2020 + A2 + Overpaid + Assassin of Parties + + + 6,435 + 6-2014 + A2 + Overpaid + Philosopher for Schools + + + 6,436 + 3-2003 + B1 + Fairly Paid + Software Developer for Eternity + + + 6,437 + 2-1998 + A2 + Overpaid + Builder Laureate + + + 6,438 + 9-2008 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 6,439 + 1-2023 + A1 + Massively Overpaid + Vigilante of Cattle + + + 6,440 + 3-2008 + C2 + Slave Labour + Author for Schools + + + 6,441 + 6-2021 + A2 + Overpaid + Assassin of Cattle + + + 6,442 + 5-2013 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 6,443 + 3-2013 + B1 + Fairly Paid + Assassin of Doom + + + 6,444 + 12-1995 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 6,445 + 10-1995 + A1 + Massively Overpaid + Assassin Laureate + + + 6,446 + 4-2023 + C2 + Slave Labour + Historian for Schools + + + 6,447 + 12-2000 + A2 + Overpaid + Sports Mascot for Schools + + + 6,448 + 5-1993 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 6,449 + 2-1998 + B2 + Underpaid + Historian Laureate + + + 6,450 + 4-2006 + C1 + Massively Underpaid + Food Taster of Parties + + + 6,451 + 7-2016 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 6,452 + 2-1990 + B1 + Fairly Paid + Author for Eternity + + + 6,453 + 8-1996 + C1 + Massively Underpaid + Food Taster of Cattle + + + 6,454 + 4-2019 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 6,455 + 4-2008 + B2 + Underpaid + Philosopher in Chief + + + 6,456 + 6-2016 + B1 + Fairly Paid + Philosopher Trainer + + + 6,457 + 4-2000 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 6,458 + 4-2017 + C2 + Slave Labour + Software Developer Laureate + + + 6,459 + 12-2015 + C1 + Massively Underpaid + Builder of Cattle + + + 6,460 + 5-2001 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 6,461 + 4-2019 + C2 + Slave Labour + Philosopher of Doom + + + 6,462 + 12-2013 + A2 + Overpaid + Sports Mascot of Cattle + + + 6,463 + 4-2014 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 6,464 + 1-2008 + B2 + Underpaid + Vigilante of Cattle + + + 6,465 + 5-2013 + A2 + Overpaid + Assassin Extraordinaire + + + 6,466 + 4-2009 + C1 + Massively Underpaid + Assassin for the Environment + + + 6,467 + 2-2000 + C1 + Massively Underpaid + Author for the Environment + + + 6,468 + 10-1990 + B2 + Underpaid + Food Taster of Parties + + + 6,469 + 9-1994 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 6,470 + 7-1992 + B1 + Fairly Paid + Historian in Chief + + + 6,471 + 9-2003 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 6,472 + 9-2000 + C1 + Massively Underpaid + Assassin of Cattle + + + 6,473 + 5-2022 + C1 + Massively Underpaid + Assassin Laureate + + + 6,474 + 7-2012 + C2 + Slave Labour + Vigilante Extraordinaire + + + 6,475 + 11-2017 + A1 + Massively Overpaid + Author in Chief + + + 6,476 + 1-2007 + B1 + Fairly Paid + Software Developer of Parties + + + 6,477 + 10-2008 + B1 + Fairly Paid + Food Taster for Schools + + + 6,478 + 1-1995 + A1 + Massively Overpaid + Historian of Parties + + + 6,479 + 10-2011 + B2 + Underpaid + Software Developer Extraordinaire + + + 6,480 + 7-2020 + C2 + Slave Labour + Philosopher of Cattle + + + 6,481 + 5-2008 + A2 + Overpaid + Food Taster of Parties + + + 6,482 + 2-2018 + B2 + Underpaid + Philosopher Trainer + + + 6,483 + 8-2017 + A1 + Massively Overpaid + Builder of Cattle + + + 6,484 + 6-2012 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 6,485 + 4-2022 + C2 + Slave Labour + Food Taster of Parties + + + 6,486 + 9-2000 + A1 + Massively Overpaid + Philosopher of Doom + + + 6,487 + 3-2016 + A2 + Overpaid + Software Developer for the Environment + + + 6,488 + 10-2016 + A2 + Overpaid + Philosopher Laureate + + + 6,489 + 1-1992 + B1 + Fairly Paid + Author in Chief + + + 6,490 + 4-2013 + A2 + Overpaid + Philosopher of Parties + + + 6,491 + 9-2019 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 6,492 + 5-2005 + B2 + Underpaid + Historian (Trainee) + + + 6,493 + 2-1999 + A1 + Massively Overpaid + Vigilante in Chief + + + 6,494 + 10-2009 + C1 + Massively Underpaid + Vigilante Laureate + + + 6,495 + 5-2006 + C1 + Massively Underpaid + Philosopher of Parties + + + 6,496 + 6-2001 + B2 + Underpaid + Sports Mascot Laureate + + + 6,497 + 9-1990 + A2 + Overpaid + Assassin in Chief + + + 6,498 + 11-2004 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 6,499 + 8-1992 + C1 + Massively Underpaid + Food Taster of Doom + + + 6,500 + 11-1990 + A1 + Massively Overpaid + Vigilante Laureate + + + 6,501 + 11-2015 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 6,502 + 11-2011 + A2 + Overpaid + Vigilante of Parties + + + 6,503 + 3-1995 + A1 + Massively Overpaid + Software Developer Laureate + + + 6,504 + 3-2012 + A2 + Overpaid + Software Developer of Parties + + + 6,505 + 12-2004 + A2 + Overpaid + Builder Laureate + + + 6,506 + 5-2015 + B2 + Underpaid + Author Laureate + + + 6,507 + 6-2002 + B2 + Underpaid + Author for Schools + + + 6,508 + 9-2004 + B2 + Underpaid + Historian Trainer + + + 6,509 + 3-2015 + C2 + Slave Labour + Food Taster for Schools + + + 6,510 + 12-2014 + A2 + Overpaid + Skydiving Instructor Trainer + + + 6,511 + 2-2001 + C1 + Massively Underpaid + Food Taster Laureate + + + 6,512 + 1-2020 + B1 + Fairly Paid + Assassin Laureate + + + 6,513 + 4-2009 + C1 + Massively Underpaid + Food Taster Trainer + + + 6,514 + 9-1998 + B1 + Fairly Paid + Philosopher Laureate + + + 6,515 + 8-2001 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 6,516 + 12-2017 + B2 + Underpaid + Assassin of Parties + + + 6,517 + 1-2013 + A1 + Massively Overpaid + Food Taster of Doom + + + 6,518 + 12-2010 + A1 + Massively Overpaid + Author in Chief + + + 6,519 + 6-1992 + B1 + Fairly Paid + Author for the Environment + + + 6,520 + 3-2021 + A2 + Overpaid + Food Taster of Doom + + + 6,521 + 7-2016 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 6,522 + 10-2012 + C2 + Slave Labour + Historian for Schools + + + 6,523 + 3-1991 + C1 + Massively Underpaid + Assassin of Parties + + + 6,524 + 9-1998 + B1 + Fairly Paid + Sports Mascot Trainer + + + 6,525 + 9-2015 + B1 + Fairly Paid + Philosopher of Doom + + + 6,526 + 11-1996 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 6,527 + 2-1991 + A2 + Overpaid + Assassin for Eternity + + + 6,528 + 6-2005 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 6,529 + 12-2014 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 6,530 + 4-2012 + A2 + Overpaid + Historian in Chief + + + 6,531 + 12-1995 + C2 + Slave Labour + Philosopher Laureate + + + 6,532 + 8-2011 + B2 + Underpaid + Historian of Doom + + + 6,533 + 12-2019 + C2 + Slave Labour + Historian for Schools + + + 6,534 + 12-2010 + B1 + Fairly Paid + Philosopher for Schools + + + 6,535 + 12-2019 + A1 + Massively Overpaid + Vigilante of Parties + + + 6,536 + 9-2010 + A2 + Overpaid + Food Taster for Eternity + + + 6,537 + 2-2020 + B1 + Fairly Paid + Author Laureate + + + 6,538 + 3-2014 + B1 + Fairly Paid + Assassin for Schools + + + 6,539 + 12-2013 + B1 + Fairly Paid + Vigilante of Parties + + + 6,540 + 7-2005 + B1 + Fairly Paid + Software Developer for Schools + + + 6,541 + 3-1996 + C2 + Slave Labour + Author (Trainee) + + + 6,542 + 2-2014 + B2 + Underpaid + Food Taster for the Environment + + + 6,543 + 2-1994 + C2 + Slave Labour + Author of Doom + + + 6,544 + 7-2008 + A2 + Overpaid + Historian Extraordinaire + + + 6,545 + 4-1991 + B2 + Underpaid + Food Taster for Schools + + + 6,546 + 1-2021 + A2 + Overpaid + Builder (Trainee) + + + 6,547 + 4-2002 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 6,548 + 1-2019 + B1 + Fairly Paid + Software Developer (Trainee) + + + 6,549 + 3-2012 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 6,550 + 7-2007 + B1 + Fairly Paid + Author for Schools + + + 6,551 + 5-1996 + B1 + Fairly Paid + Builder (Trainee) + + + 6,552 + 2-2003 + C1 + Massively Underpaid + Builder for Schools + + + 6,553 + 6-2013 + C2 + Slave Labour + Philosopher Extraordinaire + + + 6,554 + 9-1993 + A2 + Overpaid + Assassin Trainer + + + 6,555 + 9-2020 + A2 + Overpaid + Assassin for the Environment + + + 6,556 + 1-2011 + C1 + Massively Underpaid + Vigilante of Doom + + + 6,557 + 11-2010 + A2 + Overpaid + Vigilante in Chief + + + 6,558 + 1-1991 + C1 + Massively Underpaid + Author for Schools + + + 6,559 + 1-1990 + C1 + Massively Underpaid + Food Taster of Doom + + + 6,560 + 12-2010 + A2 + Overpaid + Author Trainer + + + 6,561 + 2-1995 + A1 + Massively Overpaid + Software Developer of Doom + + + 6,562 + 8-2008 + B2 + Underpaid + Builder Extraordinaire + + + 6,563 + 1-1996 + A2 + Overpaid + Food Taster in Chief + + + 6,564 + 6-1997 + C1 + Massively Underpaid + Builder of Doom + + + 6,565 + 3-2001 + C1 + Massively Underpaid + Author Extraordinaire + + + 6,566 + 10-2012 + B1 + Fairly Paid + Historian for Eternity + + + 6,567 + 11-1991 + B1 + Fairly Paid + Historian (Trainee) + + + 6,568 + 2-2023 + C2 + Slave Labour + Food Taster of Parties + + + 6,569 + 4-2019 + A2 + Overpaid + Builder Trainer + + + 6,570 + 9-2000 + B2 + Underpaid + Software Developer Laureate + + + 6,571 + 4-2016 + C2 + Slave Labour + Author Trainer + + + 6,572 + 2-1990 + A2 + Overpaid + Food Taster Laureate + + + 6,573 + 11-2013 + A2 + Overpaid + Builder in Chief + + + 6,574 + 1-2004 + B2 + Underpaid + Author in Chief + + + 6,575 + 5-1997 + A2 + Overpaid + Vigilante Laureate + + + 6,576 + 6-1992 + A2 + Overpaid + Vigilante for the Environment + + + 6,577 + 4-1992 + A1 + Massively Overpaid + Vigilante of Doom + + + 6,578 + 10-2023 + C1 + Massively Underpaid + Builder of Parties + + + 6,579 + 11-2009 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 6,580 + 9-2019 + B1 + Fairly Paid + Assassin (Trainee) + + + 6,581 + 10-1999 + B2 + Underpaid + Vigilante (Trainee) + + + 6,582 + 2-2012 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 6,583 + 9-2006 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 6,584 + 10-1993 + A2 + Overpaid + Historian Extraordinaire + + + 6,585 + 3-1997 + C1 + Massively Underpaid + Author of Cattle + + + 6,586 + 10-2003 + A2 + Overpaid + Assassin for Eternity + + + 6,587 + 5-2011 + B1 + Fairly Paid + Builder Extraordinaire + + + 6,588 + 1-1998 + B2 + Underpaid + Philosopher Trainer + + + 6,589 + 3-2019 + B2 + Underpaid + Assassin for Eternity + + + 6,590 + 12-1997 + B2 + Underpaid + Software Developer of Cattle + + + 6,591 + 1-1993 + C2 + Slave Labour + Author of Cattle + + + 6,592 + 1-1998 + C1 + Massively Underpaid + Food Taster of Doom + + + 6,593 + 2-1991 + B2 + Underpaid + Historian of Parties + + + 6,594 + 2-1998 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 6,595 + 8-1992 + C1 + Massively Underpaid + Historian of Parties + + + 6,596 + 5-2013 + A2 + Overpaid + Food Taster Trainer + + + 6,597 + 2-1999 + B1 + Fairly Paid + Food Taster Laureate + + + 6,598 + 5-2007 + C2 + Slave Labour + Vigilante (Trainee) + + + 6,599 + 12-2005 + C2 + Slave Labour + Assassin for Schools + + + 6,600 + 10-1997 + A2 + Overpaid + Software Developer of Cattle + + + 6,601 + 1-2019 + A1 + Massively Overpaid + Philosopher for Schools + + + 6,602 + 3-2021 + A1 + Massively Overpaid + Assassin (Trainee) + + + 6,603 + 11-2002 + A2 + Overpaid + Author (Trainee) + + + 6,604 + 8-1999 + C2 + Slave Labour + Builder (Trainee) + + + 6,605 + 7-1994 + B1 + Fairly Paid + Historian Trainer + + + 6,606 + 7-1992 + A2 + Overpaid + Builder (Trainee) + + + 6,607 + 7-2021 + C1 + Massively Underpaid + Builder for Schools + + + 6,608 + 10-1998 + C2 + Slave Labour + Assassin of Cattle + + + 6,609 + 8-2001 + B1 + Fairly Paid + Assassin for Eternity + + + 6,610 + 12-2020 + C1 + Massively Underpaid + Assassin of Parties + + + 6,611 + 11-2001 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 6,612 + 1-2016 + B2 + Underpaid + Sports Mascot for Schools + + + 6,613 + 5-2004 + A2 + Overpaid + Food Taster of Cattle + + + 6,614 + 3-1998 + A1 + Massively Overpaid + Vigilante for Schools + + + 6,615 + 12-1990 + B1 + Fairly Paid + Software Developer Laureate + + + 6,616 + 8-2021 + B2 + Underpaid + Software Developer Trainer + + + 6,617 + 5-2012 + B1 + Fairly Paid + Philosopher of Cattle + + + 6,618 + 1-1994 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 6,619 + 12-2021 + B2 + Underpaid + Builder (Trainee) + + + 6,620 + 5-1990 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 6,621 + 8-2018 + B2 + Underpaid + Assassin for the Environment + + + 6,622 + 10-2013 + C1 + Massively Underpaid + Author of Doom + + + 6,623 + 6-1990 + B2 + Underpaid + Software Developer (Trainee) + + + 6,624 + 8-2000 + A1 + Massively Overpaid + Builder in Chief + + + 6,625 + 4-1998 + B2 + Underpaid + Skydiving Instructor of Parties + + + 6,626 + 8-2006 + C1 + Massively Underpaid + Software Developer of Cattle + + + 6,627 + 8-2013 + A2 + Overpaid + Vigilante for Eternity + + + 6,628 + 10-1999 + C2 + Slave Labour + Assassin of Cattle + + + 6,629 + 8-2001 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 6,630 + 2-2003 + B1 + Fairly Paid + Historian Trainer + + + 6,631 + 11-1997 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 6,632 + 11-1992 + A1 + Massively Overpaid + Author Extraordinaire + + + 6,633 + 5-2023 + A2 + Overpaid + Builder Laureate + + + 6,634 + 12-2007 + C2 + Slave Labour + Sports Mascot of Cattle + + + 6,635 + 1-1995 + A2 + Overpaid + Sports Mascot of Doom + + + 6,636 + 4-1990 + A2 + Overpaid + Vigilante for Schools + + + 6,637 + 3-2006 + B2 + Underpaid + Assassin of Cattle + + + 6,638 + 3-2004 + C1 + Massively Underpaid + Food Taster in Chief + + + 6,639 + 4-1996 + C1 + Massively Underpaid + Author for Eternity + + + 6,640 + 6-2021 + C2 + Slave Labour + Author Extraordinaire + + + 6,641 + 1-2017 + C2 + Slave Labour + Vigilante of Doom + + + 6,642 + 6-2008 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 6,643 + 6-1999 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 6,644 + 3-2018 + A2 + Overpaid + Sports Mascot of Parties + + + 6,645 + 2-1994 + C2 + Slave Labour + Assassin for Schools + + + 6,646 + 4-2007 + A2 + Overpaid + Assassin for Eternity + + + 6,647 + 8-2002 + A2 + Overpaid + Author Laureate + + + 6,648 + 8-2005 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 6,649 + 9-2009 + C2 + Slave Labour + Software Developer for Eternity + + + 6,650 + 10-2012 + B2 + Underpaid + Philosopher of Parties + + + 6,651 + 5-2015 + B2 + Underpaid + Philosopher of Cattle + + + 6,652 + 6-2019 + C1 + Massively Underpaid + Food Taster Laureate + + + 6,653 + 11-2003 + C2 + Slave Labour + Food Taster for Schools + + + 6,654 + 5-2009 + B2 + Underpaid + Software Developer (Trainee) + + + 6,655 + 1-2011 + A2 + Overpaid + Historian for Eternity + + + 6,656 + 6-1997 + B1 + Fairly Paid + Vigilante (Trainee) + + + 6,657 + 8-1991 + A2 + Overpaid + Assassin (Trainee) + + + 6,658 + 4-2006 + A1 + Massively Overpaid + Philosopher in Chief + + + 6,659 + 3-2003 + C1 + Massively Underpaid + Software Developer of Cattle + + + 6,660 + 7-1994 + A2 + Overpaid + Historian Laureate + + + 6,661 + 8-2020 + A2 + Overpaid + Food Taster in Chief + + + 6,662 + 12-2012 + A1 + Massively Overpaid + Philosopher for Eternity + + + 6,663 + 4-2001 + A2 + Overpaid + Philosopher for the Environment + + + 6,664 + 6-1990 + C1 + Massively Underpaid + Historian of Cattle + + + 6,665 + 1-2020 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 6,666 + 5-2018 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 6,667 + 9-1999 + C2 + Slave Labour + Author of Parties + + + 6,668 + 10-2009 + B2 + Underpaid + Sports Mascot Trainer + + + 6,669 + 2-1996 + A2 + Overpaid + Vigilante (Trainee) + + + 6,670 + 10-2022 + C2 + Slave Labour + Food Taster in Chief + + + 6,671 + 9-2020 + B2 + Underpaid + Software Developer for Schools + + + 6,672 + 3-1991 + B1 + Fairly Paid + Software Developer of Doom + + + 6,673 + 10-2007 + A2 + Overpaid + Vigilante in Chief + + + 6,674 + 5-2004 + C2 + Slave Labour + Philosopher for Eternity + + + 6,675 + 4-1997 + A2 + Overpaid + Historian Trainer + + + 6,676 + 9-1993 + B1 + Fairly Paid + Food Taster of Doom + + + 6,677 + 11-2020 + C2 + Slave Labour + Author Extraordinaire + + + 6,678 + 12-2014 + B2 + Underpaid + Assassin in Chief + + + 6,679 + 12-1992 + C2 + Slave Labour + Sports Mascot of Cattle + + + 6,680 + 9-2003 + A2 + Overpaid + Food Taster in Chief + + + 6,681 + 10-1999 + C2 + Slave Labour + Vigilante of Cattle + + + 6,682 + 4-1999 + B2 + Underpaid + Vigilante in Chief + + + 6,683 + 10-2020 + B1 + Fairly Paid + Author of Cattle + + + 6,684 + 10-2016 + A2 + Overpaid + Software Developer for Schools + + + 6,685 + 5-1998 + B2 + Underpaid + Skydiving Instructor Laureate + + + 6,686 + 8-2020 + A2 + Overpaid + Skydiving Instructor of Doom + + + 6,687 + 12-2008 + B2 + Underpaid + Assassin (Trainee) + + + 6,688 + 7-2009 + A1 + Massively Overpaid + Vigilante for Eternity + + + 6,689 + 3-1992 + B2 + Underpaid + Philosopher Laureate + + + 6,690 + 7-2002 + C2 + Slave Labour + Assassin in Chief + + + 6,691 + 7-2003 + B2 + Underpaid + Historian in Chief + + + 6,692 + 6-2005 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 6,693 + 11-2015 + B1 + Fairly Paid + Sports Mascot Trainer + + + 6,694 + 11-1995 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 6,695 + 11-2007 + B1 + Fairly Paid + Philosopher for the Environment + + + 6,696 + 9-2010 + B1 + Fairly Paid + Builder for Eternity + + + 6,697 + 4-2022 + C2 + Slave Labour + Author Trainer + + + 6,698 + 2-2020 + B2 + Underpaid + Assassin of Parties + + + 6,699 + 4-2021 + B2 + Underpaid + Philosopher of Cattle + + + 6,700 + 1-1995 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 6,701 + 6-1990 + C2 + Slave Labour + Software Developer for the Environment + + + 6,702 + 6-2019 + C2 + Slave Labour + Software Developer Laureate + + + 6,703 + 4-1991 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 6,704 + 12-2009 + C2 + Slave Labour + Author for Schools + + + 6,705 + 12-1999 + B1 + Fairly Paid + Software Developer Trainer + + + 6,706 + 5-2018 + A1 + Massively Overpaid + Author Trainer + + + 6,707 + 10-2008 + A1 + Massively Overpaid + Food Taster Trainer + + + 6,708 + 5-2016 + A2 + Overpaid + Sports Mascot for Eternity + + + 6,709 + 9-2016 + A1 + Massively Overpaid + Builder Trainer + + + 6,710 + 12-2000 + B2 + Underpaid + Builder (Trainee) + + + 6,711 + 4-1993 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 6,712 + 11-1998 + C2 + Slave Labour + Philosopher of Cattle + + + 6,713 + 12-2000 + A1 + Massively Overpaid + Builder for Eternity + + + 6,714 + 8-2006 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 6,715 + 8-2019 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 6,716 + 6-2012 + B1 + Fairly Paid + Historian in Chief + + + 6,717 + 12-2002 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 6,718 + 3-2007 + A2 + Overpaid + Sports Mascot of Parties + + + 6,719 + 5-2003 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 6,720 + 10-2021 + A2 + Overpaid + Builder Laureate + + + 6,721 + 1-2008 + A1 + Massively Overpaid + Food Taster in Chief + + + 6,722 + 5-2018 + A2 + Overpaid + Author of Cattle + + + 6,723 + 12-2017 + C1 + Massively Underpaid + Vigilante of Doom + + + 6,724 + 5-2009 + C2 + Slave Labour + Builder of Doom + + + 6,725 + 10-2011 + A1 + Massively Overpaid + Builder Extraordinaire + + + 6,726 + 8-2006 + B2 + Underpaid + Historian Extraordinaire + + + 6,727 + 9-2020 + A2 + Overpaid + Vigilante Trainer + + + 6,728 + 4-2008 + B2 + Underpaid + Philosopher in Chief + + + 6,729 + 7-2013 + C1 + Massively Underpaid + Philosopher of Doom + + + 6,730 + 6-2004 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 6,731 + 10-2001 + C1 + Massively Underpaid + Builder for Schools + + + 6,732 + 3-2018 + A2 + Overpaid + Historian of Doom + + + 6,733 + 2-2012 + B1 + Fairly Paid + Philosopher Laureate + + + 6,734 + 5-2018 + A1 + Massively Overpaid + Historian of Doom + + + 6,735 + 3-2015 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 6,736 + 10-2000 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 6,737 + 6-2015 + A2 + Overpaid + Historian of Doom + + + 6,738 + 10-2014 + C1 + Massively Underpaid + Author of Parties + + + 6,739 + 8-2022 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 6,740 + 4-1999 + A2 + Overpaid + Software Developer of Cattle + + + 6,741 + 1-1994 + C1 + Massively Underpaid + Assassin for Schools + + + 6,742 + 2-2015 + A1 + Massively Overpaid + Philosopher in Chief + + + 6,743 + 8-2010 + B2 + Underpaid + Builder Trainer + + + 6,744 + 7-2001 + C1 + Massively Underpaid + Author for Eternity + + + 6,745 + 4-2019 + A2 + Overpaid + Philosopher Trainer + + + 6,746 + 10-2010 + A2 + Overpaid + Food Taster Extraordinaire + + + 6,747 + 2-1996 + B2 + Underpaid + Assassin for Eternity + + + 6,748 + 11-2023 + B2 + Underpaid + Food Taster (Trainee) + + + 6,749 + 9-2014 + C1 + Massively Underpaid + Builder of Cattle + + + 6,750 + 3-2010 + B1 + Fairly Paid + Philosopher of Parties + + + 6,751 + 7-2002 + C1 + Massively Underpaid + Software Developer for Eternity + + + 6,752 + 7-2011 + B1 + Fairly Paid + Philosopher of Doom + + + 6,753 + 5-2014 + B1 + Fairly Paid + Vigilante Trainer + + + 6,754 + 2-1998 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 6,755 + 7-2017 + C1 + Massively Underpaid + Software Developer in Chief + + + 6,756 + 2-1997 + A2 + Overpaid + Builder (Trainee) + + + 6,757 + 3-2011 + C1 + Massively Underpaid + Author for the Environment + + + 6,758 + 6-1999 + A2 + Overpaid + Assassin Laureate + + + 6,759 + 1-2019 + A2 + Overpaid + Builder of Cattle + + + 6,760 + 2-2011 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 6,761 + 5-1998 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 6,762 + 11-2009 + B1 + Fairly Paid + Historian of Cattle + + + 6,763 + 10-2011 + B2 + Underpaid + Vigilante for Schools + + + 6,764 + 3-1991 + A2 + Overpaid + Software Developer of Cattle + + + 6,765 + 5-1991 + A2 + Overpaid + Food Taster Laureate + + + 6,766 + 11-2020 + C2 + Slave Labour + Historian Extraordinaire + + + 6,767 + 5-2000 + C2 + Slave Labour + Assassin of Cattle + + + 6,768 + 8-2015 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 6,769 + 1-2012 + A1 + Massively Overpaid + Builder (Trainee) + + + 6,770 + 7-1995 + C2 + Slave Labour + Vigilante Extraordinaire + + + 6,771 + 6-2020 + A2 + Overpaid + Food Taster for Schools + + + 6,772 + 11-1997 + A2 + Overpaid + Philosopher in Chief + + + 6,773 + 1-2003 + A1 + Massively Overpaid + Author for the Environment + + + 6,774 + 8-2006 + B2 + Underpaid + Philosopher of Parties + + + 6,775 + 4-2017 + A2 + Overpaid + Food Taster of Parties + + + 6,776 + 9-1992 + A2 + Overpaid + Historian Extraordinaire + + + 6,777 + 5-2014 + A2 + Overpaid + Vigilante for Schools + + + 6,778 + 10-2010 + C1 + Massively Underpaid + Builder Extraordinaire + + + 6,779 + 1-2018 + A2 + Overpaid + Philosopher Laureate + + + 6,780 + 11-2020 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 6,781 + 12-2006 + B1 + Fairly Paid + Builder (Trainee) + + + 6,782 + 7-1992 + B2 + Underpaid + Vigilante in Chief + + + 6,783 + 10-2003 + C1 + Massively Underpaid + Historian (Trainee) + + + 6,784 + 7-1995 + B2 + Underpaid + Historian of Parties + + + 6,785 + 7-2012 + A1 + Massively Overpaid + Historian of Doom + + + 6,786 + 9-2015 + C2 + Slave Labour + Vigilante for Eternity + + + 6,787 + 8-2020 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 6,788 + 11-2009 + A2 + Overpaid + Skydiving Instructor of Doom + + + 6,789 + 3-2007 + C2 + Slave Labour + Vigilante in Chief + + + 6,790 + 9-1995 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 6,791 + 6-2018 + A2 + Overpaid + Skydiving Instructor for Schools + + + 6,792 + 4-1994 + C1 + Massively Underpaid + Food Taster for the Environment + + + 6,793 + 2-2006 + C2 + Slave Labour + Food Taster of Cattle + + + 6,794 + 11-2007 + A2 + Overpaid + Food Taster Trainer + + + 6,795 + 9-2002 + A1 + Massively Overpaid + Assassin of Parties + + + 6,796 + 5-2007 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 6,797 + 5-2011 + A2 + Overpaid + Philosopher for Eternity + + + 6,798 + 10-1999 + B1 + Fairly Paid + Vigilante for the Environment + + + 6,799 + 10-1992 + A2 + Overpaid + Software Developer in Chief + + + 6,800 + 7-2015 + B1 + Fairly Paid + Software Developer in Chief + + + 6,801 + 8-2003 + A1 + Massively Overpaid + Historian Trainer + + + 6,802 + 9-2007 + A1 + Massively Overpaid + Historian of Doom + + + 6,803 + 11-2011 + A2 + Overpaid + Builder of Parties + + + 6,804 + 4-2009 + B2 + Underpaid + Historian for Eternity + + + 6,805 + 2-2021 + C1 + Massively Underpaid + Historian (Trainee) + + + 6,806 + 6-2012 + C2 + Slave Labour + Software Developer in Chief + + + 6,807 + 8-2007 + C1 + Massively Underpaid + Assassin for Eternity + + + 6,808 + 8-2001 + A2 + Overpaid + Author for the Environment + + + 6,809 + 8-2002 + A1 + Massively Overpaid + Food Taster of Doom + + + 6,810 + 4-2018 + B2 + Underpaid + Author in Chief + + + 6,811 + 5-2015 + B1 + Fairly Paid + Software Developer Laureate + + + 6,812 + 3-2016 + C1 + Massively Underpaid + Author for Schools + + + 6,813 + 9-2012 + A2 + Overpaid + Builder (Trainee) + + + 6,814 + 7-1990 + A2 + Overpaid + Assassin of Cattle + + + 6,815 + 6-1994 + B1 + Fairly Paid + Builder for Schools + + + 6,816 + 10-1990 + B2 + Underpaid + Historian for the Environment + + + 6,817 + 7-2017 + C1 + Massively Underpaid + Historian Laureate + + + 6,818 + 11-2015 + C1 + Massively Underpaid + Food Taster of Cattle + + + 6,819 + 11-2018 + B1 + Fairly Paid + Assassin of Cattle + + + 6,820 + 6-1990 + B1 + Fairly Paid + Author in Chief + + + 6,821 + 2-1993 + C1 + Massively Underpaid + Assassin (Trainee) + + + 6,822 + 1-2007 + B1 + Fairly Paid + Sports Mascot Laureate + + + 6,823 + 7-1996 + C2 + Slave Labour + Software Developer of Cattle + + + 6,824 + 10-2018 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 6,825 + 7-2013 + B1 + Fairly Paid + Assassin of Cattle + + + 6,826 + 7-2009 + B2 + Underpaid + Builder (Trainee) + + + 6,827 + 10-1991 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 6,828 + 9-2010 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 6,829 + 12-2018 + C2 + Slave Labour + Philosopher Extraordinaire + + + 6,830 + 8-2004 + C2 + Slave Labour + Food Taster (Trainee) + + + 6,831 + 6-2018 + A2 + Overpaid + Assassin Extraordinaire + + + 6,832 + 2-1998 + B2 + Underpaid + Author of Parties + + + 6,833 + 3-2022 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 6,834 + 12-1994 + C2 + Slave Labour + Builder of Doom + + + 6,835 + 4-1996 + A2 + Overpaid + Assassin Laureate + + + 6,836 + 1-2013 + B2 + Underpaid + Food Taster for Eternity + + + 6,837 + 8-1990 + A2 + Overpaid + Philosopher of Doom + + + 6,838 + 10-1997 + C1 + Massively Underpaid + Philosopher of Cattle + + + 6,839 + 5-2020 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 6,840 + 12-2000 + B1 + Fairly Paid + Software Developer (Trainee) + + + 6,841 + 8-2002 + C2 + Slave Labour + Builder of Parties + + + 6,842 + 7-2004 + C1 + Massively Underpaid + Author of Cattle + + + 6,843 + 1-2006 + B2 + Underpaid + Philosopher Trainer + + + 6,844 + 4-2014 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 6,845 + 7-2005 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 6,846 + 8-2022 + C2 + Slave Labour + Author of Doom + + + 6,847 + 3-2017 + A1 + Massively Overpaid + Food Taster for Schools + + + 6,848 + 7-2020 + C2 + Slave Labour + Sports Mascot Trainer + + + 6,849 + 10-2008 + B2 + Underpaid + Builder for Schools + + + 6,850 + 4-1992 + C2 + Slave Labour + Philosopher for Eternity + + + 6,851 + 3-2016 + B2 + Underpaid + Builder of Doom + + + 6,852 + 9-2005 + B1 + Fairly Paid + Software Developer (Trainee) + + + 6,853 + 8-1997 + C2 + Slave Labour + Sports Mascot Laureate + + + 6,854 + 3-2014 + A2 + Overpaid + Builder of Doom + + + 6,855 + 9-2007 + B2 + Underpaid + Builder Extraordinaire + + + 6,856 + 10-2002 + C2 + Slave Labour + Sports Mascot of Parties + + + 6,857 + 11-2001 + B2 + Underpaid + Skydiving Instructor in Chief + + + 6,858 + 3-2018 + A2 + Overpaid + Skydiving Instructor of Doom + + + 6,859 + 3-2009 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 6,860 + 3-2021 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 6,861 + 3-1990 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 6,862 + 4-2014 + A1 + Massively Overpaid + Historian of Cattle + + + 6,863 + 1-2009 + C1 + Massively Underpaid + Author for Eternity + + + 6,864 + 6-2005 + C2 + Slave Labour + Historian of Parties + + + 6,865 + 7-2011 + B1 + Fairly Paid + Builder for Schools + + + 6,866 + 4-2020 + B1 + Fairly Paid + Builder of Doom + + + 6,867 + 12-1990 + B1 + Fairly Paid + Food Taster Trainer + + + 6,868 + 1-2008 + B2 + Underpaid + Food Taster Trainer + + + 6,869 + 10-1991 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 6,870 + 2-2004 + A2 + Overpaid + Sports Mascot for Eternity + + + 6,871 + 2-2003 + B1 + Fairly Paid + Philosopher of Parties + + + 6,872 + 10-2023 + B2 + Underpaid + Builder (Trainee) + + + 6,873 + 10-2022 + B1 + Fairly Paid + Food Taster of Cattle + + + 6,874 + 12-2002 + B2 + Underpaid + Assassin of Doom + + + 6,875 + 3-1991 + C2 + Slave Labour + Software Developer of Cattle + + + 6,876 + 4-1995 + A1 + Massively Overpaid + Assassin of Parties + + + 6,877 + 8-2018 + A2 + Overpaid + Philosopher for Eternity + + + 6,878 + 8-1995 + C2 + Slave Labour + Historian Laureate + + + 6,879 + 9-1998 + A1 + Massively Overpaid + Food Taster for the Environment + + + 6,880 + 6-2010 + A2 + Overpaid + Builder (Trainee) + + + 6,881 + 8-1999 + B1 + Fairly Paid + Sports Mascot for Schools + + + 6,882 + 8-2007 + B1 + Fairly Paid + Author Laureate + + + 6,883 + 9-2002 + B1 + Fairly Paid + Vigilante of Parties + + + 6,884 + 2-2005 + A2 + Overpaid + Food Taster for Eternity + + + 6,885 + 8-1995 + A2 + Overpaid + Philosopher (Trainee) + + + 6,886 + 10-2001 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 6,887 + 5-2012 + C1 + Massively Underpaid + Food Taster for Schools + + + 6,888 + 3-2000 + A2 + Overpaid + Author Trainer + + + 6,889 + 11-2015 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 6,890 + 2-2015 + C2 + Slave Labour + Author of Parties + + + 6,891 + 10-2005 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 6,892 + 3-2015 + A1 + Massively Overpaid + Builder Trainer + + + 6,893 + 6-2005 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 6,894 + 7-2011 + B1 + Fairly Paid + Software Developer for Eternity + + + 6,895 + 4-1995 + B1 + Fairly Paid + Author of Cattle + + + 6,896 + 2-2016 + A1 + Massively Overpaid + Food Taster Laureate + + + 6,897 + 7-1999 + B2 + Underpaid + Author (Trainee) + + + 6,898 + 9-2018 + B2 + Underpaid + Sports Mascot Laureate + + + 6,899 + 5-1995 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 6,900 + 11-2017 + A2 + Overpaid + Food Taster (Trainee) + + + 6,901 + 6-2007 + C1 + Massively Underpaid + Philosopher of Parties + + + 6,902 + 9-2018 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 6,903 + 11-1990 + A1 + Massively Overpaid + Vigilante Trainer + + + 6,904 + 6-2007 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 6,905 + 12-2000 + A1 + Massively Overpaid + Assassin (Trainee) + + + 6,906 + 11-2005 + A1 + Massively Overpaid + Assassin Laureate + + + 6,907 + 4-1993 + B1 + Fairly Paid + Author of Cattle + + + 6,908 + 9-2000 + C1 + Massively Underpaid + Food Taster of Parties + + + 6,909 + 7-1991 + B1 + Fairly Paid + Author of Doom + + + 6,910 + 5-2002 + C1 + Massively Underpaid + Assassin of Doom + + + 6,911 + 1-2018 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 6,912 + 1-1997 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 6,913 + 4-2020 + B2 + Underpaid + Assassin of Doom + + + 6,914 + 10-2020 + C2 + Slave Labour + Food Taster of Parties + + + 6,915 + 4-2001 + B2 + Underpaid + Assassin in Chief + + + 6,916 + 3-1992 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 6,917 + 12-2002 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 6,918 + 2-2008 + C1 + Massively Underpaid + Food Taster Trainer + + + 6,919 + 4-2009 + A2 + Overpaid + Author of Doom + + + 6,920 + 11-2011 + A2 + Overpaid + Sports Mascot of Parties + + + 6,921 + 6-2002 + A1 + Massively Overpaid + Philosopher of Cattle + + + 6,922 + 5-2018 + B1 + Fairly Paid + Philosopher (Trainee) + + + 6,923 + 11-2013 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 6,924 + 1-2023 + C1 + Massively Underpaid + Software Developer for Schools + + + 6,925 + 9-2001 + C1 + Massively Underpaid + Software Developer for Schools + + + 6,926 + 11-2000 + A2 + Overpaid + Vigilante for Eternity + + + 6,927 + 9-1995 + A2 + Overpaid + Sports Mascot Trainer + + + 6,928 + 11-1993 + A1 + Massively Overpaid + Author Trainer + + + 6,929 + 3-2011 + B1 + Fairly Paid + Builder (Trainee) + + + 6,930 + 10-2011 + B1 + Fairly Paid + Author Trainer + + + 6,931 + 4-2015 + C1 + Massively Underpaid + Philosopher for the Environment + + + 6,932 + 9-2021 + A1 + Massively Overpaid + Vigilante of Cattle + + + 6,933 + 11-2000 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 6,934 + 11-2012 + C2 + Slave Labour + Vigilante for the Environment + + + 6,935 + 4-1998 + C1 + Massively Underpaid + Historian of Doom + + + 6,936 + 11-2014 + C1 + Massively Underpaid + Historian in Chief + + + 6,937 + 5-2013 + A2 + Overpaid + Historian (Trainee) + + + 6,938 + 12-2017 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 6,939 + 11-2013 + A1 + Massively Overpaid + Author of Parties + + + 6,940 + 5-2012 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 6,941 + 3-2020 + C1 + Massively Underpaid + Builder Extraordinaire + + + 6,942 + 7-2009 + C2 + Slave Labour + Vigilante of Doom + + + 6,943 + 10-1991 + A1 + Massively Overpaid + Builder for Schools + + + 6,944 + 9-2004 + C2 + Slave Labour + Builder Laureate + + + 6,945 + 10-2006 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 6,946 + 11-1997 + C2 + Slave Labour + Software Developer of Parties + + + 6,947 + 6-1997 + B2 + Underpaid + Philosopher Trainer + + + 6,948 + 2-1991 + B2 + Underpaid + Vigilante for Eternity + + + 6,949 + 11-2014 + C2 + Slave Labour + Assassin of Parties + + + 6,950 + 3-2000 + B2 + Underpaid + Software Developer Trainer + + + 6,951 + 8-1997 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 6,952 + 10-2011 + C2 + Slave Labour + Assassin for the Environment + + + 6,953 + 1-2015 + A2 + Overpaid + Assassin (Trainee) + + + 6,954 + 1-1991 + B1 + Fairly Paid + Assassin Laureate + + + 6,955 + 7-2002 + C2 + Slave Labour + Vigilante for Eternity + + + 6,956 + 5-2012 + C1 + Massively Underpaid + Software Developer for the Environment + + + 6,957 + 12-2008 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 6,958 + 2-2012 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 6,959 + 2-1992 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 6,960 + 5-2002 + A1 + Massively Overpaid + Historian Laureate + + + 6,961 + 4-2019 + C2 + Slave Labour + Assassin of Cattle + + + 6,962 + 8-2008 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 6,963 + 4-1995 + C1 + Massively Underpaid + Philosopher of Doom + + + 6,964 + 10-1995 + B2 + Underpaid + Author in Chief + + + 6,965 + 8-2012 + C2 + Slave Labour + Assassin for Schools + + + 6,966 + 9-1996 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 6,967 + 9-2005 + A2 + Overpaid + Software Developer Trainer + + + 6,968 + 6-2012 + A2 + Overpaid + Vigilante Trainer + + + 6,969 + 10-1994 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 6,970 + 6-2014 + B2 + Underpaid + Author (Trainee) + + + 6,971 + 2-2014 + B2 + Underpaid + Philosopher Laureate + + + 6,972 + 10-1991 + C2 + Slave Labour + Food Taster in Chief + + + 6,973 + 5-2001 + B1 + Fairly Paid + Software Developer of Cattle + + + 6,974 + 12-1997 + A1 + Massively Overpaid + Philosopher in Chief + + + 6,975 + 8-2010 + A2 + Overpaid + Software Developer for Schools + + + 6,976 + 12-2012 + B2 + Underpaid + Food Taster for the Environment + + + 6,977 + 3-2020 + B2 + Underpaid + Philosopher of Parties + + + 6,978 + 3-2007 + C1 + Massively Underpaid + Food Taster in Chief + + + 6,979 + 8-1991 + A1 + Massively Overpaid + Author Extraordinaire + + + 6,980 + 6-2016 + C2 + Slave Labour + Historian Trainer + + + 6,981 + 12-2023 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 6,982 + 2-1996 + B2 + Underpaid + Software Developer in Chief + + + 6,983 + 11-2020 + A2 + Overpaid + Skydiving Instructor of Parties + + + 6,984 + 9-2006 + B2 + Underpaid + Sports Mascot of Cattle + + + 6,985 + 7-2004 + C2 + Slave Labour + Historian for Eternity + + + 6,986 + 7-1992 + B1 + Fairly Paid + Author Trainer + + + 6,987 + 6-2004 + A2 + Overpaid + Assassin Extraordinaire + + + 6,988 + 7-2013 + A1 + Massively Overpaid + Historian for Eternity + + + 6,989 + 3-1995 + B2 + Underpaid + Author of Cattle + + + 6,990 + 7-1995 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 6,991 + 2-2010 + A2 + Overpaid + Skydiving Instructor of Doom + + + 6,992 + 3-1990 + C1 + Massively Underpaid + Vigilante Laureate + + + 6,993 + 6-1996 + C1 + Massively Underpaid + Builder for Schools + + + 6,994 + 7-2019 + C2 + Slave Labour + Philosopher for Eternity + + + 6,995 + 7-2021 + B2 + Underpaid + Historian for Schools + + + 6,996 + 1-2017 + B1 + Fairly Paid + Assassin for Eternity + + + 6,997 + 3-1994 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 6,998 + 6-1992 + B2 + Underpaid + Skydiving Instructor Trainer + + + 6,999 + 2-2011 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 7,000 + 2-2001 + C2 + Slave Labour + Food Taster for Eternity + + + 7,001 + 5-2021 + A2 + Overpaid + Skydiving Instructor in Chief + + + 7,002 + 2-2023 + A2 + Overpaid + Assassin of Cattle + + + 7,003 + 1-2017 + C2 + Slave Labour + Philosopher of Parties + + + 7,004 + 3-2014 + A2 + Overpaid + Vigilante of Doom + + + 7,005 + 6-2009 + B2 + Underpaid + Author Trainer + + + 7,006 + 6-2011 + B1 + Fairly Paid + Philosopher for Schools + + + 7,007 + 11-1999 + A1 + Massively Overpaid + Historian (Trainee) + + + 7,008 + 6-2013 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 7,009 + 10-2000 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 7,010 + 10-1992 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 7,011 + 5-1994 + A1 + Massively Overpaid + Builder of Cattle + + + 7,012 + 3-2019 + C1 + Massively Underpaid + Assassin Trainer + + + 7,013 + 9-1998 + A1 + Massively Overpaid + Author Trainer + + + 7,014 + 10-2010 + C2 + Slave Labour + Philosopher of Doom + + + 7,015 + 8-1994 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 7,016 + 8-2019 + A1 + Massively Overpaid + Vigilante for Eternity + + + 7,017 + 12-2003 + A1 + Massively Overpaid + Vigilante for Schools + + + 7,018 + 6-2022 + B2 + Underpaid + Vigilante of Cattle + + + 7,019 + 8-1998 + B2 + Underpaid + Vigilante for Schools + + + 7,020 + 11-2011 + C1 + Massively Underpaid + Vigilante of Parties + + + 7,021 + 2-2023 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 7,022 + 3-2016 + C1 + Massively Underpaid + Philosopher for the Environment + + + 7,023 + 5-2020 + C1 + Massively Underpaid + Author Extraordinaire + + + 7,024 + 1-1999 + B1 + Fairly Paid + Vigilante of Parties + + + 7,025 + 7-2009 + C2 + Slave Labour + Philosopher for Eternity + + + 7,026 + 11-2008 + A2 + Overpaid + Philosopher of Cattle + + + 7,027 + 2-2013 + C1 + Massively Underpaid + Author for Schools + + + 7,028 + 4-1994 + B2 + Underpaid + Software Developer Laureate + + + 7,029 + 2-1994 + A1 + Massively Overpaid + Assassin for Schools + + + 7,030 + 10-2023 + B1 + Fairly Paid + Sports Mascot Trainer + + + 7,031 + 12-2016 + C2 + Slave Labour + Author for Schools + + + 7,032 + 8-2016 + C1 + Massively Underpaid + Builder in Chief + + + 7,033 + 6-2017 + B1 + Fairly Paid + Software Developer of Doom + + + 7,034 + 9-2014 + A2 + Overpaid + Sports Mascot for Schools + + + 7,035 + 10-2005 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 7,036 + 11-1994 + C1 + Massively Underpaid + Builder for the Environment + + + 7,037 + 12-2017 + B1 + Fairly Paid + Philosopher in Chief + + + 7,038 + 12-1998 + B1 + Fairly Paid + Food Taster of Parties + + + 7,039 + 11-2003 + B1 + Fairly Paid + Builder for Schools + + + 7,040 + 3-2010 + B2 + Underpaid + Philosopher Trainer + + + 7,041 + 11-2002 + A2 + Overpaid + Skydiving Instructor Laureate + + + 7,042 + 4-2006 + C2 + Slave Labour + Builder Laureate + + + 7,043 + 7-2011 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 7,044 + 12-2020 + B1 + Fairly Paid + Food Taster Laureate + + + 7,045 + 4-2020 + B2 + Underpaid + Philosopher Extraordinaire + + + 7,046 + 8-2017 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 7,047 + 7-1995 + C1 + Massively Underpaid + Food Taster of Cattle + + + 7,048 + 6-2002 + B2 + Underpaid + Historian in Chief + + + 7,049 + 12-2019 + C2 + Slave Labour + Assassin Laureate + + + 7,050 + 4-1990 + C2 + Slave Labour + Food Taster (Trainee) + + + 7,051 + 8-2021 + C1 + Massively Underpaid + Assassin Trainer + + + 7,052 + 5-2004 + B1 + Fairly Paid + Vigilante for Schools + + + 7,053 + 8-2015 + A2 + Overpaid + Vigilante for Schools + + + 7,054 + 9-1996 + A2 + Overpaid + Builder of Cattle + + + 7,055 + 12-1994 + B1 + Fairly Paid + Assassin Trainer + + + 7,056 + 8-2010 + C2 + Slave Labour + Philosopher Laureate + + + 7,057 + 2-1998 + C1 + Massively Underpaid + Philosopher Trainer + + + 7,058 + 3-2023 + C1 + Massively Underpaid + Vigilante for the Environment + + + 7,059 + 4-1992 + B2 + Underpaid + Sports Mascot (Trainee) + + + 7,060 + 2-2020 + C1 + Massively Underpaid + Author of Parties + + + 7,061 + 2-2014 + A1 + Massively Overpaid + Vigilante in Chief + + + 7,062 + 10-2007 + C1 + Massively Underpaid + Assassin (Trainee) + + + 7,063 + 10-2010 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 7,064 + 7-1991 + B2 + Underpaid + Skydiving Instructor for Schools + + + 7,065 + 8-1994 + B2 + Underpaid + Software Developer Laureate + + + 7,066 + 12-2013 + B1 + Fairly Paid + Author in Chief + + + 7,067 + 3-2001 + A2 + Overpaid + Sports Mascot in Chief + + + 7,068 + 4-1999 + C2 + Slave Labour + Assassin Trainer + + + 7,069 + 4-2015 + C2 + Slave Labour + Assassin (Trainee) + + + 7,070 + 3-2010 + C2 + Slave Labour + Software Developer of Cattle + + + 7,071 + 4-2020 + B2 + Underpaid + Builder of Cattle + + + 7,072 + 1-2023 + A2 + Overpaid + Software Developer (Trainee) + + + 7,073 + 7-2003 + B1 + Fairly Paid + Historian for Eternity + + + 7,074 + 8-2002 + C2 + Slave Labour + Vigilante of Parties + + + 7,075 + 11-2017 + B1 + Fairly Paid + Builder of Doom + + + 7,076 + 3-2021 + A2 + Overpaid + Builder for Schools + + + 7,077 + 2-2021 + B1 + Fairly Paid + Assassin (Trainee) + + + 7,078 + 10-2003 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 7,079 + 8-2015 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 7,080 + 11-2010 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 7,081 + 5-2017 + C2 + Slave Labour + Builder of Doom + + + 7,082 + 11-2021 + C2 + Slave Labour + Vigilante in Chief + + + 7,083 + 11-1996 + C1 + Massively Underpaid + Philosopher for Eternity + + + 7,084 + 9-2009 + C2 + Slave Labour + Software Developer Laureate + + + 7,085 + 7-1994 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 7,086 + 1-2016 + B2 + Underpaid + Historian Trainer + + + 7,087 + 1-1990 + B2 + Underpaid + Philosopher for the Environment + + + 7,088 + 4-2014 + A1 + Massively Overpaid + Food Taster of Parties + + + 7,089 + 12-2019 + C1 + Massively Underpaid + Food Taster of Doom + + + 7,090 + 1-2009 + B1 + Fairly Paid + Vigilante for Schools + + + 7,091 + 2-2016 + A2 + Overpaid + Food Taster Laureate + + + 7,092 + 11-2015 + A2 + Overpaid + Assassin Extraordinaire + + + 7,093 + 11-1993 + C2 + Slave Labour + Historian for the Environment + + + 7,094 + 4-2020 + C1 + Massively Underpaid + Software Developer of Parties + + + 7,095 + 12-2017 + C2 + Slave Labour + Builder Extraordinaire + + + 7,096 + 8-2013 + C2 + Slave Labour + Sports Mascot Trainer + + + 7,097 + 11-2019 + A1 + Massively Overpaid + Author (Trainee) + + + 7,098 + 6-2019 + B1 + Fairly Paid + Historian of Doom + + + 7,099 + 8-1992 + B2 + Underpaid + Software Developer for Eternity + + + 7,100 + 4-2020 + A1 + Massively Overpaid + Builder Laureate + + + 7,101 + 2-2003 + C1 + Massively Underpaid + Builder of Doom + + + 7,102 + 5-2019 + A1 + Massively Overpaid + Builder of Doom + + + 7,103 + 7-1997 + A2 + Overpaid + Software Developer of Cattle + + + 7,104 + 7-2022 + B1 + Fairly Paid + Philosopher for the Environment + + + 7,105 + 1-2020 + B2 + Underpaid + Vigilante of Cattle + + + 7,106 + 7-2008 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 7,107 + 4-1993 + A1 + Massively Overpaid + Author Laureate + + + 7,108 + 3-1991 + C2 + Slave Labour + Assassin Laureate + + + 7,109 + 7-2003 + C2 + Slave Labour + Sports Mascot Trainer + + + 7,110 + 10-2003 + B1 + Fairly Paid + Software Developer of Cattle + + + 7,111 + 12-2018 + B1 + Fairly Paid + Software Developer for Schools + + + 7,112 + 4-2020 + C1 + Massively Underpaid + Assassin of Doom + + + 7,113 + 12-2015 + B1 + Fairly Paid + Software Developer in Chief + + + 7,114 + 5-2017 + B1 + Fairly Paid + Builder of Doom + + + 7,115 + 8-1995 + A1 + Massively Overpaid + Vigilante of Doom + + + 7,116 + 12-2007 + C1 + Massively Underpaid + Historian of Cattle + + + 7,117 + 1-2003 + A2 + Overpaid + Vigilante in Chief + + + 7,118 + 3-1994 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 7,119 + 2-2022 + B1 + Fairly Paid + Sports Mascot Trainer + + + 7,120 + 4-2020 + C1 + Massively Underpaid + Vigilante for Eternity + + + 7,121 + 7-1993 + C1 + Massively Underpaid + Builder in Chief + + + 7,122 + 3-2023 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 7,123 + 8-1990 + B1 + Fairly Paid + Vigilante of Parties + + + 7,124 + 10-2022 + C2 + Slave Labour + Philosopher of Cattle + + + 7,125 + 6-1996 + C1 + Massively Underpaid + Software Developer for Eternity + + + 7,126 + 11-1998 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 7,127 + 2-1995 + B2 + Underpaid + Author of Doom + + + 7,128 + 11-2005 + C2 + Slave Labour + Assassin in Chief + + + 7,129 + 8-1999 + A2 + Overpaid + Sports Mascot in Chief + + + 7,130 + 9-2006 + C1 + Massively Underpaid + Vigilante Trainer + + + 7,131 + 11-2005 + B1 + Fairly Paid + Assassin for the Environment + + + 7,132 + 1-2003 + C2 + Slave Labour + Builder (Trainee) + + + 7,133 + 6-1990 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 7,134 + 12-2014 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 7,135 + 10-1993 + C2 + Slave Labour + Builder for Schools + + + 7,136 + 11-2005 + C2 + Slave Labour + Food Taster for the Environment + + + 7,137 + 2-2004 + B1 + Fairly Paid + Builder of Cattle + + + 7,138 + 5-2004 + A2 + Overpaid + Author of Parties + + + 7,139 + 11-2006 + B1 + Fairly Paid + Software Developer of Cattle + + + 7,140 + 8-2018 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 7,141 + 11-2011 + B2 + Underpaid + Author of Cattle + + + 7,142 + 8-1992 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 7,143 + 5-2010 + C1 + Massively Underpaid + Author for Eternity + + + 7,144 + 1-1996 + B2 + Underpaid + Assassin Trainer + + + 7,145 + 7-2000 + C1 + Massively Underpaid + Historian of Doom + + + 7,146 + 6-2021 + B1 + Fairly Paid + Philosopher for the Environment + + + 7,147 + 12-2007 + A1 + Massively Overpaid + Food Taster of Parties + + + 7,148 + 8-2012 + C2 + Slave Labour + Author of Parties + + + 7,149 + 4-1998 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 7,150 + 12-2009 + A2 + Overpaid + Skydiving Instructor of Parties + + + 7,151 + 5-1994 + C2 + Slave Labour + Software Developer for Eternity + + + 7,152 + 9-1997 + C1 + Massively Underpaid + Food Taster of Cattle + + + 7,153 + 2-2009 + A1 + Massively Overpaid + Assassin (Trainee) + + + 7,154 + 6-2006 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 7,155 + 10-2012 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 7,156 + 1-1990 + B1 + Fairly Paid + Author for Eternity + + + 7,157 + 9-1991 + B2 + Underpaid + Sports Mascot of Parties + + + 7,158 + 12-2019 + B1 + Fairly Paid + Historian of Cattle + + + 7,159 + 12-2012 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 7,160 + 2-2016 + C1 + Massively Underpaid + Software Developer for the Environment + + + 7,161 + 2-2010 + C1 + Massively Underpaid + Software Developer Laureate + + + 7,162 + 6-1992 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 7,163 + 2-2001 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 7,164 + 10-2014 + C2 + Slave Labour + Philosopher for Eternity + + + 7,165 + 12-1993 + B1 + Fairly Paid + Food Taster of Parties + + + 7,166 + 8-1999 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 7,167 + 11-1998 + C1 + Massively Underpaid + Historian (Trainee) + + + 7,168 + 1-2007 + C2 + Slave Labour + Software Developer for Eternity + + + 7,169 + 1-2005 + A1 + Massively Overpaid + Vigilante of Cattle + + + 7,170 + 2-2011 + C1 + Massively Underpaid + Vigilante of Cattle + + + 7,171 + 4-1997 + B1 + Fairly Paid + Builder for Schools + + + 7,172 + 10-1993 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 7,173 + 6-2017 + C2 + Slave Labour + Software Developer for Schools + + + 7,174 + 6-1996 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 7,175 + 9-2023 + B2 + Underpaid + Software Developer in Chief + + + 7,176 + 1-1990 + B2 + Underpaid + Author Extraordinaire + + + 7,177 + 9-2013 + B2 + Underpaid + Builder Extraordinaire + + + 7,178 + 12-2004 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 7,179 + 11-2011 + C2 + Slave Labour + Historian of Cattle + + + 7,180 + 10-1995 + B1 + Fairly Paid + Builder Extraordinaire + + + 7,181 + 10-1993 + C2 + Slave Labour + Historian in Chief + + + 7,182 + 3-1996 + B2 + Underpaid + Skydiving Instructor Laureate + + + 7,183 + 8-2002 + A1 + Massively Overpaid + Builder Extraordinaire + + + 7,184 + 2-1999 + B1 + Fairly Paid + Food Taster of Cattle + + + 7,185 + 10-2017 + B2 + Underpaid + Sports Mascot of Doom + + + 7,186 + 9-1998 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 7,187 + 11-1996 + B1 + Fairly Paid + Vigilante Trainer + + + 7,188 + 11-2023 + A1 + Massively Overpaid + Author for Eternity + + + 7,189 + 12-2008 + C2 + Slave Labour + Assassin (Trainee) + + + 7,190 + 8-2023 + B2 + Underpaid + Vigilante of Cattle + + + 7,191 + 6-2000 + B2 + Underpaid + Food Taster for the Environment + + + 7,192 + 4-2019 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 7,193 + 6-2000 + C2 + Slave Labour + Philosopher Extraordinaire + + + 7,194 + 1-1992 + B1 + Fairly Paid + Food Taster Trainer + + + 7,195 + 9-2017 + C1 + Massively Underpaid + Vigilante for the Environment + + + 7,196 + 11-2020 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 7,197 + 11-2004 + B2 + Underpaid + Philosopher Trainer + + + 7,198 + 11-1990 + C1 + Massively Underpaid + Vigilante of Doom + + + 7,199 + 9-2020 + B1 + Fairly Paid + Software Developer of Cattle + + + 7,200 + 2-2014 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 7,201 + 3-2019 + A2 + Overpaid + Assassin (Trainee) + + + 7,202 + 2-2001 + A2 + Overpaid + Philosopher of Doom + + + 7,203 + 2-2013 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 7,204 + 5-1997 + C1 + Massively Underpaid + Software Developer Laureate + + + 7,205 + 8-2019 + C1 + Massively Underpaid + Assassin of Doom + + + 7,206 + 2-2015 + A1 + Massively Overpaid + Historian for Schools + + + 7,207 + 11-2005 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 7,208 + 9-2011 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 7,209 + 11-2015 + B2 + Underpaid + Builder for Schools + + + 7,210 + 5-2012 + C2 + Slave Labour + Builder (Trainee) + + + 7,211 + 9-2004 + B1 + Fairly Paid + Vigilante for Schools + + + 7,212 + 6-1992 + C1 + Massively Underpaid + Software Developer Laureate + + + 7,213 + 12-2001 + A2 + Overpaid + Vigilante for Eternity + + + 7,214 + 4-2015 + B2 + Underpaid + Software Developer of Doom + + + 7,215 + 10-2014 + B2 + Underpaid + Sports Mascot of Doom + + + 7,216 + 8-2023 + B1 + Fairly Paid + Author for the Environment + + + 7,217 + 10-2022 + B1 + Fairly Paid + Food Taster for Schools + + + 7,218 + 6-1997 + C1 + Massively Underpaid + Assassin Laureate + + + 7,219 + 8-2007 + C2 + Slave Labour + Sports Mascot of Doom + + + 7,220 + 8-2002 + A1 + Massively Overpaid + Philosopher of Parties + + + 7,221 + 1-2010 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 7,222 + 4-2014 + A2 + Overpaid + Builder of Doom + + + 7,223 + 4-1993 + A1 + Massively Overpaid + Food Taster of Doom + + + 7,224 + 3-1998 + B2 + Underpaid + Sports Mascot of Cattle + + + 7,225 + 2-2000 + C1 + Massively Underpaid + Philosopher Trainer + + + 7,226 + 6-1992 + C2 + Slave Labour + Author of Parties + + + 7,227 + 9-2006 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 7,228 + 12-1990 + B1 + Fairly Paid + Builder of Parties + + + 7,229 + 5-2003 + A2 + Overpaid + Vigilante of Cattle + + + 7,230 + 3-1995 + C2 + Slave Labour + Philosopher of Cattle + + + 7,231 + 8-2017 + B2 + Underpaid + Assassin for Schools + + + 7,232 + 5-2002 + B2 + Underpaid + Historian (Trainee) + + + 7,233 + 2-2013 + C1 + Massively Underpaid + Builder for Schools + + + 7,234 + 12-2006 + B1 + Fairly Paid + Vigilante in Chief + + + 7,235 + 2-2018 + A2 + Overpaid + Vigilante for Schools + + + 7,236 + 4-2019 + B2 + Underpaid + Vigilante Trainer + + + 7,237 + 1-1990 + A2 + Overpaid + Sports Mascot for Schools + + + 7,238 + 7-2005 + A1 + Massively Overpaid + Builder in Chief + + + 7,239 + 7-1990 + C2 + Slave Labour + Vigilante Extraordinaire + + + 7,240 + 4-2001 + A2 + Overpaid + Sports Mascot (Trainee) + + + 7,241 + 12-2001 + B2 + Underpaid + Software Developer of Doom + + + 7,242 + 10-2019 + C1 + Massively Underpaid + Software Developer Trainer + + + 7,243 + 5-2011 + A2 + Overpaid + Skydiving Instructor for Schools + + + 7,244 + 5-2001 + A1 + Massively Overpaid + Assassin for Eternity + + + 7,245 + 6-2011 + A2 + Overpaid + Author for Eternity + + + 7,246 + 8-2011 + A2 + Overpaid + Philosopher for the Environment + + + 7,247 + 8-1992 + A1 + Massively Overpaid + Historian Laureate + + + 7,248 + 2-2011 + C2 + Slave Labour + Author Extraordinaire + + + 7,249 + 7-2011 + C2 + Slave Labour + Philosopher Extraordinaire + + + 7,250 + 7-2013 + A1 + Massively Overpaid + Historian Extraordinaire + + + 7,251 + 4-2021 + B1 + Fairly Paid + Vigilante of Doom + + + 7,252 + 8-1998 + B1 + Fairly Paid + Skydiving Instructor (Trainee) + + + 7,253 + 3-2017 + A2 + Overpaid + Historian for Schools + + + 7,254 + 8-1998 + B2 + Underpaid + Author for Schools + + + 7,255 + 11-2023 + B1 + Fairly Paid + Philosopher Laureate + + + 7,256 + 7-2008 + B1 + Fairly Paid + Software Developer (Trainee) + + + 7,257 + 5-1995 + B2 + Underpaid + Food Taster (Trainee) + + + 7,258 + 9-2000 + A2 + Overpaid + Food Taster for Schools + + + 7,259 + 10-1999 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 7,260 + 8-1992 + B2 + Underpaid + Food Taster Laureate + + + 7,261 + 5-1998 + C2 + Slave Labour + Software Developer for Schools + + + 7,262 + 7-2015 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 7,263 + 5-1995 + B1 + Fairly Paid + Builder Extraordinaire + + + 7,264 + 7-2009 + A2 + Overpaid + Food Taster of Parties + + + 7,265 + 1-2008 + C2 + Slave Labour + Builder for the Environment + + + 7,266 + 9-2002 + C2 + Slave Labour + Builder for Eternity + + + 7,267 + 8-2023 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 7,268 + 5-1995 + A2 + Overpaid + Historian for Schools + + + 7,269 + 4-1995 + B2 + Underpaid + Skydiving Instructor of Parties + + + 7,270 + 9-1992 + B2 + Underpaid + Historian of Doom + + + 7,271 + 9-2018 + B1 + Fairly Paid + Software Developer in Chief + + + 7,272 + 11-1990 + B2 + Underpaid + Historian for the Environment + + + 7,273 + 1-2004 + A1 + Massively Overpaid + Builder (Trainee) + + + 7,274 + 11-2001 + A2 + Overpaid + Vigilante of Parties + + + 7,275 + 11-2009 + C1 + Massively Underpaid + Author of Doom + + + 7,276 + 5-2017 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 7,277 + 12-2019 + A1 + Massively Overpaid + Author of Cattle + + + 7,278 + 1-2012 + C1 + Massively Underpaid + Philosopher of Doom + + + 7,279 + 8-2018 + A2 + Overpaid + Sports Mascot for Eternity + + + 7,280 + 5-2011 + B1 + Fairly Paid + Historian Trainer + + + 7,281 + 8-2000 + A2 + Overpaid + Assassin for Schools + + + 7,282 + 1-2014 + C2 + Slave Labour + Builder of Parties + + + 7,283 + 8-2004 + A2 + Overpaid + Food Taster for Eternity + + + 7,284 + 3-2016 + A1 + Massively Overpaid + Historian of Doom + + + 7,285 + 9-1990 + C1 + Massively Underpaid + Food Taster in Chief + + + 7,286 + 10-1999 + B1 + Fairly Paid + Software Developer of Cattle + + + 7,287 + 10-1998 + B2 + Underpaid + Food Taster Extraordinaire + + + 7,288 + 1-2003 + C1 + Massively Underpaid + Software Developer in Chief + + + 7,289 + 5-2002 + A1 + Massively Overpaid + Author Trainer + + + 7,290 + 1-2016 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 7,291 + 8-2022 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 7,292 + 5-1998 + B2 + Underpaid + Author (Trainee) + + + 7,293 + 6-2016 + B1 + Fairly Paid + Builder in Chief + + + 7,294 + 4-2017 + C1 + Massively Underpaid + Builder for Eternity + + + 7,295 + 10-1990 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 7,296 + 12-2003 + B1 + Fairly Paid + Builder Extraordinaire + + + 7,297 + 10-2013 + B2 + Underpaid + Builder for Eternity + + + 7,298 + 9-2004 + C2 + Slave Labour + Author of Doom + + + 7,299 + 8-1996 + A1 + Massively Overpaid + Author Trainer + + + 7,300 + 3-2008 + A1 + Massively Overpaid + Historian for the Environment + + + 7,301 + 11-2008 + C2 + Slave Labour + Philosopher Extraordinaire + + + 7,302 + 9-2004 + B2 + Underpaid + Food Taster Trainer + + + 7,303 + 2-2006 + C2 + Slave Labour + Historian of Parties + + + 7,304 + 1-1996 + A1 + Massively Overpaid + Author in Chief + + + 7,305 + 1-2013 + C1 + Massively Underpaid + Software Developer of Doom + + + 7,306 + 12-1994 + C1 + Massively Underpaid + Author Laureate + + + 7,307 + 9-2015 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 7,308 + 7-2023 + A1 + Massively Overpaid + Builder of Parties + + + 7,309 + 6-2007 + B2 + Underpaid + Author Laureate + + + 7,310 + 1-1995 + B2 + Underpaid + Software Developer of Cattle + + + 7,311 + 3-1992 + B1 + Fairly Paid + Builder Laureate + + + 7,312 + 12-2023 + C1 + Massively Underpaid + Author of Cattle + + + 7,313 + 2-2019 + A1 + Massively Overpaid + Builder of Parties + + + 7,314 + 8-2007 + A2 + Overpaid + Assassin of Cattle + + + 7,315 + 4-1991 + C1 + Massively Underpaid + Software Developer in Chief + + + 7,316 + 9-1991 + A2 + Overpaid + Author of Parties + + + 7,317 + 9-2001 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 7,318 + 4-1999 + C1 + Massively Underpaid + Software Developer of Doom + + + 7,319 + 9-2003 + B1 + Fairly Paid + Software Developer for Schools + + + 7,320 + 9-2023 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 7,321 + 10-2013 + C1 + Massively Underpaid + Vigilante for the Environment + + + 7,322 + 11-2000 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 7,323 + 10-2002 + B1 + Fairly Paid + Vigilante in Chief + + + 7,324 + 10-2018 + B2 + Underpaid + Philosopher for Schools + + + 7,325 + 5-1998 + A2 + Overpaid + Author for Schools + + + 7,326 + 8-1996 + A1 + Massively Overpaid + Philosopher Laureate + + + 7,327 + 1-2006 + C1 + Massively Underpaid + Food Taster for the Environment + + + 7,328 + 11-1993 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 7,329 + 5-2020 + B1 + Fairly Paid + Author in Chief + + + 7,330 + 8-2008 + A1 + Massively Overpaid + Vigilante Trainer + + + 7,331 + 1-1998 + A2 + Overpaid + Food Taster of Doom + + + 7,332 + 11-1997 + B2 + Underpaid + Vigilante Extraordinaire + + + 7,333 + 8-2013 + B1 + Fairly Paid + Author of Doom + + + 7,334 + 6-2015 + C2 + Slave Labour + Philosopher Extraordinaire + + + 7,335 + 4-2004 + B2 + Underpaid + Vigilante Trainer + + + 7,336 + 7-2009 + B2 + Underpaid + Philosopher (Trainee) + + + 7,337 + 4-2001 + A1 + Massively Overpaid + Philosopher Laureate + + + 7,338 + 12-2013 + B2 + Underpaid + Author for the Environment + + + 7,339 + 5-2012 + A1 + Massively Overpaid + Assassin of Cattle + + + 7,340 + 12-2003 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 7,341 + 8-2000 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 7,342 + 4-1995 + A2 + Overpaid + Food Taster for Eternity + + + 7,343 + 6-2004 + B2 + Underpaid + Sports Mascot Laureate + + + 7,344 + 8-2002 + A1 + Massively Overpaid + Software Developer for Eternity + + + 7,345 + 7-2023 + A2 + Overpaid + Builder of Doom + + + 7,346 + 7-2001 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 7,347 + 7-2005 + B1 + Fairly Paid + Author for the Environment + + + 7,348 + 10-2014 + C2 + Slave Labour + Vigilante of Parties + + + 7,349 + 5-2004 + B2 + Underpaid + Skydiving Instructor of Parties + + + 7,350 + 1-1998 + C1 + Massively Underpaid + Philosopher of Parties + + + 7,351 + 8-2006 + B2 + Underpaid + Philosopher (Trainee) + + + 7,352 + 2-2004 + C2 + Slave Labour + Philosopher in Chief + + + 7,353 + 12-1990 + C2 + Slave Labour + Assassin Laureate + + + 7,354 + 11-1995 + C2 + Slave Labour + Sports Mascot of Cattle + + + 7,355 + 5-2011 + B1 + Fairly Paid + Author Laureate + + + 7,356 + 7-1991 + B1 + Fairly Paid + Historian for Schools + + + 7,357 + 11-1996 + A1 + Massively Overpaid + Builder for the Environment + + + 7,358 + 5-2018 + B1 + Fairly Paid + Vigilante for Schools + + + 7,359 + 12-2020 + B2 + Underpaid + Author for Schools + + + 7,360 + 9-2002 + B2 + Underpaid + Philosopher for Schools + + + 7,361 + 6-2019 + C1 + Massively Underpaid + Assassin of Doom + + + 7,362 + 1-2008 + C1 + Massively Underpaid + Food Taster in Chief + + + 7,363 + 11-2005 + B2 + Underpaid + Assassin (Trainee) + + + 7,364 + 3-1995 + C1 + Massively Underpaid + Assassin of Doom + + + 7,365 + 9-2019 + C2 + Slave Labour + Builder in Chief + + + 7,366 + 12-2009 + A1 + Massively Overpaid + Food Taster for the Environment + + + 7,367 + 5-2017 + A2 + Overpaid + Philosopher for Schools + + + 7,368 + 12-2003 + B2 + Underpaid + Skydiving Instructor of Doom + + + 7,369 + 7-2014 + A2 + Overpaid + Philosopher in Chief + + + 7,370 + 9-2000 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 7,371 + 12-1995 + B1 + Fairly Paid + Assassin Extraordinaire + + + 7,372 + 2-2023 + A1 + Massively Overpaid + Philosopher for the Environment + + + 7,373 + 6-2004 + A2 + Overpaid + Historian for Schools + + + 7,374 + 4-2017 + B2 + Underpaid + Assassin for the Environment + + + 7,375 + 12-2018 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 7,376 + 4-2008 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 7,377 + 10-2009 + C1 + Massively Underpaid + Vigilante for Eternity + + + 7,378 + 3-2015 + B1 + Fairly Paid + Sports Mascot of Doom + + + 7,379 + 10-2022 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 7,380 + 2-1995 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 7,381 + 11-2012 + C2 + Slave Labour + Food Taster for Schools + + + 7,382 + 1-2020 + C1 + Massively Underpaid + Historian for the Environment + + + 7,383 + 3-2004 + B2 + Underpaid + Author Laureate + + + 7,384 + 9-1999 + C2 + Slave Labour + Philosopher Laureate + + + 7,385 + 8-1992 + C1 + Massively Underpaid + Builder (Trainee) + + + 7,386 + 8-2022 + B2 + Underpaid + Philosopher of Doom + + + 7,387 + 1-2006 + A1 + Massively Overpaid + Food Taster of Cattle + + + 7,388 + 12-2008 + C1 + Massively Underpaid + Food Taster for Schools + + + 7,389 + 10-1996 + C2 + Slave Labour + Builder for Schools + + + 7,390 + 3-2014 + C2 + Slave Labour + Vigilante of Doom + + + 7,391 + 5-2003 + C1 + Massively Underpaid + Historian Trainer + + + 7,392 + 1-2016 + B2 + Underpaid + Historian of Cattle + + + 7,393 + 2-2010 + C1 + Massively Underpaid + Assassin for Schools + + + 7,394 + 9-1999 + B2 + Underpaid + Builder for Schools + + + 7,395 + 4-2014 + B2 + Underpaid + Food Taster of Parties + + + 7,396 + 8-2019 + A1 + Massively Overpaid + Software Developer for the Environment + + + 7,397 + 4-2006 + C1 + Massively Underpaid + Food Taster in Chief + + + 7,398 + 3-2010 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 7,399 + 12-1993 + B2 + Underpaid + Skydiving Instructor for Schools + + + 7,400 + 1-1992 + C1 + Massively Underpaid + Software Developer in Chief + + + 7,401 + 5-1994 + A2 + Overpaid + Sports Mascot in Chief + + + 7,402 + 5-2000 + B1 + Fairly Paid + Author of Cattle + + + 7,403 + 1-1991 + C2 + Slave Labour + Software Developer for Eternity + + + 7,404 + 11-2015 + C2 + Slave Labour + Food Taster for the Environment + + + 7,405 + 11-1993 + C2 + Slave Labour + Philosopher of Cattle + + + 7,406 + 3-1996 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 7,407 + 1-1992 + A2 + Overpaid + Software Developer (Trainee) + + + 7,408 + 1-1991 + C2 + Slave Labour + Author Trainer + + + 7,409 + 12-2007 + A1 + Massively Overpaid + Vigilante in Chief + + + 7,410 + 1-1997 + C2 + Slave Labour + Software Developer of Doom + + + 7,411 + 9-2002 + C2 + Slave Labour + Assassin of Parties + + + 7,412 + 5-2019 + A2 + Overpaid + Vigilante in Chief + + + 7,413 + 5-1992 + C2 + Slave Labour + Builder for Eternity + + + 7,414 + 9-2023 + B1 + Fairly Paid + Author of Parties + + + 7,415 + 7-2014 + B1 + Fairly Paid + Historian in Chief + + + 7,416 + 9-2008 + B1 + Fairly Paid + Author in Chief + + + 7,417 + 6-2004 + C2 + Slave Labour + Builder for Schools + + + 7,418 + 4-2011 + A1 + Massively Overpaid + Builder of Parties + + + 7,419 + 9-2013 + C1 + Massively Underpaid + Food Taster for the Environment + + + 7,420 + 9-2001 + A2 + Overpaid + Assassin of Cattle + + + 7,421 + 7-2001 + A2 + Overpaid + Vigilante Trainer + + + 7,422 + 4-2021 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 7,423 + 7-2003 + B2 + Underpaid + Skydiving Instructor Laureate + + + 7,424 + 11-2014 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 7,425 + 4-2008 + B1 + Fairly Paid + Vigilante (Trainee) + + + 7,426 + 4-2003 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 7,427 + 2-2018 + B1 + Fairly Paid + Vigilante in Chief + + + 7,428 + 6-1998 + A2 + Overpaid + Philosopher of Doom + + + 7,429 + 6-1995 + B1 + Fairly Paid + Historian in Chief + + + 7,430 + 8-2021 + A1 + Massively Overpaid + Historian of Cattle + + + 7,431 + 1-2017 + B2 + Underpaid + Vigilante Trainer + + + 7,432 + 6-2009 + A2 + Overpaid + Author Trainer + + + 7,433 + 3-2013 + B2 + Underpaid + Builder in Chief + + + 7,434 + 5-2003 + B2 + Underpaid + Author for Schools + + + 7,435 + 1-2013 + B1 + Fairly Paid + Software Developer of Parties + + + 7,436 + 5-2000 + C2 + Slave Labour + Food Taster Trainer + + + 7,437 + 9-2002 + B1 + Fairly Paid + Software Developer for Eternity + + + 7,438 + 12-2014 + A2 + Overpaid + Vigilante of Cattle + + + 7,439 + 5-2010 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 7,440 + 2-1993 + B1 + Fairly Paid + Assassin for the Environment + + + 7,441 + 11-2023 + B2 + Underpaid + Builder for Schools + + + 7,442 + 6-2005 + B2 + Underpaid + Sports Mascot of Doom + + + 7,443 + 11-2008 + C2 + Slave Labour + Philosopher in Chief + + + 7,444 + 8-2021 + B1 + Fairly Paid + Assassin (Trainee) + + + 7,445 + 12-2012 + B1 + Fairly Paid + Software Developer for Eternity + + + 7,446 + 8-2015 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 7,447 + 10-2006 + B1 + Fairly Paid + Assassin Laureate + + + 7,448 + 2-2005 + B2 + Underpaid + Author Trainer + + + 7,449 + 4-1998 + B2 + Underpaid + Philosopher Extraordinaire + + + 7,450 + 5-2020 + A2 + Overpaid + Sports Mascot Laureate + + + 7,451 + 6-2011 + B2 + Underpaid + Skydiving Instructor of Doom + + + 7,452 + 10-1992 + A2 + Overpaid + Builder (Trainee) + + + 7,453 + 10-2023 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 7,454 + 10-2015 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 7,455 + 3-2019 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 7,456 + 5-2008 + A1 + Massively Overpaid + Food Taster for Eternity + + + 7,457 + 9-1998 + B2 + Underpaid + Assassin Extraordinaire + + + 7,458 + 6-2012 + A1 + Massively Overpaid + Builder in Chief + + + 7,459 + 9-1992 + B1 + Fairly Paid + Software Developer of Doom + + + 7,460 + 9-2014 + C2 + Slave Labour + Vigilante for Schools + + + 7,461 + 3-2012 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 7,462 + 2-2008 + A1 + Massively Overpaid + Historian for the Environment + + + 7,463 + 4-2005 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 7,464 + 10-1990 + B2 + Underpaid + Vigilante (Trainee) + + + 7,465 + 11-2022 + A2 + Overpaid + Software Developer for the Environment + + + 7,466 + 4-2003 + B1 + Fairly Paid + Vigilante (Trainee) + + + 7,467 + 12-2014 + A2 + Overpaid + Builder Laureate + + + 7,468 + 10-2007 + C1 + Massively Underpaid + Builder Laureate + + + 7,469 + 6-1996 + B2 + Underpaid + Sports Mascot of Doom + + + 7,470 + 4-2019 + B1 + Fairly Paid + Vigilante in Chief + + + 7,471 + 8-2005 + C1 + Massively Underpaid + Food Taster for Eternity + + + 7,472 + 4-2022 + A2 + Overpaid + Skydiving Instructor of Doom + + + 7,473 + 3-1992 + C2 + Slave Labour + Author of Cattle + + + 7,474 + 5-2013 + C1 + Massively Underpaid + Author of Doom + + + 7,475 + 5-2022 + C1 + Massively Underpaid + Software Developer of Parties + + + 7,476 + 5-2008 + A2 + Overpaid + Software Developer of Parties + + + 7,477 + 12-1992 + B2 + Underpaid + Software Developer Extraordinaire + + + 7,478 + 10-2015 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 7,479 + 2-2017 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 7,480 + 6-2016 + C2 + Slave Labour + Builder of Doom + + + 7,481 + 5-2018 + B1 + Fairly Paid + Food Taster (Trainee) + + + 7,482 + 12-1993 + B1 + Fairly Paid + Assassin for the Environment + + + 7,483 + 3-2007 + C2 + Slave Labour + Historian of Parties + + + 7,484 + 3-2005 + A1 + Massively Overpaid + Software Developer for Schools + + + 7,485 + 7-1996 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 7,486 + 8-2013 + A1 + Massively Overpaid + Philosopher for the Environment + + + 7,487 + 7-2009 + C2 + Slave Labour + Sports Mascot Trainer + + + 7,488 + 12-2009 + A2 + Overpaid + Sports Mascot (Trainee) + + + 7,489 + 4-2017 + B2 + Underpaid + Vigilante for Schools + + + 7,490 + 6-1999 + C2 + Slave Labour + Assassin Extraordinaire + + + 7,491 + 3-2006 + C2 + Slave Labour + Historian for the Environment + + + 7,492 + 1-1998 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 7,493 + 7-2021 + C1 + Massively Underpaid + Historian in Chief + + + 7,494 + 9-2023 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 7,495 + 9-1993 + B1 + Fairly Paid + Author in Chief + + + 7,496 + 9-2003 + C1 + Massively Underpaid + Historian for Eternity + + + 7,497 + 4-2010 + A2 + Overpaid + Builder Extraordinaire + + + 7,498 + 3-2019 + C1 + Massively Underpaid + Historian of Cattle + + + 7,499 + 9-2009 + B1 + Fairly Paid + Sports Mascot for Schools + + + 7,500 + 4-2020 + C2 + Slave Labour + Builder of Parties + + + 7,501 + 12-2007 + B1 + Fairly Paid + Historian for Schools + + + 7,502 + 2-2004 + B1 + Fairly Paid + Builder Trainer + + + 7,503 + 7-2013 + A2 + Overpaid + Historian Trainer + + + 7,504 + 11-1995 + B1 + Fairly Paid + Software Developer for the Environment + + + 7,505 + 7-2019 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 7,506 + 1-2006 + C2 + Slave Labour + Builder in Chief + + + 7,507 + 11-2014 + A2 + Overpaid + Software Developer for Eternity + + + 7,508 + 1-1995 + B2 + Underpaid + Historian for Schools + + + 7,509 + 1-1991 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 7,510 + 3-2013 + A2 + Overpaid + Software Developer Laureate + + + 7,511 + 10-2008 + A2 + Overpaid + Food Taster of Cattle + + + 7,512 + 10-1999 + B2 + Underpaid + Philosopher Laureate + + + 7,513 + 2-1990 + B2 + Underpaid + Skydiving Instructor of Doom + + + 7,514 + 1-2023 + B2 + Underpaid + Food Taster for Eternity + + + 7,515 + 9-2022 + C1 + Massively Underpaid + Software Developer of Cattle + + + 7,516 + 12-2011 + A2 + Overpaid + Historian for Schools + + + 7,517 + 2-2004 + B2 + Underpaid + Food Taster Laureate + + + 7,518 + 11-1994 + A1 + Massively Overpaid + Vigilante for Schools + + + 7,519 + 5-1990 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 7,520 + 7-2003 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 7,521 + 10-2008 + B1 + Fairly Paid + Vigilante of Cattle + + + 7,522 + 2-2022 + C1 + Massively Underpaid + Author for Eternity + + + 7,523 + 10-2016 + A1 + Massively Overpaid + Assassin (Trainee) + + + 7,524 + 6-2021 + A1 + Massively Overpaid + Vigilante of Doom + + + 7,525 + 10-2009 + B2 + Underpaid + Author of Parties + + + 7,526 + 2-2001 + A2 + Overpaid + Skydiving Instructor Laureate + + + 7,527 + 1-2007 + A2 + Overpaid + Builder in Chief + + + 7,528 + 8-2003 + C2 + Slave Labour + Historian for the Environment + + + 7,529 + 4-2000 + B2 + Underpaid + Philosopher for Schools + + + 7,530 + 7-2013 + B1 + Fairly Paid + Food Taster for Schools + + + 7,531 + 1-2000 + B1 + Fairly Paid + Software Developer for Eternity + + + 7,532 + 9-1995 + C1 + Massively Underpaid + Historian for the Environment + + + 7,533 + 9-1999 + B1 + Fairly Paid + Software Developer (Trainee) + + + 7,534 + 2-1995 + C1 + Massively Underpaid + Vigilante of Cattle + + + 7,535 + 1-1999 + A2 + Overpaid + Software Developer Trainer + + + 7,536 + 10-1991 + A1 + Massively Overpaid + Food Taster of Parties + + + 7,537 + 3-2015 + A1 + Massively Overpaid + Builder Extraordinaire + + + 7,538 + 9-2011 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 7,539 + 8-2019 + B1 + Fairly Paid + Vigilante in Chief + + + 7,540 + 4-2022 + A1 + Massively Overpaid + Builder (Trainee) + + + 7,541 + 7-2005 + B2 + Underpaid + Skydiving Instructor Trainer + + + 7,542 + 12-2013 + B2 + Underpaid + Historian Trainer + + + 7,543 + 10-1999 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 7,544 + 5-2021 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 7,545 + 1-2022 + A2 + Overpaid + Philosopher Extraordinaire + + + 7,546 + 7-2012 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 7,547 + 4-2011 + B1 + Fairly Paid + Software Developer (Trainee) + + + 7,548 + 5-2015 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 7,549 + 10-2009 + B2 + Underpaid + Builder Trainer + + + 7,550 + 1-2021 + A2 + Overpaid + Vigilante (Trainee) + + + 7,551 + 7-2020 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 7,552 + 3-2019 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 7,553 + 10-1993 + B1 + Fairly Paid + Author (Trainee) + + + 7,554 + 12-2013 + C2 + Slave Labour + Author in Chief + + + 7,555 + 10-2021 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 7,556 + 9-2006 + B1 + Fairly Paid + Historian of Cattle + + + 7,557 + 8-2009 + C2 + Slave Labour + Software Developer Trainer + + + 7,558 + 8-1991 + B1 + Fairly Paid + Vigilante for Eternity + + + 7,559 + 10-2000 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 7,560 + 3-2006 + C2 + Slave Labour + Historian for the Environment + + + 7,561 + 7-2007 + B2 + Underpaid + Assassin for Eternity + + + 7,562 + 10-2003 + C2 + Slave Labour + Software Developer for Eternity + + + 7,563 + 4-2010 + C1 + Massively Underpaid + Builder of Cattle + + + 7,564 + 12-2005 + C2 + Slave Labour + Food Taster (Trainee) + + + 7,565 + 10-1991 + B2 + Underpaid + Sports Mascot Laureate + + + 7,566 + 12-2004 + A1 + Massively Overpaid + Author in Chief + + + 7,567 + 5-2002 + B2 + Underpaid + Historian for the Environment + + + 7,568 + 9-2008 + C2 + Slave Labour + Food Taster Trainer + + + 7,569 + 12-2016 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 7,570 + 10-2015 + C2 + Slave Labour + Sports Mascot of Cattle + + + 7,571 + 1-2017 + C1 + Massively Underpaid + Author Extraordinaire + + + 7,572 + 10-2000 + A1 + Massively Overpaid + Assassin (Trainee) + + + 7,573 + 10-1991 + B1 + Fairly Paid + Author in Chief + + + 7,574 + 8-1994 + A2 + Overpaid + Philosopher of Parties + + + 7,575 + 6-2004 + B1 + Fairly Paid + Software Developer Trainer + + + 7,576 + 5-1995 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 7,577 + 1-2012 + B1 + Fairly Paid + Sports Mascot Laureate + + + 7,578 + 12-2000 + B1 + Fairly Paid + Assassin Extraordinaire + + + 7,579 + 8-1999 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 7,580 + 7-2011 + A2 + Overpaid + Skydiving Instructor of Parties + + + 7,581 + 5-2005 + A2 + Overpaid + Vigilante of Cattle + + + 7,582 + 8-2010 + B1 + Fairly Paid + Author for Eternity + + + 7,583 + 7-2012 + A1 + Massively Overpaid + Assassin of Cattle + + + 7,584 + 7-2006 + A1 + Massively Overpaid + Historian Extraordinaire + + + 7,585 + 12-2000 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 7,586 + 5-2011 + B2 + Underpaid + Assassin Laureate + + + 7,587 + 1-2020 + A2 + Overpaid + Author Trainer + + + 7,588 + 4-1994 + A2 + Overpaid + Philosopher of Parties + + + 7,589 + 9-1993 + C1 + Massively Underpaid + Food Taster for Eternity + + + 7,590 + 7-2023 + A2 + Overpaid + Vigilante of Cattle + + + 7,591 + 10-2003 + C1 + Massively Underpaid + Vigilante in Chief + + + 7,592 + 1-2011 + C2 + Slave Labour + Philosopher for the Environment + + + 7,593 + 9-2015 + A2 + Overpaid + Assassin Laureate + + + 7,594 + 4-2015 + C2 + Slave Labour + Sports Mascot for the Environment + + + 7,595 + 1-2011 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 7,596 + 8-1994 + C2 + Slave Labour + Philosopher Laureate + + + 7,597 + 3-2003 + B1 + Fairly Paid + Builder Laureate + + + 7,598 + 1-1997 + C2 + Slave Labour + Software Developer of Parties + + + 7,599 + 1-2010 + A1 + Massively Overpaid + Food Taster for the Environment + + + 7,600 + 10-1993 + C2 + Slave Labour + Food Taster in Chief + + + 7,601 + 9-1998 + A2 + Overpaid + Author (Trainee) + + + 7,602 + 8-2018 + C2 + Slave Labour + Author for Schools + + + 7,603 + 10-1995 + C1 + Massively Underpaid + Assassin of Parties + + + 7,604 + 4-2018 + A1 + Massively Overpaid + Vigilante for the Environment + + + 7,605 + 1-1991 + B1 + Fairly Paid + Food Taster for Eternity + + + 7,606 + 11-1997 + B1 + Fairly Paid + Software Developer of Doom + + + 7,607 + 10-1993 + A2 + Overpaid + Vigilante for Eternity + + + 7,608 + 6-2009 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 7,609 + 11-1997 + B1 + Fairly Paid + Philosopher for Eternity + + + 7,610 + 2-1998 + A2 + Overpaid + Skydiving Instructor in Chief + + + 7,611 + 8-2023 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 7,612 + 12-1991 + B2 + Underpaid + Historian Laureate + + + 7,613 + 9-2022 + C2 + Slave Labour + Historian of Parties + + + 7,614 + 11-1993 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 7,615 + 8-2016 + C1 + Massively Underpaid + Food Taster of Doom + + + 7,616 + 5-2007 + B1 + Fairly Paid + Sports Mascot Laureate + + + 7,617 + 5-2005 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 7,618 + 8-2014 + B2 + Underpaid + Philosopher for Schools + + + 7,619 + 4-2016 + A1 + Massively Overpaid + Vigilante for Eternity + + + 7,620 + 12-2003 + B2 + Underpaid + Skydiving Instructor of Parties + + + 7,621 + 12-2011 + B1 + Fairly Paid + Builder Trainer + + + 7,622 + 7-2020 + B2 + Underpaid + Food Taster of Parties + + + 7,623 + 4-2018 + C2 + Slave Labour + Author Laureate + + + 7,624 + 10-2000 + C2 + Slave Labour + Food Taster Laureate + + + 7,625 + 3-1996 + B2 + Underpaid + Builder Trainer + + + 7,626 + 11-2008 + A2 + Overpaid + Assassin of Cattle + + + 7,627 + 5-1996 + B2 + Underpaid + Builder for the Environment + + + 7,628 + 11-2022 + C1 + Massively Underpaid + Author of Parties + + + 7,629 + 8-1992 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 7,630 + 3-1997 + A2 + Overpaid + Historian Extraordinaire + + + 7,631 + 4-2008 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 7,632 + 6-2007 + C1 + Massively Underpaid + Software Developer for the Environment + + + 7,633 + 6-2008 + B2 + Underpaid + Software Developer of Doom + + + 7,634 + 6-2008 + A1 + Massively Overpaid + Philosopher for the Environment + + + 7,635 + 6-2007 + A1 + Massively Overpaid + Builder for the Environment + + + 7,636 + 12-2005 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 7,637 + 2-2021 + C2 + Slave Labour + Food Taster of Parties + + + 7,638 + 10-2001 + C2 + Slave Labour + Assassin for Schools + + + 7,639 + 3-1994 + A1 + Massively Overpaid + Food Taster of Parties + + + 7,640 + 5-2002 + C2 + Slave Labour + Food Taster for Schools + + + 7,641 + 3-2007 + C2 + Slave Labour + Assassin of Doom + + + 7,642 + 10-2010 + C2 + Slave Labour + Historian for the Environment + + + 7,643 + 1-2002 + B2 + Underpaid + Historian of Parties + + + 7,644 + 5-2006 + B1 + Fairly Paid + Sports Mascot Extraordinaire + + + 7,645 + 8-2018 + B2 + Underpaid + Builder in Chief + + + 7,646 + 11-1996 + A1 + Massively Overpaid + Historian for Schools + + + 7,647 + 2-2002 + B2 + Underpaid + Philosopher Trainer + + + 7,648 + 1-2009 + A2 + Overpaid + Historian Trainer + + + 7,649 + 12-2011 + B2 + Underpaid + Historian Trainer + + + 7,650 + 7-2002 + B2 + Underpaid + Sports Mascot for Schools + + + 7,651 + 6-2016 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 7,652 + 10-1990 + B1 + Fairly Paid + Food Taster in Chief + + + 7,653 + 3-2006 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 7,654 + 1-2013 + A2 + Overpaid + Software Developer for the Environment + + + 7,655 + 12-2007 + B2 + Underpaid + Author for the Environment + + + 7,656 + 3-2005 + A1 + Massively Overpaid + Assassin of Cattle + + + 7,657 + 5-1998 + C2 + Slave Labour + Software Developer Extraordinaire + + + 7,658 + 5-2022 + A2 + Overpaid + Historian for Eternity + + + 7,659 + 4-2005 + A1 + Massively Overpaid + Builder Laureate + + + 7,660 + 8-2013 + B1 + Fairly Paid + Historian of Doom + + + 7,661 + 9-2004 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 7,662 + 9-1999 + B1 + Fairly Paid + Food Taster of Doom + + + 7,663 + 7-2009 + C2 + Slave Labour + Food Taster Laureate + + + 7,664 + 3-2000 + B1 + Fairly Paid + Software Developer for Schools + + + 7,665 + 4-2023 + B2 + Underpaid + Food Taster Trainer + + + 7,666 + 5-1999 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 7,667 + 2-2014 + C2 + Slave Labour + Assassin for Eternity + + + 7,668 + 11-2000 + A1 + Massively Overpaid + Builder Trainer + + + 7,669 + 11-2012 + B1 + Fairly Paid + Historian of Doom + + + 7,670 + 12-2020 + C2 + Slave Labour + Assassin (Trainee) + + + 7,671 + 8-2021 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 7,672 + 5-2010 + A2 + Overpaid + Builder of Parties + + + 7,673 + 3-1993 + B2 + Underpaid + Sports Mascot of Parties + + + 7,674 + 3-2016 + A1 + Massively Overpaid + Food Taster for the Environment + + + 7,675 + 6-2015 + A1 + Massively Overpaid + Food Taster for Schools + + + 7,676 + 10-2014 + A2 + Overpaid + Vigilante for the Environment + + + 7,677 + 12-2007 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 7,678 + 7-2000 + A1 + Massively Overpaid + Historian of Doom + + + 7,679 + 6-2002 + A2 + Overpaid + Sports Mascot of Parties + + + 7,680 + 12-1998 + B1 + Fairly Paid + Food Taster (Trainee) + + + 7,681 + 6-1993 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 7,682 + 4-2005 + A1 + Massively Overpaid + Software Developer for Schools + + + 7,683 + 9-1998 + C2 + Slave Labour + Historian Laureate + + + 7,684 + 8-2002 + A1 + Massively Overpaid + Author of Parties + + + 7,685 + 8-2000 + C2 + Slave Labour + Food Taster (Trainee) + + + 7,686 + 8-2023 + C1 + Massively Underpaid + Vigilante for Schools + + + 7,687 + 2-1991 + A2 + Overpaid + Food Taster Trainer + + + 7,688 + 12-1992 + B2 + Underpaid + Food Taster of Cattle + + + 7,689 + 2-1994 + B2 + Underpaid + Skydiving Instructor Trainer + + + 7,690 + 7-2001 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 7,691 + 1-1993 + C2 + Slave Labour + Builder for Schools + + + 7,692 + 11-2020 + A1 + Massively Overpaid + Historian for Schools + + + 7,693 + 6-1993 + A1 + Massively Overpaid + Philosopher Trainer + + + 7,694 + 12-2004 + B1 + Fairly Paid + Assassin for the Environment + + + 7,695 + 8-1995 + A2 + Overpaid + Software Developer of Parties + + + 7,696 + 9-1997 + B2 + Underpaid + Assassin Laureate + + + 7,697 + 6-2006 + C1 + Massively Underpaid + Builder for the Environment + + + 7,698 + 10-2008 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 7,699 + 9-2014 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 7,700 + 10-2011 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 7,701 + 6-1995 + B1 + Fairly Paid + Philosopher for Eternity + + + 7,702 + 5-2010 + A1 + Massively Overpaid + Software Developer of Doom + + + 7,703 + 4-1990 + C2 + Slave Labour + Historian of Doom + + + 7,704 + 3-2008 + B2 + Underpaid + Food Taster Laureate + + + 7,705 + 5-2012 + B1 + Fairly Paid + Author in Chief + + + 7,706 + 3-2014 + C1 + Massively Underpaid + Historian of Parties + + + 7,707 + 4-2007 + C2 + Slave Labour + Vigilante for Eternity + + + 7,708 + 12-2011 + B1 + Fairly Paid + Philosopher for the Environment + + + 7,709 + 2-2013 + A1 + Massively Overpaid + Historian in Chief + + + 7,710 + 11-1999 + B2 + Underpaid + Historian Extraordinaire + + + 7,711 + 6-2014 + A2 + Overpaid + Software Developer for Eternity + + + 7,712 + 9-2001 + B2 + Underpaid + Historian of Doom + + + 7,713 + 6-2020 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 7,714 + 12-2014 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 7,715 + 6-2022 + C1 + Massively Underpaid + Builder of Doom + + + 7,716 + 7-1992 + A2 + Overpaid + Vigilante of Parties + + + 7,717 + 12-2015 + B1 + Fairly Paid + Author in Chief + + + 7,718 + 12-2001 + A2 + Overpaid + Software Developer in Chief + + + 7,719 + 11-2007 + C2 + Slave Labour + Philosopher of Doom + + + 7,720 + 4-2004 + B1 + Fairly Paid + Food Taster for Eternity + + + 7,721 + 6-2023 + C2 + Slave Labour + Food Taster of Parties + + + 7,722 + 9-2016 + A2 + Overpaid + Author of Doom + + + 7,723 + 1-1991 + C1 + Massively Underpaid + Builder Extraordinaire + + + 7,724 + 3-2001 + C1 + Massively Underpaid + Philosopher for Schools + + + 7,725 + 10-2016 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 7,726 + 6-1990 + A2 + Overpaid + Author for Schools + + + 7,727 + 1-2014 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 7,728 + 11-2011 + C2 + Slave Labour + Author Extraordinaire + + + 7,729 + 5-2009 + A1 + Massively Overpaid + Author (Trainee) + + + 7,730 + 6-1997 + B2 + Underpaid + Philosopher in Chief + + + 7,731 + 1-2020 + C2 + Slave Labour + Historian for the Environment + + + 7,732 + 7-1997 + B2 + Underpaid + Builder Laureate + + + 7,733 + 9-2018 + B1 + Fairly Paid + Sports Mascot in Chief + + + 7,734 + 1-2003 + A2 + Overpaid + Builder of Parties + + + 7,735 + 9-2017 + B2 + Underpaid + Skydiving Instructor in Chief + + + 7,736 + 3-2000 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 7,737 + 9-2006 + A1 + Massively Overpaid + Food Taster for the Environment + + + 7,738 + 4-2021 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 7,739 + 7-1995 + A1 + Massively Overpaid + Philosopher for Eternity + + + 7,740 + 1-2001 + B2 + Underpaid + Software Developer (Trainee) + + + 7,741 + 7-2012 + C1 + Massively Underpaid + Philosopher Trainer + + + 7,742 + 3-2019 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 7,743 + 12-1990 + A1 + Massively Overpaid + Assassin of Parties + + + 7,744 + 9-1998 + A2 + Overpaid + Skydiving Instructor of Parties + + + 7,745 + 8-2014 + A1 + Massively Overpaid + Historian for Schools + + + 7,746 + 7-2018 + C2 + Slave Labour + Historian of Cattle + + + 7,747 + 1-1995 + B2 + Underpaid + Historian of Doom + + + 7,748 + 12-2017 + C1 + Massively Underpaid + Vigilante of Parties + + + 7,749 + 4-2022 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 7,750 + 10-2001 + A2 + Overpaid + Assassin for Schools + + + 7,751 + 9-2017 + A1 + Massively Overpaid + Historian of Cattle + + + 7,752 + 8-2023 + A1 + Massively Overpaid + Software Developer in Chief + + + 7,753 + 6-2016 + C2 + Slave Labour + Food Taster (Trainee) + + + 7,754 + 11-2017 + B1 + Fairly Paid + Historian Laureate + + + 7,755 + 12-2022 + B1 + Fairly Paid + Software Developer of Cattle + + + 7,756 + 6-2015 + C1 + Massively Underpaid + Food Taster Trainer + + + 7,757 + 3-1991 + A2 + Overpaid + Vigilante Extraordinaire + + + 7,758 + 6-1996 + A1 + Massively Overpaid + Builder of Parties + + + 7,759 + 12-2010 + C1 + Massively Underpaid + Author Extraordinaire + + + 7,760 + 6-2000 + B1 + Fairly Paid + Philosopher for the Environment + + + 7,761 + 12-1992 + B2 + Underpaid + Assassin of Doom + + + 7,762 + 12-2022 + B1 + Fairly Paid + Vigilante in Chief + + + 7,763 + 11-2005 + C1 + Massively Underpaid + Builder for Eternity + + + 7,764 + 8-2017 + C2 + Slave Labour + Sports Mascot of Doom + + + 7,765 + 1-2003 + B1 + Fairly Paid + Builder of Doom + + + 7,766 + 1-2004 + C2 + Slave Labour + Philosopher (Trainee) + + + 7,767 + 6-2004 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 7,768 + 12-2013 + A1 + Massively Overpaid + Historian Laureate + + + 7,769 + 5-1998 + B1 + Fairly Paid + Philosopher of Doom + + + 7,770 + 3-2011 + C2 + Slave Labour + Philosopher for Schools + + + 7,771 + 11-1991 + C2 + Slave Labour + Sports Mascot for Schools + + + 7,772 + 2-2011 + A2 + Overpaid + Sports Mascot for the Environment + + + 7,773 + 9-1993 + C1 + Massively Underpaid + Vigilante Trainer + + + 7,774 + 8-2015 + C1 + Massively Underpaid + Historian Laureate + + + 7,775 + 7-2004 + C1 + Massively Underpaid + Philosopher of Doom + + + 7,776 + 12-1999 + B2 + Underpaid + Software Developer of Cattle + + + 7,777 + 5-2007 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 7,778 + 1-2000 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 7,779 + 1-2014 + B1 + Fairly Paid + Food Taster for Schools + + + 7,780 + 1-2006 + B1 + Fairly Paid + Builder (Trainee) + + + 7,781 + 6-1990 + B1 + Fairly Paid + Food Taster in Chief + + + 7,782 + 5-1993 + B1 + Fairly Paid + Vigilante for Schools + + + 7,783 + 2-2012 + C1 + Massively Underpaid + Historian (Trainee) + + + 7,784 + 11-2002 + A1 + Massively Overpaid + Software Developer Laureate + + + 7,785 + 9-1999 + B2 + Underpaid + Food Taster (Trainee) + + + 7,786 + 4-2006 + C1 + Massively Underpaid + Philosopher in Chief + + + 7,787 + 9-2006 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 7,788 + 7-2001 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 7,789 + 12-2003 + A1 + Massively Overpaid + Builder Extraordinaire + + + 7,790 + 8-2007 + B2 + Underpaid + Author of Cattle + + + 7,791 + 7-1991 + B1 + Fairly Paid + Assassin in Chief + + + 7,792 + 9-2012 + C2 + Slave Labour + Sports Mascot Trainer + + + 7,793 + 8-2012 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 7,794 + 11-2010 + B1 + Fairly Paid + Builder Trainer + + + 7,795 + 6-2010 + B1 + Fairly Paid + Food Taster Laureate + + + 7,796 + 7-2012 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 7,797 + 2-2023 + C2 + Slave Labour + Philosopher of Parties + + + 7,798 + 12-2005 + A2 + Overpaid + Sports Mascot for the Environment + + + 7,799 + 4-1998 + A2 + Overpaid + Builder of Doom + + + 7,800 + 10-2014 + B1 + Fairly Paid + Historian for Schools + + + 7,801 + 8-2011 + B2 + Underpaid + Philosopher of Cattle + + + 7,802 + 4-2014 + A2 + Overpaid + Software Developer Extraordinaire + + + 7,803 + 3-2010 + B2 + Underpaid + Author for the Environment + + + 7,804 + 12-2015 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 7,805 + 5-2006 + B2 + Underpaid + Historian Trainer + + + 7,806 + 1-2000 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 7,807 + 7-2002 + A2 + Overpaid + Assassin Extraordinaire + + + 7,808 + 5-1998 + B2 + Underpaid + Author in Chief + + + 7,809 + 4-1995 + B1 + Fairly Paid + Vigilante for the Environment + + + 7,810 + 9-1992 + A2 + Overpaid + Builder Laureate + + + 7,811 + 7-2020 + C2 + Slave Labour + Assassin for Eternity + + + 7,812 + 12-2001 + A2 + Overpaid + Builder of Cattle + + + 7,813 + 3-2023 + C2 + Slave Labour + Author for Schools + + + 7,814 + 5-2012 + C2 + Slave Labour + Historian of Doom + + + 7,815 + 9-2009 + B2 + Underpaid + Food Taster for the Environment + + + 7,816 + 10-2019 + B1 + Fairly Paid + Skydiving Instructor for Schools + + + 7,817 + 7-2002 + B2 + Underpaid + Skydiving Instructor of Cattle + + + 7,818 + 5-2016 + C2 + Slave Labour + Assassin Laureate + + + 7,819 + 9-1999 + C1 + Massively Underpaid + Food Taster of Cattle + + + 7,820 + 10-2008 + C2 + Slave Labour + Philosopher of Parties + + + 7,821 + 8-2001 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 7,822 + 5-2023 + A2 + Overpaid + Builder (Trainee) + + + 7,823 + 5-2004 + A2 + Overpaid + Vigilante of Doom + + + 7,824 + 1-1993 + B1 + Fairly Paid + Author for Eternity + + + 7,825 + 8-1999 + B2 + Underpaid + Assassin Laureate + + + 7,826 + 6-2009 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 7,827 + 8-2006 + A2 + Overpaid + Skydiving Instructor of Doom + + + 7,828 + 10-2011 + C2 + Slave Labour + Food Taster in Chief + + + 7,829 + 4-2000 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 7,830 + 6-2005 + B1 + Fairly Paid + Assassin (Trainee) + + + 7,831 + 5-2015 + B1 + Fairly Paid + Historian of Cattle + + + 7,832 + 2-2006 + B2 + Underpaid + Author Trainer + + + 7,833 + 11-1997 + B2 + Underpaid + Assassin of Doom + + + 7,834 + 2-2017 + A2 + Overpaid + Software Developer (Trainee) + + + 7,835 + 11-2000 + B1 + Fairly Paid + Philosopher Trainer + + + 7,836 + 5-2018 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 7,837 + 6-2021 + B2 + Underpaid + Assassin (Trainee) + + + 7,838 + 10-2007 + A2 + Overpaid + Software Developer in Chief + + + 7,839 + 4-2020 + A2 + Overpaid + Builder (Trainee) + + + 7,840 + 8-2011 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 7,841 + 10-2022 + A2 + Overpaid + Philosopher for Schools + + + 7,842 + 8-2004 + C2 + Slave Labour + Food Taster Laureate + + + 7,843 + 10-2007 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 7,844 + 7-2019 + A1 + Massively Overpaid + Vigilante Trainer + + + 7,845 + 11-2005 + B2 + Underpaid + Vigilante of Parties + + + 7,846 + 10-2021 + B2 + Underpaid + Sports Mascot in Chief + + + 7,847 + 2-2016 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 7,848 + 12-1998 + C2 + Slave Labour + Vigilante of Parties + + + 7,849 + 1-2016 + C2 + Slave Labour + Sports Mascot Laureate + + + 7,850 + 10-1997 + A2 + Overpaid + Software Developer Laureate + + + 7,851 + 3-2013 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 7,852 + 3-2006 + B1 + Fairly Paid + Assassin Extraordinaire + + + 7,853 + 12-2017 + C1 + Massively Underpaid + Philosopher (Trainee) + + + 7,854 + 3-2019 + C2 + Slave Labour + Builder for the Environment + + + 7,855 + 6-1992 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 7,856 + 1-2011 + A2 + Overpaid + Vigilante Trainer + + + 7,857 + 12-2016 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 7,858 + 2-1997 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 7,859 + 2-1990 + B1 + Fairly Paid + Philosopher for Eternity + + + 7,860 + 11-1999 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 7,861 + 11-2010 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 7,862 + 4-2022 + C1 + Massively Underpaid + Assassin Trainer + + + 7,863 + 11-2022 + A2 + Overpaid + Author for Schools + + + 7,864 + 8-2012 + A2 + Overpaid + Skydiving Instructor for Schools + + + 7,865 + 4-2000 + B2 + Underpaid + Vigilante of Parties + + + 7,866 + 11-1990 + C1 + Massively Underpaid + Assassin of Parties + + + 7,867 + 9-1990 + A2 + Overpaid + Author of Parties + + + 7,868 + 10-2009 + B2 + Underpaid + Software Developer in Chief + + + 7,869 + 2-2015 + C2 + Slave Labour + Sports Mascot of Doom + + + 7,870 + 11-1997 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 7,871 + 6-2012 + C2 + Slave Labour + Vigilante (Trainee) + + + 7,872 + 10-1999 + C2 + Slave Labour + Builder for Schools + + + 7,873 + 6-2006 + A1 + Massively Overpaid + Philosopher for the Environment + + + 7,874 + 2-2006 + A2 + Overpaid + Sports Mascot of Doom + + + 7,875 + 4-2018 + B2 + Underpaid + Sports Mascot Trainer + + + 7,876 + 12-2011 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 7,877 + 2-2011 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 7,878 + 1-2023 + A2 + Overpaid + Software Developer Extraordinaire + + + 7,879 + 5-1996 + C1 + Massively Underpaid + Assassin Trainer + + + 7,880 + 10-1996 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 7,881 + 10-1994 + C1 + Massively Underpaid + Author Laureate + + + 7,882 + 11-2011 + C1 + Massively Underpaid + Historian of Doom + + + 7,883 + 8-2020 + A2 + Overpaid + Sports Mascot of Doom + + + 7,884 + 10-2010 + A1 + Massively Overpaid + Food Taster of Doom + + + 7,885 + 12-2023 + C1 + Massively Underpaid + Author in Chief + + + 7,886 + 4-2006 + A1 + Massively Overpaid + Food Taster of Doom + + + 7,887 + 3-2023 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 7,888 + 6-1995 + A2 + Overpaid + Vigilante for Eternity + + + 7,889 + 1-1990 + A2 + Overpaid + Author for Eternity + + + 7,890 + 11-1998 + B1 + Fairly Paid + Philosopher (Trainee) + + + 7,891 + 12-2001 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 7,892 + 4-2000 + C2 + Slave Labour + Vigilante of Parties + + + 7,893 + 11-1990 + C1 + Massively Underpaid + Software Developer of Cattle + + + 7,894 + 12-2011 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 7,895 + 1-2019 + B2 + Underpaid + Historian Laureate + + + 7,896 + 3-2012 + C2 + Slave Labour + Sports Mascot Trainer + + + 7,897 + 3-1997 + C2 + Slave Labour + Philosopher of Parties + + + 7,898 + 1-2007 + A1 + Massively Overpaid + Builder for the Environment + + + 7,899 + 3-2016 + C2 + Slave Labour + Historian for Schools + + + 7,900 + 4-2021 + B2 + Underpaid + Sports Mascot of Cattle + + + 7,901 + 4-1999 + C1 + Massively Underpaid + Philosopher for Eternity + + + 7,902 + 9-2022 + A2 + Overpaid + Assassin of Doom + + + 7,903 + 1-1998 + B1 + Fairly Paid + Food Taster of Parties + + + 7,904 + 3-1990 + C1 + Massively Underpaid + Builder of Doom + + + 7,905 + 8-2020 + B2 + Underpaid + Skydiving Instructor of Parties + + + 7,906 + 7-1991 + C1 + Massively Underpaid + Sports Mascot in Chief + + + 7,907 + 6-2003 + C2 + Slave Labour + Philosopher for Eternity + + + 7,908 + 6-2000 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 7,909 + 4-2007 + C2 + Slave Labour + Sports Mascot Laureate + + + 7,910 + 1-2011 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 7,911 + 6-2023 + A2 + Overpaid + Food Taster (Trainee) + + + 7,912 + 12-1991 + C2 + Slave Labour + Historian Trainer + + + 7,913 + 7-2007 + B1 + Fairly Paid + Software Developer Laureate + + + 7,914 + 11-1991 + B2 + Underpaid + Assassin for the Environment + + + 7,915 + 4-2003 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 7,916 + 11-2006 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 7,917 + 1-2017 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 7,918 + 12-1992 + C2 + Slave Labour + Software Developer Trainer + + + 7,919 + 10-2000 + B2 + Underpaid + Builder in Chief + + + 7,920 + 12-2001 + A2 + Overpaid + Philosopher for the Environment + + + 7,921 + 2-1995 + A2 + Overpaid + Software Developer Trainer + + + 7,922 + 4-2019 + B1 + Fairly Paid + Assassin in Chief + + + 7,923 + 5-2010 + A2 + Overpaid + Philosopher of Cattle + + + 7,924 + 4-2017 + C2 + Slave Labour + Author of Parties + + + 7,925 + 8-2001 + C2 + Slave Labour + Vigilante (Trainee) + + + 7,926 + 9-2021 + B2 + Underpaid + Sports Mascot (Trainee) + + + 7,927 + 6-2003 + C2 + Slave Labour + Vigilante for Eternity + + + 7,928 + 3-2011 + B1 + Fairly Paid + Food Taster in Chief + + + 7,929 + 3-1991 + C1 + Massively Underpaid + Software Developer of Cattle + + + 7,930 + 5-1997 + C1 + Massively Underpaid + Historian of Cattle + + + 7,931 + 2-2017 + C1 + Massively Underpaid + Vigilante of Doom + + + 7,932 + 1-1993 + B2 + Underpaid + Author for the Environment + + + 7,933 + 3-2019 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 7,934 + 7-2020 + C2 + Slave Labour + Author for the Environment + + + 7,935 + 2-2005 + A1 + Massively Overpaid + Food Taster for the Environment + + + 7,936 + 2-2022 + B1 + Fairly Paid + Software Developer Trainer + + + 7,937 + 1-2002 + A1 + Massively Overpaid + Builder Laureate + + + 7,938 + 2-2000 + A2 + Overpaid + Food Taster in Chief + + + 7,939 + 11-1996 + B2 + Underpaid + Food Taster for the Environment + + + 7,940 + 12-2019 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 7,941 + 4-2005 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 7,942 + 2-2011 + B1 + Fairly Paid + Builder Trainer + + + 7,943 + 2-2012 + B2 + Underpaid + Builder of Parties + + + 7,944 + 1-2019 + A2 + Overpaid + Vigilante Trainer + + + 7,945 + 7-1993 + B1 + Fairly Paid + Historian Extraordinaire + + + 7,946 + 8-2019 + A2 + Overpaid + Philosopher of Parties + + + 7,947 + 7-2011 + A1 + Massively Overpaid + Builder Extraordinaire + + + 7,948 + 10-2002 + C2 + Slave Labour + Sports Mascot Laureate + + + 7,949 + 1-2016 + A2 + Overpaid + Builder of Parties + + + 7,950 + 6-1998 + C1 + Massively Underpaid + Builder Laureate + + + 7,951 + 9-2018 + C2 + Slave Labour + Builder (Trainee) + + + 7,952 + 2-2019 + A2 + Overpaid + Sports Mascot Trainer + + + 7,953 + 11-1996 + B2 + Underpaid + Assassin for Schools + + + 7,954 + 11-2011 + B1 + Fairly Paid + Historian Laureate + + + 7,955 + 10-2002 + A1 + Massively Overpaid + Software Developer Trainer + + + 7,956 + 2-2015 + B1 + Fairly Paid + Builder of Doom + + + 7,957 + 5-2015 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 7,958 + 12-1998 + B1 + Fairly Paid + Vigilante for Eternity + + + 7,959 + 1-1990 + B1 + Fairly Paid + Assassin of Cattle + + + 7,960 + 6-2021 + B1 + Fairly Paid + Philosopher Trainer + + + 7,961 + 8-2020 + C1 + Massively Underpaid + Author for Eternity + + + 7,962 + 2-1996 + A1 + Massively Overpaid + Author for Schools + + + 7,963 + 1-2010 + B2 + Underpaid + Historian (Trainee) + + + 7,964 + 3-2021 + A1 + Massively Overpaid + Historian of Parties + + + 7,965 + 1-2009 + A2 + Overpaid + Builder for Schools + + + 7,966 + 6-2014 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 7,967 + 8-2023 + B2 + Underpaid + Builder of Doom + + + 7,968 + 12-2021 + B1 + Fairly Paid + Builder of Cattle + + + 7,969 + 8-1993 + B2 + Underpaid + Philosopher of Cattle + + + 7,970 + 8-2020 + B1 + Fairly Paid + Assassin in Chief + + + 7,971 + 1-2006 + A2 + Overpaid + Software Developer Extraordinaire + + + 7,972 + 12-2006 + B1 + Fairly Paid + Vigilante of Parties + + + 7,973 + 8-2007 + C2 + Slave Labour + Historian for Eternity + + + 7,974 + 9-1992 + A1 + Massively Overpaid + Historian for Schools + + + 7,975 + 3-1999 + A2 + Overpaid + Builder of Parties + + + 7,976 + 5-2012 + A2 + Overpaid + Philosopher Trainer + + + 7,977 + 2-2022 + C2 + Slave Labour + Historian of Parties + + + 7,978 + 4-2005 + C1 + Massively Underpaid + Author for the Environment + + + 7,979 + 12-2017 + C2 + Slave Labour + Software Developer of Parties + + + 7,980 + 11-2002 + B1 + Fairly Paid + Software Developer of Parties + + + 7,981 + 5-1999 + B2 + Underpaid + Food Taster of Parties + + + 7,982 + 6-2013 + B1 + Fairly Paid + Historian of Cattle + + + 7,983 + 11-2011 + B1 + Fairly Paid + Food Taster for the Environment + + + 7,984 + 12-1997 + C1 + Massively Underpaid + Author of Parties + + + 7,985 + 9-2005 + A2 + Overpaid + Food Taster of Doom + + + 7,986 + 2-1999 + A2 + Overpaid + Food Taster Extraordinaire + + + 7,987 + 10-2013 + B1 + Fairly Paid + Historian for Eternity + + + 7,988 + 4-1990 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 7,989 + 12-2014 + C1 + Massively Underpaid + Builder Laureate + + + 7,990 + 7-2011 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 7,991 + 7-2004 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 7,992 + 1-2006 + B1 + Fairly Paid + Builder for the Environment + + + 7,993 + 11-2008 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 7,994 + 10-2016 + C2 + Slave Labour + Sports Mascot for Eternity + + + 7,995 + 1-1996 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 7,996 + 8-2020 + A2 + Overpaid + Sports Mascot Trainer + + + 7,997 + 8-1999 + B2 + Underpaid + Assassin of Cattle + + + 7,998 + 4-2007 + B2 + Underpaid + Assassin Trainer + + + 7,999 + 11-2003 + B1 + Fairly Paid + Software Developer of Parties + + + 8,000 + 4-1997 + B2 + Underpaid + Sports Mascot (Trainee) + + + 8,001 + 2-2018 + B2 + Underpaid + Vigilante Trainer + + + 8,002 + 5-1990 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 8,003 + 9-2015 + C2 + Slave Labour + Historian of Cattle + + + 8,004 + 9-2020 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 8,005 + 10-2021 + B2 + Underpaid + Skydiving Instructor of Parties + + + 8,006 + 4-2011 + B1 + Fairly Paid + Builder for Eternity + + + 8,007 + 7-1993 + C2 + Slave Labour + Historian Laureate + + + 8,008 + 1-2020 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 8,009 + 9-1993 + C2 + Slave Labour + Philosopher Laureate + + + 8,010 + 4-1997 + C2 + Slave Labour + Historian Extraordinaire + + + 8,011 + 4-2002 + C2 + Slave Labour + Assassin (Trainee) + + + 8,012 + 2-2002 + B2 + Underpaid + Sports Mascot for Schools + + + 8,013 + 4-2018 + C2 + Slave Labour + Builder Laureate + + + 8,014 + 8-2017 + C1 + Massively Underpaid + Builder Trainer + + + 8,015 + 12-2014 + A2 + Overpaid + Author (Trainee) + + + 8,016 + 11-2020 + B2 + Underpaid + Philosopher in Chief + + + 8,017 + 4-2005 + A2 + Overpaid + Sports Mascot of Parties + + + 8,018 + 6-1997 + A2 + Overpaid + Philosopher of Doom + + + 8,019 + 2-2007 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 8,020 + 1-2006 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 8,021 + 3-1999 + C1 + Massively Underpaid + Assassin Laureate + + + 8,022 + 5-2001 + C2 + Slave Labour + Philosopher for Schools + + + 8,023 + 5-1997 + C2 + Slave Labour + Software Developer for Eternity + + + 8,024 + 11-1992 + A2 + Overpaid + Sports Mascot for Eternity + + + 8,025 + 11-1990 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 8,026 + 3-2010 + A1 + Massively Overpaid + Builder Trainer + + + 8,027 + 2-1995 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 8,028 + 2-2015 + C2 + Slave Labour + Author for Eternity + + + 8,029 + 10-1996 + C1 + Massively Underpaid + Assassin Laureate + + + 8,030 + 11-2006 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 8,031 + 11-1999 + B2 + Underpaid + Historian of Doom + + + 8,032 + 3-2019 + A2 + Overpaid + Author Extraordinaire + + + 8,033 + 2-2003 + C2 + Slave Labour + Sports Mascot of Cattle + + + 8,034 + 5-2017 + A2 + Overpaid + Philosopher Extraordinaire + + + 8,035 + 2-2017 + C1 + Massively Underpaid + Author in Chief + + + 8,036 + 6-1996 + B2 + Underpaid + Assassin in Chief + + + 8,037 + 4-2007 + B1 + Fairly Paid + Food Taster Laureate + + + 8,038 + 5-1998 + A1 + Massively Overpaid + Food Taster (Trainee) + + + 8,039 + 9-2008 + A1 + Massively Overpaid + Historian for Eternity + + + 8,040 + 5-2009 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 8,041 + 4-1993 + A2 + Overpaid + Historian Laureate + + + 8,042 + 3-2010 + C2 + Slave Labour + Assassin Extraordinaire + + + 8,043 + 10-2001 + B2 + Underpaid + Author in Chief + + + 8,044 + 11-1999 + A2 + Overpaid + Historian of Doom + + + 8,045 + 12-2021 + B2 + Underpaid + Food Taster Trainer + + + 8,046 + 8-2005 + B2 + Underpaid + Sports Mascot (Trainee) + + + 8,047 + 8-1991 + B2 + Underpaid + Builder of Parties + + + 8,048 + 2-2016 + C2 + Slave Labour + Author Laureate + + + 8,049 + 2-2017 + B2 + Underpaid + Philosopher for Schools + + + 8,050 + 10-2006 + B1 + Fairly Paid + Author Extraordinaire + + + 8,051 + 10-2017 + C2 + Slave Labour + Food Taster Laureate + + + 8,052 + 12-1990 + C1 + Massively Underpaid + Builder for Eternity + + + 8,053 + 4-1996 + A2 + Overpaid + Software Developer Trainer + + + 8,054 + 6-1993 + B1 + Fairly Paid + Philosopher of Doom + + + 8,055 + 12-2001 + C2 + Slave Labour + Software Developer for Eternity + + + 8,056 + 5-2017 + A1 + Massively Overpaid + Software Developer for Eternity + + + 8,057 + 8-1999 + A1 + Massively Overpaid + Philosopher for the Environment + + + 8,058 + 2-2022 + A1 + Massively Overpaid + Historian (Trainee) + + + 8,059 + 3-2023 + C2 + Slave Labour + Food Taster for the Environment + + + 8,060 + 12-1991 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 8,061 + 1-2003 + C2 + Slave Labour + Vigilante of Parties + + + 8,062 + 10-2006 + A2 + Overpaid + Builder Extraordinaire + + + 8,063 + 8-2000 + B1 + Fairly Paid + Software Developer of Doom + + + 8,064 + 4-2011 + B2 + Underpaid + Author Laureate + + + 8,065 + 9-1999 + C1 + Massively Underpaid + Food Taster of Cattle + + + 8,066 + 4-2022 + A2 + Overpaid + Assassin Laureate + + + 8,067 + 10-2021 + A2 + Overpaid + Historian of Parties + + + 8,068 + 9-2013 + A2 + Overpaid + Software Developer for the Environment + + + 8,069 + 8-2005 + B2 + Underpaid + Sports Mascot of Cattle + + + 8,070 + 12-1999 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 8,071 + 5-2008 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 8,072 + 12-1993 + B1 + Fairly Paid + Vigilante of Cattle + + + 8,073 + 5-2020 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 8,074 + 3-1998 + C1 + Massively Underpaid + Food Taster of Parties + + + 8,075 + 4-1998 + A1 + Massively Overpaid + Software Developer Trainer + + + 8,076 + 10-1991 + C2 + Slave Labour + Historian (Trainee) + + + 8,077 + 12-1999 + C2 + Slave Labour + Food Taster for the Environment + + + 8,078 + 3-1994 + B1 + Fairly Paid + Author Trainer + + + 8,079 + 11-2019 + B1 + Fairly Paid + Assassin Trainer + + + 8,080 + 3-2006 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 8,081 + 6-1995 + C2 + Slave Labour + Historian Laureate + + + 8,082 + 8-1999 + A1 + Massively Overpaid + Historian in Chief + + + 8,083 + 9-2021 + A2 + Overpaid + Skydiving Instructor Trainer + + + 8,084 + 11-2016 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 8,085 + 1-2015 + C1 + Massively Underpaid + Historian Trainer + + + 8,086 + 6-2021 + B2 + Underpaid + Software Developer Laureate + + + 8,087 + 2-1997 + A1 + Massively Overpaid + Philosopher of Doom + + + 8,088 + 2-1994 + A2 + Overpaid + Sports Mascot of Parties + + + 8,089 + 11-1991 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 8,090 + 7-2012 + B2 + Underpaid + Software Developer for Schools + + + 8,091 + 1-2019 + B1 + Fairly Paid + Vigilante for Eternity + + + 8,092 + 9-1993 + B1 + Fairly Paid + Philosopher Trainer + + + 8,093 + 6-2016 + C2 + Slave Labour + Food Taster (Trainee) + + + 8,094 + 10-2009 + C2 + Slave Labour + Builder in Chief + + + 8,095 + 2-2006 + A2 + Overpaid + Assassin Laureate + + + 8,096 + 4-2023 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 8,097 + 10-2007 + C2 + Slave Labour + Builder of Parties + + + 8,098 + 1-1992 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 8,099 + 2-1990 + A2 + Overpaid + Historian of Parties + + + 8,100 + 5-2018 + A1 + Massively Overpaid + Assassin Laureate + + + 8,101 + 3-2013 + B2 + Underpaid + Author Laureate + + + 8,102 + 11-2009 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 8,103 + 8-2005 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 8,104 + 6-2011 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 8,105 + 2-1992 + A1 + Massively Overpaid + Food Taster of Parties + + + 8,106 + 10-1997 + C2 + Slave Labour + Vigilante for Schools + + + 8,107 + 11-2000 + B2 + Underpaid + Assassin of Cattle + + + 8,108 + 11-1997 + B2 + Underpaid + Author (Trainee) + + + 8,109 + 7-2018 + A1 + Massively Overpaid + Food Taster Laureate + + + 8,110 + 4-2000 + B2 + Underpaid + Vigilante for Schools + + + 8,111 + 2-2014 + B1 + Fairly Paid + Software Developer for Schools + + + 8,112 + 3-1997 + A2 + Overpaid + Sports Mascot for Schools + + + 8,113 + 1-1997 + A2 + Overpaid + Food Taster in Chief + + + 8,114 + 2-1992 + B2 + Underpaid + Skydiving Instructor Laureate + + + 8,115 + 9-2005 + B1 + Fairly Paid + Software Developer for the Environment + + + 8,116 + 5-2003 + A1 + Massively Overpaid + Author of Doom + + + 8,117 + 3-1997 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 8,118 + 2-2004 + A1 + Massively Overpaid + Author Trainer + + + 8,119 + 1-1990 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 8,120 + 12-1993 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 8,121 + 3-2013 + B1 + Fairly Paid + Food Taster Laureate + + + 8,122 + 6-2001 + A1 + Massively Overpaid + Historian for Eternity + + + 8,123 + 4-1998 + A1 + Massively Overpaid + Historian Laureate + + + 8,124 + 11-2007 + A2 + Overpaid + Food Taster for the Environment + + + 8,125 + 1-1999 + B2 + Underpaid + Philosopher of Cattle + + + 8,126 + 9-2022 + B2 + Underpaid + Sports Mascot Trainer + + + 8,127 + 2-2015 + A1 + Massively Overpaid + Historian of Parties + + + 8,128 + 10-1993 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 8,129 + 5-1999 + B1 + Fairly Paid + Vigilante Laureate + + + 8,130 + 9-2010 + C2 + Slave Labour + Food Taster for Schools + + + 8,131 + 1-2013 + B1 + Fairly Paid + Vigilante for Schools + + + 8,132 + 2-2001 + A2 + Overpaid + Vigilante in Chief + + + 8,133 + 9-2023 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 8,134 + 4-2010 + A2 + Overpaid + Sports Mascot of Parties + + + 8,135 + 12-2019 + B1 + Fairly Paid + Software Developer for the Environment + + + 8,136 + 4-2011 + B2 + Underpaid + Vigilante of Parties + + + 8,137 + 7-2012 + A2 + Overpaid + Builder for Eternity + + + 8,138 + 1-2022 + B2 + Underpaid + Philosopher of Doom + + + 8,139 + 2-2011 + A1 + Massively Overpaid + Builder for Eternity + + + 8,140 + 3-2012 + C2 + Slave Labour + Sports Mascot for the Environment + + + 8,141 + 11-2000 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 8,142 + 12-2000 + B2 + Underpaid + Historian for Schools + + + 8,143 + 3-2013 + B1 + Fairly Paid + Food Taster for Schools + + + 8,144 + 12-2010 + C2 + Slave Labour + Builder of Cattle + + + 8,145 + 5-2002 + C2 + Slave Labour + Software Developer of Parties + + + 8,146 + 9-2015 + C1 + Massively Underpaid + Philosopher Laureate + + + 8,147 + 3-1991 + A1 + Massively Overpaid + Vigilante of Parties + + + 8,148 + 6-1994 + B2 + Underpaid + Builder Extraordinaire + + + 8,149 + 10-2021 + B1 + Fairly Paid + Software Developer for Schools + + + 8,150 + 5-1999 + C2 + Slave Labour + Software Developer in Chief + + + 8,151 + 12-2017 + C2 + Slave Labour + Philosopher for Eternity + + + 8,152 + 3-2010 + A2 + Overpaid + Food Taster of Doom + + + 8,153 + 9-2006 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 8,154 + 10-2013 + B2 + Underpaid + Historian in Chief + + + 8,155 + 3-1990 + A2 + Overpaid + Builder for Schools + + + 8,156 + 9-2018 + A1 + Massively Overpaid + Builder of Parties + + + 8,157 + 2-2001 + B1 + Fairly Paid + Author for Eternity + + + 8,158 + 1-2013 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 8,159 + 6-2021 + B2 + Underpaid + Software Developer Extraordinaire + + + 8,160 + 7-2021 + C1 + Massively Underpaid + Assassin of Parties + + + 8,161 + 7-2011 + B2 + Underpaid + Historian Extraordinaire + + + 8,162 + 4-2018 + C1 + Massively Underpaid + Philosopher of Doom + + + 8,163 + 5-2003 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 8,164 + 4-2023 + C1 + Massively Underpaid + Historian Laureate + + + 8,165 + 10-2019 + C1 + Massively Underpaid + Assassin for Schools + + + 8,166 + 7-2012 + A2 + Overpaid + Assassin of Parties + + + 8,167 + 3-2018 + B1 + Fairly Paid + Builder of Parties + + + 8,168 + 2-2009 + A1 + Massively Overpaid + Food Taster Extraordinaire + + + 8,169 + 7-2011 + C2 + Slave Labour + Food Taster Trainer + + + 8,170 + 8-2015 + C1 + Massively Underpaid + Author of Doom + + + 8,171 + 7-2015 + B2 + Underpaid + Skydiving Instructor for Schools + + + 8,172 + 8-2021 + B2 + Underpaid + Vigilante (Trainee) + + + 8,173 + 6-2008 + B2 + Underpaid + Vigilante of Cattle + + + 8,174 + 5-1999 + C1 + Massively Underpaid + Author (Trainee) + + + 8,175 + 12-2008 + C1 + Massively Underpaid + Assassin Trainer + + + 8,176 + 2-1994 + A1 + Massively Overpaid + Food Taster for Schools + + + 8,177 + 1-1997 + A1 + Massively Overpaid + Builder Trainer + + + 8,178 + 1-2011 + C2 + Slave Labour + Builder Laureate + + + 8,179 + 2-2002 + A2 + Overpaid + Philosopher (Trainee) + + + 8,180 + 2-2002 + B2 + Underpaid + Vigilante for Eternity + + + 8,181 + 10-1993 + A2 + Overpaid + Historian Trainer + + + 8,182 + 7-2018 + C1 + Massively Underpaid + Author of Cattle + + + 8,183 + 11-2007 + B1 + Fairly Paid + Author for Schools + + + 8,184 + 12-2007 + A2 + Overpaid + Philosopher for Eternity + + + 8,185 + 4-2020 + B1 + Fairly Paid + Philosopher for the Environment + + + 8,186 + 8-2022 + C1 + Massively Underpaid + Builder of Cattle + + + 8,187 + 7-2007 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 8,188 + 2-2017 + A2 + Overpaid + Software Developer Laureate + + + 8,189 + 1-2019 + B1 + Fairly Paid + Author for Schools + + + 8,190 + 2-2009 + B2 + Underpaid + Philosopher for Schools + + + 8,191 + 8-2017 + A1 + Massively Overpaid + Philosopher in Chief + + + 8,192 + 11-1991 + B2 + Underpaid + Builder of Cattle + + + 8,193 + 7-2006 + A1 + Massively Overpaid + Food Taster in Chief + + + 8,194 + 10-2002 + C1 + Massively Underpaid + Builder for Schools + + + 8,195 + 7-2016 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 8,196 + 6-2001 + A2 + Overpaid + Vigilante of Cattle + + + 8,197 + 10-2013 + C1 + Massively Underpaid + Historian for Eternity + + + 8,198 + 10-2015 + A1 + Massively Overpaid + Food Taster Trainer + + + 8,199 + 11-1996 + C2 + Slave Labour + Builder for Eternity + + + 8,200 + 7-2005 + C2 + Slave Labour + Food Taster (Trainee) + + + 8,201 + 10-2007 + A1 + Massively Overpaid + Historian for Eternity + + + 8,202 + 2-2000 + C1 + Massively Underpaid + Historian Laureate + + + 8,203 + 3-2019 + C2 + Slave Labour + Food Taster Trainer + + + 8,204 + 11-2017 + B2 + Underpaid + Skydiving Instructor of Doom + + + 8,205 + 1-2021 + A1 + Massively Overpaid + Food Taster of Doom + + + 8,206 + 5-1990 + C1 + Massively Underpaid + Food Taster for the Environment + + + 8,207 + 4-1999 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 8,208 + 8-2019 + A2 + Overpaid + Sports Mascot Trainer + + + 8,209 + 6-2022 + A2 + Overpaid + Software Developer for Schools + + + 8,210 + 2-2022 + B1 + Fairly Paid + Vigilante for Eternity + + + 8,211 + 8-1996 + B1 + Fairly Paid + Food Taster for Schools + + + 8,212 + 8-2022 + B1 + Fairly Paid + Author for the Environment + + + 8,213 + 7-1999 + B1 + Fairly Paid + Food Taster of Doom + + + 8,214 + 9-2014 + C1 + Massively Underpaid + Builder Trainer + + + 8,215 + 3-1990 + C2 + Slave Labour + Software Developer in Chief + + + 8,216 + 2-2016 + C1 + Massively Underpaid + Vigilante for Eternity + + + 8,217 + 9-2006 + C1 + Massively Underpaid + Assassin (Trainee) + + + 8,218 + 4-2009 + C2 + Slave Labour + Vigilante of Doom + + + 8,219 + 4-2004 + A2 + Overpaid + Sports Mascot in Chief + + + 8,220 + 12-2003 + C2 + Slave Labour + Author of Doom + + + 8,221 + 9-1990 + A1 + Massively Overpaid + Builder (Trainee) + + + 8,222 + 10-1993 + B1 + Fairly Paid + Builder of Parties + + + 8,223 + 3-2014 + A1 + Massively Overpaid + Author Trainer + + + 8,224 + 1-1994 + B2 + Underpaid + Historian of Parties + + + 8,225 + 5-2006 + C1 + Massively Underpaid + Author Laureate + + + 8,226 + 3-2015 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 8,227 + 10-1999 + A2 + Overpaid + Author for Eternity + + + 8,228 + 6-1996 + B2 + Underpaid + Historian Trainer + + + 8,229 + 9-2010 + B2 + Underpaid + Vigilante Laureate + + + 8,230 + 7-1991 + A1 + Massively Overpaid + Historian for Eternity + + + 8,231 + 10-1999 + A2 + Overpaid + Skydiving Instructor Trainer + + + 8,232 + 12-2001 + A1 + Massively Overpaid + Software Developer of Parties + + + 8,233 + 5-2011 + A2 + Overpaid + Builder for Eternity + + + 8,234 + 5-2014 + B2 + Underpaid + Author of Doom + + + 8,235 + 9-2003 + B2 + Underpaid + Author Laureate + + + 8,236 + 12-1994 + C1 + Massively Underpaid + Software Developer for the Environment + + + 8,237 + 1-2003 + A1 + Massively Overpaid + Historian in Chief + + + 8,238 + 3-2005 + C2 + Slave Labour + Philosopher of Parties + + + 8,239 + 11-1998 + C1 + Massively Underpaid + Vigilante of Cattle + + + 8,240 + 3-1994 + B2 + Underpaid + Author (Trainee) + + + 8,241 + 7-2008 + A1 + Massively Overpaid + Builder for the Environment + + + 8,242 + 7-2006 + A2 + Overpaid + Author for Eternity + + + 8,243 + 2-2023 + C1 + Massively Underpaid + Assassin of Parties + + + 8,244 + 10-2014 + C2 + Slave Labour + Vigilante of Parties + + + 8,245 + 9-1998 + C2 + Slave Labour + Builder of Doom + + + 8,246 + 6-2018 + B2 + Underpaid + Food Taster Trainer + + + 8,247 + 6-1994 + A1 + Massively Overpaid + Builder Trainer + + + 8,248 + 2-2006 + B1 + Fairly Paid + Sports Mascot for Schools + + + 8,249 + 7-1998 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 8,250 + 2-1990 + B1 + Fairly Paid + Food Taster for the Environment + + + 8,251 + 5-1994 + B1 + Fairly Paid + Philosopher (Trainee) + + + 8,252 + 10-1991 + B1 + Fairly Paid + Vigilante of Cattle + + + 8,253 + 12-1991 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 8,254 + 7-2011 + A1 + Massively Overpaid + Assassin (Trainee) + + + 8,255 + 3-2018 + B1 + Fairly Paid + Historian Extraordinaire + + + 8,256 + 1-1992 + C2 + Slave Labour + Builder of Doom + + + 8,257 + 2-1998 + C2 + Slave Labour + Historian of Parties + + + 8,258 + 10-2011 + C2 + Slave Labour + Software Developer in Chief + + + 8,259 + 3-2008 + A2 + Overpaid + Software Developer for the Environment + + + 8,260 + 3-1999 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 8,261 + 11-2014 + B1 + Fairly Paid + Assassin Extraordinaire + + + 8,262 + 9-1998 + A1 + Massively Overpaid + Builder for Schools + + + 8,263 + 11-2002 + A2 + Overpaid + Food Taster for Schools + + + 8,264 + 12-2018 + B2 + Underpaid + Food Taster (Trainee) + + + 8,265 + 11-1996 + B2 + Underpaid + Software Developer for the Environment + + + 8,266 + 3-2015 + C2 + Slave Labour + Software Developer of Cattle + + + 8,267 + 6-2009 + C2 + Slave Labour + Philosopher (Trainee) + + + 8,268 + 4-2017 + B2 + Underpaid + Builder for Eternity + + + 8,269 + 4-2018 + A1 + Massively Overpaid + Builder Extraordinaire + + + 8,270 + 4-1993 + A2 + Overpaid + Historian of Doom + + + 8,271 + 9-1995 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 8,272 + 2-2013 + B2 + Underpaid + Sports Mascot of Doom + + + 8,273 + 5-1999 + B1 + Fairly Paid + Historian for Eternity + + + 8,274 + 9-1999 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 8,275 + 7-1992 + C1 + Massively Underpaid + Author for Schools + + + 8,276 + 11-2009 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 8,277 + 3-2006 + A2 + Overpaid + Historian Laureate + + + 8,278 + 10-1998 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 8,279 + 6-2007 + C1 + Massively Underpaid + Author for the Environment + + + 8,280 + 8-1993 + C1 + Massively Underpaid + Author Extraordinaire + + + 8,281 + 6-2016 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 8,282 + 5-2021 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 8,283 + 11-2014 + A1 + Massively Overpaid + Builder (Trainee) + + + 8,284 + 12-1996 + C1 + Massively Underpaid + Assassin for the Environment + + + 8,285 + 1-1995 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 8,286 + 4-2013 + B1 + Fairly Paid + Sports Mascot Laureate + + + 8,287 + 9-2017 + B2 + Underpaid + Author Laureate + + + 8,288 + 8-2009 + A1 + Massively Overpaid + Philosopher Laureate + + + 8,289 + 1-2011 + A1 + Massively Overpaid + Historian for Eternity + + + 8,290 + 12-2011 + C1 + Massively Underpaid + Software Developer for Eternity + + + 8,291 + 6-1992 + C2 + Slave Labour + Food Taster in Chief + + + 8,292 + 5-2004 + A1 + Massively Overpaid + Builder of Parties + + + 8,293 + 11-2007 + A1 + Massively Overpaid + Builder (Trainee) + + + 8,294 + 12-2016 + B1 + Fairly Paid + Food Taster for Eternity + + + 8,295 + 6-1999 + A2 + Overpaid + Assassin Laureate + + + 8,296 + 12-2002 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 8,297 + 12-2000 + A1 + Massively Overpaid + Software Developer for the Environment + + + 8,298 + 9-2003 + B2 + Underpaid + Food Taster for Eternity + + + 8,299 + 2-2010 + A2 + Overpaid + Author Extraordinaire + + + 8,300 + 1-2013 + B1 + Fairly Paid + Food Taster for the Environment + + + 8,301 + 7-2003 + A2 + Overpaid + Author in Chief + + + 8,302 + 11-1991 + B2 + Underpaid + Food Taster for the Environment + + + 8,303 + 6-2014 + B1 + Fairly Paid + Author of Parties + + + 8,304 + 6-2008 + C2 + Slave Labour + Vigilante for Schools + + + 8,305 + 11-2015 + C2 + Slave Labour + Vigilante Trainer + + + 8,306 + 4-1995 + B1 + Fairly Paid + Author for Schools + + + 8,307 + 4-2004 + A1 + Massively Overpaid + Author for Eternity + + + 8,308 + 7-2018 + B1 + Fairly Paid + Historian of Doom + + + 8,309 + 7-1990 + A2 + Overpaid + Food Taster for Eternity + + + 8,310 + 6-2009 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 8,311 + 6-1993 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 8,312 + 3-2013 + B2 + Underpaid + Philosopher Laureate + + + 8,313 + 7-2002 + B1 + Fairly Paid + Historian for Eternity + + + 8,314 + 3-2008 + A2 + Overpaid + Builder Trainer + + + 8,315 + 9-1996 + C1 + Massively Underpaid + Assassin of Parties + + + 8,316 + 6-2022 + A1 + Massively Overpaid + Philosopher for Eternity + + + 8,317 + 11-2016 + C2 + Slave Labour + Software Developer of Doom + + + 8,318 + 10-1997 + A2 + Overpaid + Software Developer for the Environment + + + 8,319 + 11-1996 + C1 + Massively Underpaid + Food Taster Trainer + + + 8,320 + 6-1991 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 8,321 + 7-1995 + A2 + Overpaid + Philosopher for the Environment + + + 8,322 + 1-1999 + C2 + Slave Labour + Author of Doom + + + 8,323 + 6-1992 + C2 + Slave Labour + Author (Trainee) + + + 8,324 + 4-2012 + B1 + Fairly Paid + Assassin in Chief + + + 8,325 + 11-2020 + C1 + Massively Underpaid + Food Taster for Eternity + + + 8,326 + 9-2001 + A2 + Overpaid + Skydiving Instructor of Parties + + + 8,327 + 4-1993 + C2 + Slave Labour + Vigilante in Chief + + + 8,328 + 7-2007 + B2 + Underpaid + Philosopher for Eternity + + + 8,329 + 9-1992 + A2 + Overpaid + Software Developer for the Environment + + + 8,330 + 10-1997 + B2 + Underpaid + Skydiving Instructor Trainer + + + 8,331 + 9-2013 + C1 + Massively Underpaid + Food Taster in Chief + + + 8,332 + 1-2023 + C1 + Massively Underpaid + Vigilante for Eternity + + + 8,333 + 10-2014 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 8,334 + 8-2021 + A2 + Overpaid + Author of Cattle + + + 8,335 + 11-1993 + C1 + Massively Underpaid + Assassin Trainer + + + 8,336 + 10-1997 + A2 + Overpaid + Assassin for Eternity + + + 8,337 + 6-1996 + B1 + Fairly Paid + Sports Mascot for Schools + + + 8,338 + 4-2021 + B2 + Underpaid + Food Taster (Trainee) + + + 8,339 + 9-1994 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 8,340 + 10-2005 + B2 + Underpaid + Food Taster for Schools + + + 8,341 + 8-2010 + A1 + Massively Overpaid + Software Developer for Schools + + + 8,342 + 8-1998 + C2 + Slave Labour + Food Taster for the Environment + + + 8,343 + 2-2008 + B1 + Fairly Paid + Builder Laureate + + + 8,344 + 3-2014 + C1 + Massively Underpaid + Food Taster of Doom + + + 8,345 + 1-1996 + A2 + Overpaid + Author for the Environment + + + 8,346 + 6-2012 + C2 + Slave Labour + Philosopher Laureate + + + 8,347 + 9-1997 + B2 + Underpaid + Builder for the Environment + + + 8,348 + 12-2018 + C1 + Massively Underpaid + Author of Cattle + + + 8,349 + 3-2013 + B2 + Underpaid + Author for Schools + + + 8,350 + 3-1997 + C1 + Massively Underpaid + Software Developer Trainer + + + 8,351 + 7-1994 + B1 + Fairly Paid + Assassin for the Environment + + + 8,352 + 6-1992 + B1 + Fairly Paid + Historian of Parties + + + 8,353 + 11-2019 + C2 + Slave Labour + Assassin in Chief + + + 8,354 + 9-2009 + B2 + Underpaid + Historian Extraordinaire + + + 8,355 + 4-2001 + B2 + Underpaid + Builder (Trainee) + + + 8,356 + 4-2006 + C1 + Massively Underpaid + Author of Doom + + + 8,357 + 9-1999 + B2 + Underpaid + Assassin Laureate + + + 8,358 + 7-1995 + B1 + Fairly Paid + Food Taster Trainer + + + 8,359 + 11-1998 + B1 + Fairly Paid + Author for Schools + + + 8,360 + 2-2015 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 8,361 + 2-2000 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 8,362 + 8-2011 + B2 + Underpaid + Philosopher Laureate + + + 8,363 + 10-2003 + B2 + Underpaid + Builder for Schools + + + 8,364 + 2-2005 + A1 + Massively Overpaid + Software Developer of Parties + + + 8,365 + 12-2002 + C1 + Massively Underpaid + Food Taster Trainer + + + 8,366 + 7-2012 + B1 + Fairly Paid + Assassin of Parties + + + 8,367 + 11-1996 + B1 + Fairly Paid + Vigilante in Chief + + + 8,368 + 7-1991 + C1 + Massively Underpaid + Software Developer of Doom + + + 8,369 + 4-2003 + A2 + Overpaid + Philosopher Laureate + + + 8,370 + 5-2011 + B1 + Fairly Paid + Assassin for Eternity + + + 8,371 + 9-2021 + A2 + Overpaid + Author (Trainee) + + + 8,372 + 8-2017 + B2 + Underpaid + Author of Doom + + + 8,373 + 9-2002 + B1 + Fairly Paid + Food Taster for the Environment + + + 8,374 + 6-1997 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 8,375 + 6-2017 + A1 + Massively Overpaid + Philosopher for the Environment + + + 8,376 + 5-2008 + B1 + Fairly Paid + Philosopher of Cattle + + + 8,377 + 8-2012 + B2 + Underpaid + Historian for the Environment + + + 8,378 + 3-1993 + B1 + Fairly Paid + Builder for Schools + + + 8,379 + 4-2007 + A2 + Overpaid + Historian (Trainee) + + + 8,380 + 6-1995 + A1 + Massively Overpaid + Food Taster Laureate + + + 8,381 + 10-1996 + A2 + Overpaid + Author Laureate + + + 8,382 + 1-2009 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 8,383 + 3-2005 + B2 + Underpaid + Food Taster Extraordinaire + + + 8,384 + 8-1995 + C2 + Slave Labour + Software Developer for the Environment + + + 8,385 + 10-1993 + B1 + Fairly Paid + Vigilante for the Environment + + + 8,386 + 7-2000 + C1 + Massively Underpaid + Builder for Schools + + + 8,387 + 12-2021 + B2 + Underpaid + Sports Mascot Laureate + + + 8,388 + 1-1999 + B1 + Fairly Paid + Assassin Extraordinaire + + + 8,389 + 11-2016 + A2 + Overpaid + Assassin for Eternity + + + 8,390 + 2-1996 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 8,391 + 5-1998 + B2 + Underpaid + Philosopher Laureate + + + 8,392 + 8-1992 + C2 + Slave Labour + Sports Mascot of Doom + + + 8,393 + 2-2016 + B1 + Fairly Paid + Builder for Eternity + + + 8,394 + 9-1993 + C2 + Slave Labour + Vigilante of Doom + + + 8,395 + 1-2009 + B1 + Fairly Paid + Food Taster of Parties + + + 8,396 + 9-2002 + B2 + Underpaid + Vigilante for Eternity + + + 8,397 + 3-2016 + B2 + Underpaid + Builder of Cattle + + + 8,398 + 5-2005 + C2 + Slave Labour + Food Taster in Chief + + + 8,399 + 9-2012 + C1 + Massively Underpaid + Historian Extraordinaire + + + 8,400 + 4-2008 + A2 + Overpaid + Historian of Parties + + + 8,401 + 4-2013 + B2 + Underpaid + Philosopher of Parties + + + 8,402 + 10-2023 + B1 + Fairly Paid + Philosopher for Schools + + + 8,403 + 5-2012 + B1 + Fairly Paid + Philosopher in Chief + + + 8,404 + 6-1992 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 8,405 + 11-2021 + B1 + Fairly Paid + Builder of Doom + + + 8,406 + 8-2009 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 8,407 + 10-1998 + A1 + Massively Overpaid + Philosopher of Cattle + + + 8,408 + 5-2016 + A2 + Overpaid + Food Taster (Trainee) + + + 8,409 + 9-2011 + A2 + Overpaid + Builder Extraordinaire + + + 8,410 + 9-2021 + C1 + Massively Underpaid + Food Taster Trainer + + + 8,411 + 9-2003 + B1 + Fairly Paid + Software Developer of Cattle + + + 8,412 + 12-2010 + A2 + Overpaid + Builder for Eternity + + + 8,413 + 2-2001 + B2 + Underpaid + Skydiving Instructor Trainer + + + 8,414 + 8-2017 + A2 + Overpaid + Author for Schools + + + 8,415 + 1-2007 + B1 + Fairly Paid + Software Developer for Schools + + + 8,416 + 9-2006 + B1 + Fairly Paid + Author Laureate + + + 8,417 + 3-1998 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 8,418 + 12-2019 + C1 + Massively Underpaid + Builder for the Environment + + + 8,419 + 1-1991 + B2 + Underpaid + Philosopher Laureate + + + 8,420 + 7-2004 + C1 + Massively Underpaid + Philosopher for the Environment + + + 8,421 + 3-2005 + B1 + Fairly Paid + Software Developer of Parties + + + 8,422 + 6-2010 + C1 + Massively Underpaid + Vigilante in Chief + + + 8,423 + 1-2010 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 8,424 + 2-2023 + B1 + Fairly Paid + Software Developer of Doom + + + 8,425 + 5-1990 + A2 + Overpaid + Author for Eternity + + + 8,426 + 2-2002 + C1 + Massively Underpaid + Builder Trainer + + + 8,427 + 8-1992 + B1 + Fairly Paid + Philosopher of Doom + + + 8,428 + 3-2020 + C2 + Slave Labour + Builder for the Environment + + + 8,429 + 2-2009 + C1 + Massively Underpaid + Philosopher for Schools + + + 8,430 + 1-1998 + A1 + Massively Overpaid + Builder for the Environment + + + 8,431 + 11-1999 + C1 + Massively Underpaid + Food Taster in Chief + + + 8,432 + 2-2016 + C2 + Slave Labour + Author in Chief + + + 8,433 + 3-1995 + A1 + Massively Overpaid + Author (Trainee) + + + 8,434 + 8-2000 + A1 + Massively Overpaid + Philosopher for Eternity + + + 8,435 + 5-1992 + B1 + Fairly Paid + Author for the Environment + + + 8,436 + 6-2014 + B1 + Fairly Paid + Assassin for Schools + + + 8,437 + 7-2002 + B1 + Fairly Paid + Author of Doom + + + 8,438 + 12-1995 + C1 + Massively Underpaid + Author Laureate + + + 8,439 + 10-2006 + A2 + Overpaid + Software Developer (Trainee) + + + 8,440 + 6-2002 + A1 + Massively Overpaid + Builder Extraordinaire + + + 8,441 + 8-2006 + C2 + Slave Labour + Vigilante for Schools + + + 8,442 + 6-1999 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 8,443 + 10-2005 + C2 + Slave Labour + Sports Mascot of Parties + + + 8,444 + 10-1996 + A2 + Overpaid + Author for Schools + + + 8,445 + 12-1998 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 8,446 + 6-2019 + B2 + Underpaid + Food Taster (Trainee) + + + 8,447 + 8-2002 + A2 + Overpaid + Food Taster (Trainee) + + + 8,448 + 5-2015 + A2 + Overpaid + Philosopher for Eternity + + + 8,449 + 10-2017 + C2 + Slave Labour + Historian Extraordinaire + + + 8,450 + 12-1993 + A2 + Overpaid + Food Taster of Cattle + + + 8,451 + 4-2022 + A2 + Overpaid + Vigilante (Trainee) + + + 8,452 + 3-2014 + B2 + Underpaid + Author Extraordinaire + + + 8,453 + 6-2008 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 8,454 + 8-2008 + C2 + Slave Labour + Builder in Chief + + + 8,455 + 11-2002 + A2 + Overpaid + Author of Doom + + + 8,456 + 4-2002 + A1 + Massively Overpaid + Builder for Eternity + + + 8,457 + 3-2006 + B1 + Fairly Paid + Author in Chief + + + 8,458 + 7-1996 + C1 + Massively Underpaid + Vigilante Trainer + + + 8,459 + 9-2015 + B1 + Fairly Paid + Vigilante of Parties + + + 8,460 + 10-2017 + A1 + Massively Overpaid + Author for Eternity + + + 8,461 + 8-1992 + A2 + Overpaid + Philosopher (Trainee) + + + 8,462 + 11-2014 + C1 + Massively Underpaid + Assassin of Cattle + + + 8,463 + 5-2014 + C1 + Massively Underpaid + Vigilante for the Environment + + + 8,464 + 5-1995 + A2 + Overpaid + Author for the Environment + + + 8,465 + 11-2014 + C1 + Massively Underpaid + Historian of Parties + + + 8,466 + 7-1999 + A2 + Overpaid + Food Taster for the Environment + + + 8,467 + 8-2020 + B2 + Underpaid + Author for Eternity + + + 8,468 + 1-2018 + A1 + Massively Overpaid + Assassin Trainer + + + 8,469 + 11-2007 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 8,470 + 8-2023 + A2 + Overpaid + Assassin of Cattle + + + 8,471 + 3-2006 + C1 + Massively Underpaid + Author for the Environment + + + 8,472 + 9-2009 + C2 + Slave Labour + Author for Schools + + + 8,473 + 12-1999 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 8,474 + 12-1997 + B1 + Fairly Paid + Assassin Laureate + + + 8,475 + 4-2002 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 8,476 + 12-2009 + A1 + Massively Overpaid + Builder of Doom + + + 8,477 + 8-2018 + C2 + Slave Labour + Food Taster of Parties + + + 8,478 + 5-2023 + A1 + Massively Overpaid + Assassin Laureate + + + 8,479 + 5-2005 + A1 + Massively Overpaid + Food Taster in Chief + + + 8,480 + 3-2014 + A2 + Overpaid + Assassin in Chief + + + 8,481 + 8-1992 + A2 + Overpaid + Assassin Trainer + + + 8,482 + 7-2011 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 8,483 + 9-2009 + B1 + Fairly Paid + Food Taster (Trainee) + + + 8,484 + 11-2009 + B2 + Underpaid + Author of Cattle + + + 8,485 + 8-2009 + A1 + Massively Overpaid + Builder of Parties + + + 8,486 + 11-2000 + A2 + Overpaid + Author for Schools + + + 8,487 + 11-2009 + A2 + Overpaid + Builder of Cattle + + + 8,488 + 9-2009 + B2 + Underpaid + Author Extraordinaire + + + 8,489 + 1-2008 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 8,490 + 11-2023 + B2 + Underpaid + Food Taster of Cattle + + + 8,491 + 9-1994 + B1 + Fairly Paid + Builder Extraordinaire + + + 8,492 + 12-2022 + C1 + Massively Underpaid + Assassin for Eternity + + + 8,493 + 7-1992 + B2 + Underpaid + Builder Laureate + + + 8,494 + 7-2006 + C1 + Massively Underpaid + Builder in Chief + + + 8,495 + 5-1991 + B2 + Underpaid + Author Laureate + + + 8,496 + 12-2005 + B2 + Underpaid + Software Developer for Eternity + + + 8,497 + 8-1998 + C2 + Slave Labour + Vigilante for the Environment + + + 8,498 + 11-1994 + B2 + Underpaid + Food Taster for Eternity + + + 8,499 + 11-2003 + C2 + Slave Labour + Food Taster Trainer + + + 8,500 + 7-1999 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 8,501 + 12-1990 + C1 + Massively Underpaid + Author of Doom + + + 8,502 + 11-2004 + C1 + Massively Underpaid + Sports Mascot for the Environment + + + 8,503 + 8-1990 + A2 + Overpaid + Assassin Extraordinaire + + + 8,504 + 9-1992 + A2 + Overpaid + Vigilante Trainer + + + 8,505 + 5-1994 + B1 + Fairly Paid + Historian (Trainee) + + + 8,506 + 12-2009 + A1 + Massively Overpaid + Assassin (Trainee) + + + 8,507 + 7-2023 + C2 + Slave Labour + Vigilante (Trainee) + + + 8,508 + 6-2014 + B2 + Underpaid + Author Extraordinaire + + + 8,509 + 6-2008 + B2 + Underpaid + Skydiving Instructor Laureate + + + 8,510 + 12-2015 + A2 + Overpaid + Builder Laureate + + + 8,511 + 10-1992 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 8,512 + 8-2015 + A2 + Overpaid + Vigilante of Parties + + + 8,513 + 4-1999 + A1 + Massively Overpaid + Historian (Trainee) + + + 8,514 + 4-2002 + B2 + Underpaid + Software Developer Laureate + + + 8,515 + 11-2016 + A2 + Overpaid + Author of Cattle + + + 8,516 + 8-2022 + A1 + Massively Overpaid + Food Taster Trainer + + + 8,517 + 9-1999 + A1 + Massively Overpaid + Historian for Eternity + + + 8,518 + 11-2016 + B2 + Underpaid + Assassin of Cattle + + + 8,519 + 9-2010 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 8,520 + 5-2006 + C2 + Slave Labour + Builder for the Environment + + + 8,521 + 3-2013 + A2 + Overpaid + Software Developer of Cattle + + + 8,522 + 12-2012 + C2 + Slave Labour + Author of Doom + + + 8,523 + 11-2022 + A1 + Massively Overpaid + Skydiving Instructor Extraordinaire + + + 8,524 + 8-2012 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 8,525 + 5-2002 + A1 + Massively Overpaid + Food Taster for Schools + + + 8,526 + 10-1994 + A2 + Overpaid + Author of Cattle + + + 8,527 + 8-2021 + A2 + Overpaid + Vigilante Extraordinaire + + + 8,528 + 11-2019 + A1 + Massively Overpaid + Author Trainer + + + 8,529 + 9-2004 + B2 + Underpaid + Assassin for Eternity + + + 8,530 + 7-2016 + B2 + Underpaid + Assassin for Schools + + + 8,531 + 6-1997 + A2 + Overpaid + Author for Schools + + + 8,532 + 8-1995 + C2 + Slave Labour + Food Taster of Parties + + + 8,533 + 2-2018 + C1 + Massively Underpaid + Historian for Schools + + + 8,534 + 10-2003 + C1 + Massively Underpaid + Assassin (Trainee) + + + 8,535 + 12-2001 + B1 + Fairly Paid + Author in Chief + + + 8,536 + 7-2014 + B2 + Underpaid + Software Developer for Schools + + + 8,537 + 11-2003 + A2 + Overpaid + Software Developer (Trainee) + + + 8,538 + 2-2000 + B1 + Fairly Paid + Builder in Chief + + + 8,539 + 10-1997 + B2 + Underpaid + Historian in Chief + + + 8,540 + 8-1994 + A1 + Massively Overpaid + Vigilante for Schools + + + 8,541 + 1-1998 + A2 + Overpaid + Builder of Doom + + + 8,542 + 3-2021 + B1 + Fairly Paid + Philosopher (Trainee) + + + 8,543 + 9-2000 + A2 + Overpaid + Sports Mascot for Eternity + + + 8,544 + 3-2001 + C2 + Slave Labour + Philosopher of Cattle + + + 8,545 + 2-2017 + B1 + Fairly Paid + Software Developer for Schools + + + 8,546 + 5-2021 + B1 + Fairly Paid + Builder of Doom + + + 8,547 + 7-1997 + B2 + Underpaid + Software Developer Trainer + + + 8,548 + 10-2006 + A2 + Overpaid + Skydiving Instructor for Schools + + + 8,549 + 9-2016 + C2 + Slave Labour + Builder Extraordinaire + + + 8,550 + 1-2017 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 8,551 + 10-2021 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 8,552 + 5-1995 + A2 + Overpaid + Skydiving Instructor Laureate + + + 8,553 + 7-2021 + B1 + Fairly Paid + Vigilante for Schools + + + 8,554 + 4-2010 + B1 + Fairly Paid + Philosopher in Chief + + + 8,555 + 12-1995 + A2 + Overpaid + Historian of Doom + + + 8,556 + 1-1993 + A1 + Massively Overpaid + Assassin for the Environment + + + 8,557 + 3-2011 + A2 + Overpaid + Historian Laureate + + + 8,558 + 11-2003 + A1 + Massively Overpaid + Software Developer of Parties + + + 8,559 + 3-1992 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 8,560 + 11-1995 + A2 + Overpaid + Assassin Laureate + + + 8,561 + 2-2018 + C1 + Massively Underpaid + Author for Schools + + + 8,562 + 4-2022 + B2 + Underpaid + Philosopher (Trainee) + + + 8,563 + 4-1991 + C1 + Massively Underpaid + Food Taster of Parties + + + 8,564 + 12-1992 + C2 + Slave Labour + Author for Schools + + + 8,565 + 12-2005 + C1 + Massively Underpaid + Author of Parties + + + 8,566 + 8-2003 + A2 + Overpaid + Software Developer of Cattle + + + 8,567 + 6-2012 + C1 + Massively Underpaid + Historian Laureate + + + 8,568 + 10-2022 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 8,569 + 3-1994 + C2 + Slave Labour + Software Developer of Cattle + + + 8,570 + 9-2018 + A1 + Massively Overpaid + Assassin of Cattle + + + 8,571 + 2-2003 + C2 + Slave Labour + Builder (Trainee) + + + 8,572 + 2-1996 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 8,573 + 7-2007 + C2 + Slave Labour + Vigilante for Eternity + + + 8,574 + 11-2003 + B1 + Fairly Paid + Software Developer of Cattle + + + 8,575 + 8-2013 + B1 + Fairly Paid + Historian of Cattle + + + 8,576 + 5-2004 + C1 + Massively Underpaid + Builder Trainer + + + 8,577 + 7-2013 + A2 + Overpaid + Author Laureate + + + 8,578 + 4-1994 + C2 + Slave Labour + Philosopher Trainer + + + 8,579 + 9-2011 + B1 + Fairly Paid + Philosopher of Doom + + + 8,580 + 1-2010 + C1 + Massively Underpaid + Builder in Chief + + + 8,581 + 8-2018 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 8,582 + 12-1992 + B1 + Fairly Paid + Author of Parties + + + 8,583 + 12-2016 + C1 + Massively Underpaid + Philosopher Trainer + + + 8,584 + 9-1995 + B1 + Fairly Paid + Assassin (Trainee) + + + 8,585 + 4-2003 + B2 + Underpaid + Assassin Extraordinaire + + + 8,586 + 12-2014 + C1 + Massively Underpaid + Vigilante for the Environment + + + 8,587 + 4-2014 + B1 + Fairly Paid + Software Developer for the Environment + + + 8,588 + 9-2002 + A1 + Massively Overpaid + Vigilante for Eternity + + + 8,589 + 6-2008 + C1 + Massively Underpaid + Historian Trainer + + + 8,590 + 6-2014 + B1 + Fairly Paid + Philosopher Trainer + + + 8,591 + 1-2021 + B2 + Underpaid + Assassin Extraordinaire + + + 8,592 + 4-1999 + C1 + Massively Underpaid + Historian of Parties + + + 8,593 + 8-1995 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 8,594 + 10-2015 + C2 + Slave Labour + Sports Mascot for the Environment + + + 8,595 + 10-2015 + B1 + Fairly Paid + Vigilante of Doom + + + 8,596 + 12-1996 + A1 + Massively Overpaid + Food Taster in Chief + + + 8,597 + 8-2002 + C1 + Massively Underpaid + Builder of Parties + + + 8,598 + 9-1992 + A1 + Massively Overpaid + Author of Doom + + + 8,599 + 11-1995 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 8,600 + 11-1992 + B1 + Fairly Paid + Skydiving Instructor of Doom + + + 8,601 + 5-2003 + B2 + Underpaid + Assassin of Cattle + + + 8,602 + 2-2001 + B1 + Fairly Paid + Vigilante of Cattle + + + 8,603 + 2-2022 + B1 + Fairly Paid + Historian Trainer + + + 8,604 + 4-2005 + B2 + Underpaid + Vigilante of Cattle + + + 8,605 + 7-2017 + B2 + Underpaid + Builder for Eternity + + + 8,606 + 3-2008 + A1 + Massively Overpaid + Food Taster Laureate + + + 8,607 + 5-2004 + A2 + Overpaid + Sports Mascot Laureate + + + 8,608 + 5-2022 + A1 + Massively Overpaid + Software Developer for Eternity + + + 8,609 + 3-2007 + B2 + Underpaid + Food Taster in Chief + + + 8,610 + 10-1997 + B1 + Fairly Paid + Software Developer for Schools + + + 8,611 + 8-2009 + B1 + Fairly Paid + Food Taster Trainer + + + 8,612 + 6-2013 + A2 + Overpaid + Food Taster Extraordinaire + + + 8,613 + 6-2008 + B2 + Underpaid + Historian of Doom + + + 8,614 + 8-2009 + A1 + Massively Overpaid + Vigilante of Cattle + + + 8,615 + 1-2013 + C2 + Slave Labour + Builder for the Environment + + + 8,616 + 8-1994 + A1 + Massively Overpaid + Builder of Parties + + + 8,617 + 3-1998 + A1 + Massively Overpaid + Historian Laureate + + + 8,618 + 11-2001 + B2 + Underpaid + Skydiving Instructor Trainer + + + 8,619 + 6-2011 + A2 + Overpaid + Author for the Environment + + + 8,620 + 6-1996 + A2 + Overpaid + Assassin of Cattle + + + 8,621 + 8-2005 + C2 + Slave Labour + Builder in Chief + + + 8,622 + 4-2010 + C1 + Massively Underpaid + Software Developer for Eternity + + + 8,623 + 10-2007 + A2 + Overpaid + Assassin for the Environment + + + 8,624 + 9-2005 + A1 + Massively Overpaid + Builder of Doom + + + 8,625 + 10-2012 + C2 + Slave Labour + Philosopher Trainer + + + 8,626 + 1-1993 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 8,627 + 8-2000 + A2 + Overpaid + Food Taster in Chief + + + 8,628 + 6-2012 + C2 + Slave Labour + Historian Laureate + + + 8,629 + 1-2011 + C2 + Slave Labour + Philosopher in Chief + + + 8,630 + 5-2020 + B1 + Fairly Paid + Software Developer for Eternity + + + 8,631 + 8-1992 + C1 + Massively Underpaid + Vigilante for Schools + + + 8,632 + 5-1999 + A2 + Overpaid + Assassin of Doom + + + 8,633 + 12-2003 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 8,634 + 6-2012 + C2 + Slave Labour + Philosopher of Cattle + + + 8,635 + 7-2023 + C2 + Slave Labour + Sports Mascot for Eternity + + + 8,636 + 6-2009 + C1 + Massively Underpaid + Author of Parties + + + 8,637 + 8-1990 + A1 + Massively Overpaid + Food Taster for Eternity + + + 8,638 + 7-2006 + B2 + Underpaid + Assassin Laureate + + + 8,639 + 3-1997 + C1 + Massively Underpaid + Author for Schools + + + 8,640 + 8-2017 + B1 + Fairly Paid + Vigilante for Schools + + + 8,641 + 9-2012 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 8,642 + 10-1995 + C1 + Massively Underpaid + Builder in Chief + + + 8,643 + 5-2007 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 8,644 + 3-2019 + C2 + Slave Labour + Builder in Chief + + + 8,645 + 6-1998 + B2 + Underpaid + Food Taster of Parties + + + 8,646 + 5-1999 + A1 + Massively Overpaid + Philosopher of Doom + + + 8,647 + 4-2000 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 8,648 + 2-2018 + A1 + Massively Overpaid + Author of Doom + + + 8,649 + 2-1996 + B2 + Underpaid + Author (Trainee) + + + 8,650 + 11-1994 + C1 + Massively Underpaid + Builder Trainer + + + 8,651 + 11-1995 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 8,652 + 2-2012 + C2 + Slave Labour + Builder for the Environment + + + 8,653 + 10-2001 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 8,654 + 4-2017 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 8,655 + 10-2022 + A1 + Massively Overpaid + Builder for Eternity + + + 8,656 + 2-2021 + A1 + Massively Overpaid + Vigilante of Doom + + + 8,657 + 4-2017 + C2 + Slave Labour + Food Taster Laureate + + + 8,658 + 1-1994 + B1 + Fairly Paid + Builder for Schools + + + 8,659 + 9-2010 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 8,660 + 3-2001 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 8,661 + 2-2020 + B2 + Underpaid + Historian (Trainee) + + + 8,662 + 8-2017 + A1 + Massively Overpaid + Software Developer for the Environment + + + 8,663 + 10-2009 + C1 + Massively Underpaid + Author (Trainee) + + + 8,664 + 9-1995 + C2 + Slave Labour + Sports Mascot of Doom + + + 8,665 + 8-1996 + B1 + Fairly Paid + Assassin for the Environment + + + 8,666 + 2-2017 + A2 + Overpaid + Historian Extraordinaire + + + 8,667 + 9-2019 + C1 + Massively Underpaid + Historian (Trainee) + + + 8,668 + 9-2013 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 8,669 + 8-2017 + B2 + Underpaid + Author Laureate + + + 8,670 + 12-2011 + B1 + Fairly Paid + Philosopher of Cattle + + + 8,671 + 10-2000 + C1 + Massively Underpaid + Philosopher Laureate + + + 8,672 + 10-2017 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 8,673 + 1-2005 + C1 + Massively Underpaid + Food Taster for the Environment + + + 8,674 + 2-2015 + C2 + Slave Labour + Philosopher Trainer + + + 8,675 + 8-1995 + A2 + Overpaid + Historian of Parties + + + 8,676 + 5-1991 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 8,677 + 2-2016 + A2 + Overpaid + Philosopher of Cattle + + + 8,678 + 4-1997 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 8,679 + 1-1995 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 8,680 + 8-2009 + B2 + Underpaid + Sports Mascot Trainer + + + 8,681 + 1-2012 + A1 + Massively Overpaid + Vigilante Trainer + + + 8,682 + 10-2014 + C2 + Slave Labour + Historian in Chief + + + 8,683 + 2-2008 + A1 + Massively Overpaid + Author (Trainee) + + + 8,684 + 8-2013 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 8,685 + 11-2016 + B1 + Fairly Paid + Philosopher for the Environment + + + 8,686 + 6-2016 + C2 + Slave Labour + Philosopher Laureate + + + 8,687 + 5-2005 + A1 + Massively Overpaid + Philosopher Trainer + + + 8,688 + 4-2009 + B1 + Fairly Paid + Historian Trainer + + + 8,689 + 11-2007 + B2 + Underpaid + Philosopher of Parties + + + 8,690 + 5-2000 + C1 + Massively Underpaid + Philosopher of Parties + + + 8,691 + 2-1996 + C1 + Massively Underpaid + Author of Doom + + + 8,692 + 4-2007 + A2 + Overpaid + Builder in Chief + + + 8,693 + 9-2008 + A2 + Overpaid + Software Developer (Trainee) + + + 8,694 + 7-1994 + C2 + Slave Labour + Assassin for the Environment + + + 8,695 + 7-2004 + A1 + Massively Overpaid + Historian Extraordinaire + + + 8,696 + 11-2010 + B1 + Fairly Paid + Sports Mascot Laureate + + + 8,697 + 1-2009 + A2 + Overpaid + Skydiving Instructor of Parties + + + 8,698 + 1-2016 + C1 + Massively Underpaid + Historian in Chief + + + 8,699 + 12-2019 + C2 + Slave Labour + Software Developer Trainer + + + 8,700 + 11-2011 + C2 + Slave Labour + Vigilante (Trainee) + + + 8,701 + 9-1994 + A2 + Overpaid + Vigilante Laureate + + + 8,702 + 5-1998 + A2 + Overpaid + Vigilante Trainer + + + 8,703 + 3-2011 + B1 + Fairly Paid + Food Taster Trainer + + + 8,704 + 1-2007 + C2 + Slave Labour + Historian Laureate + + + 8,705 + 7-2020 + C2 + Slave Labour + Software Developer for the Environment + + + 8,706 + 12-1993 + A2 + Overpaid + Software Developer for the Environment + + + 8,707 + 4-2008 + B2 + Underpaid + Software Developer for the Environment + + + 8,708 + 9-1994 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 8,709 + 8-1992 + A1 + Massively Overpaid + Assassin for Eternity + + + 8,710 + 8-2012 + A2 + Overpaid + Software Developer Laureate + + + 8,711 + 8-2004 + A2 + Overpaid + Software Developer Trainer + + + 8,712 + 2-2017 + C1 + Massively Underpaid + Author for Eternity + + + 8,713 + 10-1995 + B2 + Underpaid + Historian for the Environment + + + 8,714 + 1-1994 + A1 + Massively Overpaid + Builder Extraordinaire + + + 8,715 + 5-2007 + C2 + Slave Labour + Software Developer for Eternity + + + 8,716 + 4-2015 + B2 + Underpaid + Assassin for Schools + + + 8,717 + 9-2008 + B1 + Fairly Paid + Assassin for Eternity + + + 8,718 + 6-2007 + B1 + Fairly Paid + Vigilante for the Environment + + + 8,719 + 7-2007 + B2 + Underpaid + Philosopher for the Environment + + + 8,720 + 11-2015 + A2 + Overpaid + Author Trainer + + + 8,721 + 6-2010 + B2 + Underpaid + Author in Chief + + + 8,722 + 6-1998 + B1 + Fairly Paid + Builder for Eternity + + + 8,723 + 6-1991 + C2 + Slave Labour + Philosopher Trainer + + + 8,724 + 7-2008 + B1 + Fairly Paid + Sports Mascot of Parties + + + 8,725 + 4-2012 + A1 + Massively Overpaid + Philosopher (Trainee) + + + 8,726 + 5-2011 + B2 + Underpaid + Assassin Laureate + + + 8,727 + 9-2018 + C2 + Slave Labour + Builder of Parties + + + 8,728 + 5-2011 + C2 + Slave Labour + Author in Chief + + + 8,729 + 12-2021 + B1 + Fairly Paid + Software Developer Trainer + + + 8,730 + 2-1995 + A1 + Massively Overpaid + Assassin for Eternity + + + 8,731 + 3-2016 + A2 + Overpaid + Builder of Doom + + + 8,732 + 9-2003 + C1 + Massively Underpaid + Food Taster for Schools + + + 8,733 + 4-1992 + C1 + Massively Underpaid + Vigilante for Eternity + + + 8,734 + 6-1990 + B2 + Underpaid + Builder for Schools + + + 8,735 + 4-1998 + A1 + Massively Overpaid + Historian Laureate + + + 8,736 + 8-2004 + C1 + Massively Underpaid + Philosopher of Cattle + + + 8,737 + 4-2005 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 8,738 + 1-2006 + A1 + Massively Overpaid + Builder Laureate + + + 8,739 + 2-2003 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 8,740 + 5-2003 + A1 + Massively Overpaid + Skydiving Instructor Laureate + + + 8,741 + 6-1998 + C2 + Slave Labour + Builder of Cattle + + + 8,742 + 1-1991 + A1 + Massively Overpaid + Software Developer of Cattle + + + 8,743 + 6-2012 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 8,744 + 11-2012 + A1 + Massively Overpaid + Builder of Parties + + + 8,745 + 4-2011 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 8,746 + 12-1990 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 8,747 + 2-2012 + C2 + Slave Labour + Sports Mascot for Schools + + + 8,748 + 1-2023 + C1 + Massively Underpaid + Philosopher Laureate + + + 8,749 + 7-2022 + C1 + Massively Underpaid + Historian of Doom + + + 8,750 + 9-2001 + C1 + Massively Underpaid + Philosopher in Chief + + + 8,751 + 1-2012 + B1 + Fairly Paid + Philosopher of Cattle + + + 8,752 + 6-2008 + A1 + Massively Overpaid + Historian for Eternity + + + 8,753 + 2-2010 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 8,754 + 4-2004 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 8,755 + 11-2012 + C1 + Massively Underpaid + Sports Mascot Extraordinaire + + + 8,756 + 10-2005 + C1 + Massively Underpaid + Vigilante for the Environment + + + 8,757 + 1-2018 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 8,758 + 7-2020 + B2 + Underpaid + Builder for the Environment + + + 8,759 + 11-2012 + C1 + Massively Underpaid + Author for Eternity + + + 8,760 + 6-2016 + A2 + Overpaid + Software Developer in Chief + + + 8,761 + 5-2014 + B1 + Fairly Paid + Vigilante of Doom + + + 8,762 + 8-1999 + C2 + Slave Labour + Historian for Schools + + + 8,763 + 10-2021 + A2 + Overpaid + Philosopher Trainer + + + 8,764 + 11-1997 + B2 + Underpaid + Sports Mascot Trainer + + + 8,765 + 1-2002 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 8,766 + 3-2019 + B2 + Underpaid + Vigilante Extraordinaire + + + 8,767 + 7-2011 + C1 + Massively Underpaid + Builder Laureate + + + 8,768 + 9-2004 + A2 + Overpaid + Sports Mascot for the Environment + + + 8,769 + 4-2021 + C2 + Slave Labour + Author Extraordinaire + + + 8,770 + 5-2009 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 8,771 + 1-2019 + B2 + Underpaid + Skydiving Instructor of Parties + + + 8,772 + 11-2019 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 8,773 + 6-2002 + A2 + Overpaid + Builder in Chief + + + 8,774 + 4-2014 + B2 + Underpaid + Software Developer of Doom + + + 8,775 + 9-2000 + A1 + Massively Overpaid + Philosopher Laureate + + + 8,776 + 12-1999 + C2 + Slave Labour + Assassin Laureate + + + 8,777 + 12-2008 + B2 + Underpaid + Assassin for Schools + + + 8,778 + 11-2022 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 8,779 + 5-1996 + B1 + Fairly Paid + Philosopher Trainer + + + 8,780 + 5-2018 + C2 + Slave Labour + Software Developer of Cattle + + + 8,781 + 4-1997 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 8,782 + 10-1995 + A1 + Massively Overpaid + Historian Trainer + + + 8,783 + 2-2004 + A1 + Massively Overpaid + Author in Chief + + + 8,784 + 10-2014 + C2 + Slave Labour + Vigilante of Parties + + + 8,785 + 10-2009 + C1 + Massively Underpaid + Vigilante in Chief + + + 8,786 + 7-2016 + A1 + Massively Overpaid + Vigilante for Schools + + + 8,787 + 10-2015 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 8,788 + 11-1992 + C2 + Slave Labour + Historian (Trainee) + + + 8,789 + 6-2010 + B1 + Fairly Paid + Software Developer of Parties + + + 8,790 + 7-2010 + C1 + Massively Underpaid + Vigilante of Cattle + + + 8,791 + 1-2018 + C2 + Slave Labour + Assassin Laureate + + + 8,792 + 8-1994 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 8,793 + 7-1997 + C2 + Slave Labour + Philosopher (Trainee) + + + 8,794 + 4-2005 + A1 + Massively Overpaid + Food Taster for Schools + + + 8,795 + 11-2002 + C2 + Slave Labour + Historian of Parties + + + 8,796 + 2-1995 + C1 + Massively Underpaid + Software Developer Laureate + + + 8,797 + 4-2013 + B1 + Fairly Paid + Vigilante for the Environment + + + 8,798 + 1-1994 + C1 + Massively Underpaid + Builder Extraordinaire + + + 8,799 + 7-2020 + C2 + Slave Labour + Assassin for Eternity + + + 8,800 + 12-2001 + C1 + Massively Underpaid + Software Developer of Cattle + + + 8,801 + 2-2003 + C2 + Slave Labour + Sports Mascot Extraordinaire + + + 8,802 + 7-2013 + B2 + Underpaid + Vigilante (Trainee) + + + 8,803 + 11-2009 + A2 + Overpaid + Vigilante in Chief + + + 8,804 + 1-1993 + B1 + Fairly Paid + Software Developer for Eternity + + + 8,805 + 1-2018 + C2 + Slave Labour + Vigilante for the Environment + + + 8,806 + 8-1993 + A1 + Massively Overpaid + Vigilante in Chief + + + 8,807 + 3-2022 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 8,808 + 4-2010 + A2 + Overpaid + Software Developer Extraordinaire + + + 8,809 + 8-2001 + C2 + Slave Labour + Food Taster in Chief + + + 8,810 + 5-1990 + A1 + Massively Overpaid + Historian of Parties + + + 8,811 + 12-1990 + C2 + Slave Labour + Author in Chief + + + 8,812 + 3-1991 + A2 + Overpaid + Assassin Extraordinaire + + + 8,813 + 6-1999 + B1 + Fairly Paid + Assassin of Cattle + + + 8,814 + 1-2013 + A1 + Massively Overpaid + Author of Doom + + + 8,815 + 10-2015 + A2 + Overpaid + Software Developer Extraordinaire + + + 8,816 + 6-2011 + A2 + Overpaid + Skydiving Instructor of Cattle + + + 8,817 + 7-2007 + A1 + Massively Overpaid + Vigilante Laureate + + + 8,818 + 10-2013 + B2 + Underpaid + Vigilante for the Environment + + + 8,819 + 2-2006 + B2 + Underpaid + Historian of Doom + + + 8,820 + 8-1992 + C2 + Slave Labour + Philosopher in Chief + + + 8,821 + 12-2019 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 8,822 + 3-1990 + B2 + Underpaid + Assassin of Cattle + + + 8,823 + 11-2023 + A1 + Massively Overpaid + Builder in Chief + + + 8,824 + 7-2018 + B2 + Underpaid + Builder (Trainee) + + + 8,825 + 10-2003 + B2 + Underpaid + Historian Trainer + + + 8,826 + 2-2002 + C2 + Slave Labour + Food Taster in Chief + + + 8,827 + 8-2000 + A1 + Massively Overpaid + Author of Parties + + + 8,828 + 4-2002 + A1 + Massively Overpaid + Philosopher for Eternity + + + 8,829 + 1-1990 + C1 + Massively Underpaid + Software Developer Extraordinaire + + + 8,830 + 11-1994 + B2 + Underpaid + Philosopher Trainer + + + 8,831 + 7-2011 + C2 + Slave Labour + Software Developer in Chief + + + 8,832 + 2-2009 + B2 + Underpaid + Builder Laureate + + + 8,833 + 10-2021 + B1 + Fairly Paid + Food Taster of Parties + + + 8,834 + 5-2022 + A2 + Overpaid + Philosopher for Schools + + + 8,835 + 9-1994 + C1 + Massively Underpaid + Food Taster of Parties + + + 8,836 + 5-2021 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 8,837 + 3-1996 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 8,838 + 9-2011 + C2 + Slave Labour + Sports Mascot of Parties + + + 8,839 + 11-2015 + C1 + Massively Underpaid + Sports Mascot of Parties + + + 8,840 + 12-1999 + C2 + Slave Labour + Food Taster of Cattle + + + 8,841 + 12-2005 + A1 + Massively Overpaid + Author of Cattle + + + 8,842 + 7-2018 + B1 + Fairly Paid + Assassin for Eternity + + + 8,843 + 9-1995 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 8,844 + 11-2023 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 8,845 + 7-1994 + B1 + Fairly Paid + Assassin for Schools + + + 8,846 + 11-2019 + B2 + Underpaid + Historian for the Environment + + + 8,847 + 5-2019 + A2 + Overpaid + Software Developer Trainer + + + 8,848 + 3-2014 + A2 + Overpaid + Builder of Cattle + + + 8,849 + 8-2012 + B1 + Fairly Paid + Software Developer Laureate + + + 8,850 + 6-2005 + C2 + Slave Labour + Author of Cattle + + + 8,851 + 9-2018 + B2 + Underpaid + Author Trainer + + + 8,852 + 7-1996 + B2 + Underpaid + Food Taster Laureate + + + 8,853 + 11-2013 + C1 + Massively Underpaid + Assassin (Trainee) + + + 8,854 + 7-1997 + B2 + Underpaid + Food Taster (Trainee) + + + 8,855 + 10-2010 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 8,856 + 9-2014 + C2 + Slave Labour + Assassin (Trainee) + + + 8,857 + 7-2014 + B1 + Fairly Paid + Builder of Parties + + + 8,858 + 5-1998 + B1 + Fairly Paid + Author for Schools + + + 8,859 + 10-1993 + B2 + Underpaid + Sports Mascot of Parties + + + 8,860 + 2-2000 + A2 + Overpaid + Food Taster of Parties + + + 8,861 + 8-1994 + B2 + Underpaid + Philosopher (Trainee) + + + 8,862 + 5-2002 + A2 + Overpaid + Software Developer for the Environment + + + 8,863 + 10-1992 + A2 + Overpaid + Assassin of Cattle + + + 8,864 + 11-1991 + B2 + Underpaid + Historian (Trainee) + + + 8,865 + 8-2000 + B2 + Underpaid + Sports Mascot Extraordinaire + + + 8,866 + 9-2020 + B2 + Underpaid + Author in Chief + + + 8,867 + 10-1992 + C2 + Slave Labour + Philosopher Extraordinaire + + + 8,868 + 1-2012 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 8,869 + 4-2016 + B2 + Underpaid + Philosopher Laureate + + + 8,870 + 3-1995 + C2 + Slave Labour + Software Developer Laureate + + + 8,871 + 5-1992 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 8,872 + 7-2020 + B1 + Fairly Paid + Historian Extraordinaire + + + 8,873 + 11-2022 + C2 + Slave Labour + Author of Cattle + + + 8,874 + 1-2005 + C2 + Slave Labour + Sports Mascot Laureate + + + 8,875 + 8-2020 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 8,876 + 9-1999 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 8,877 + 1-2023 + C2 + Slave Labour + Sports Mascot of Parties + + + 8,878 + 7-2017 + B1 + Fairly Paid + Assassin in Chief + + + 8,879 + 11-2011 + C2 + Slave Labour + Assassin Extraordinaire + + + 8,880 + 12-2008 + B2 + Underpaid + Historian of Cattle + + + 8,881 + 5-2015 + C2 + Slave Labour + Author of Parties + + + 8,882 + 3-2016 + B2 + Underpaid + Philosopher for the Environment + + + 8,883 + 8-2018 + B2 + Underpaid + Historian of Parties + + + 8,884 + 10-1994 + C2 + Slave Labour + Vigilante for the Environment + + + 8,885 + 11-2008 + B2 + Underpaid + Author of Cattle + + + 8,886 + 9-1992 + C1 + Massively Underpaid + Builder (Trainee) + + + 8,887 + 8-2013 + C2 + Slave Labour + Historian of Cattle + + + 8,888 + 11-2000 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 8,889 + 9-1996 + C2 + Slave Labour + Builder of Parties + + + 8,890 + 10-2010 + B2 + Underpaid + Vigilante of Parties + + + 8,891 + 12-2014 + C1 + Massively Underpaid + Assassin of Parties + + + 8,892 + 9-1998 + C2 + Slave Labour + Software Developer Laureate + + + 8,893 + 9-2007 + B1 + Fairly Paid + Food Taster of Cattle + + + 8,894 + 11-2005 + A2 + Overpaid + Software Developer of Doom + + + 8,895 + 9-2021 + A2 + Overpaid + Vigilante in Chief + + + 8,896 + 6-1999 + B2 + Underpaid + Vigilante of Doom + + + 8,897 + 9-1990 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 8,898 + 8-2017 + A2 + Overpaid + Skydiving Instructor of Doom + + + 8,899 + 4-2008 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 8,900 + 7-2007 + B1 + Fairly Paid + Author in Chief + + + 8,901 + 8-2010 + B2 + Underpaid + Sports Mascot (Trainee) + + + 8,902 + 3-2020 + B2 + Underpaid + Vigilante of Cattle + + + 8,903 + 1-1997 + C1 + Massively Underpaid + Historian for Eternity + + + 8,904 + 2-2015 + B2 + Underpaid + Builder Extraordinaire + + + 8,905 + 1-2017 + B1 + Fairly Paid + Software Developer for the Environment + + + 8,906 + 3-2002 + A2 + Overpaid + Author of Parties + + + 8,907 + 10-2014 + B2 + Underpaid + Vigilante of Doom + + + 8,908 + 1-2012 + B2 + Underpaid + Vigilante of Doom + + + 8,909 + 2-2020 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 8,910 + 1-2019 + A2 + Overpaid + Author in Chief + + + 8,911 + 8-2002 + B1 + Fairly Paid + Software Developer in Chief + + + 8,912 + 10-2023 + A1 + Massively Overpaid + Philosopher for Schools + + + 8,913 + 3-2005 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 8,914 + 5-2004 + B2 + Underpaid + Philosopher Trainer + + + 8,915 + 3-2000 + C1 + Massively Underpaid + Historian for Schools + + + 8,916 + 7-2023 + C2 + Slave Labour + Vigilante in Chief + + + 8,917 + 4-1993 + A2 + Overpaid + Skydiving Instructor in Chief + + + 8,918 + 7-2001 + C2 + Slave Labour + Historian of Cattle + + + 8,919 + 11-2019 + A2 + Overpaid + Sports Mascot of Parties + + + 8,920 + 3-2009 + A2 + Overpaid + Software Developer for Schools + + + 8,921 + 12-2004 + C1 + Massively Underpaid + Food Taster of Parties + + + 8,922 + 10-1991 + C1 + Massively Underpaid + Builder Trainer + + + 8,923 + 7-2019 + A2 + Overpaid + Philosopher of Parties + + + 8,924 + 7-2003 + C2 + Slave Labour + Builder Trainer + + + 8,925 + 4-1995 + C1 + Massively Underpaid + Historian Laureate + + + 8,926 + 2-2017 + A2 + Overpaid + Software Developer for the Environment + + + 8,927 + 5-2008 + C2 + Slave Labour + Builder of Doom + + + 8,928 + 12-2002 + A2 + Overpaid + Philosopher Trainer + + + 8,929 + 9-2003 + A1 + Massively Overpaid + Food Taster Laureate + + + 8,930 + 6-1990 + A2 + Overpaid + Food Taster for Eternity + + + 8,931 + 8-2009 + C1 + Massively Underpaid + Historian for Schools + + + 8,932 + 1-1999 + C2 + Slave Labour + Skydiving Instructor for the Environment + + + 8,933 + 11-2022 + A2 + Overpaid + Historian for the Environment + + + 8,934 + 4-2009 + A2 + Overpaid + Builder (Trainee) + + + 8,935 + 11-2011 + B2 + Underpaid + Author for Schools + + + 8,936 + 9-1999 + B2 + Underpaid + Vigilante for the Environment + + + 8,937 + 3-2022 + C1 + Massively Underpaid + Assassin for the Environment + + + 8,938 + 1-2004 + C2 + Slave Labour + Author (Trainee) + + + 8,939 + 3-1996 + A2 + Overpaid + Assassin of Cattle + + + 8,940 + 3-2008 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 8,941 + 11-1998 + C2 + Slave Labour + Historian for Eternity + + + 8,942 + 1-1995 + C2 + Slave Labour + Software Developer Trainer + + + 8,943 + 1-2010 + B1 + Fairly Paid + Food Taster (Trainee) + + + 8,944 + 5-2010 + A2 + Overpaid + Historian for the Environment + + + 8,945 + 5-2001 + C2 + Slave Labour + Author for Schools + + + 8,946 + 4-2010 + A2 + Overpaid + Author for Schools + + + 8,947 + 5-2020 + A1 + Massively Overpaid + Software Developer of Cattle + + + 8,948 + 12-2008 + B1 + Fairly Paid + Vigilante in Chief + + + 8,949 + 1-1997 + A1 + Massively Overpaid + Historian Trainer + + + 8,950 + 8-2015 + C2 + Slave Labour + Sports Mascot for Schools + + + 8,951 + 2-2014 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 8,952 + 5-1991 + B2 + Underpaid + Builder for the Environment + + + 8,953 + 9-1993 + C1 + Massively Underpaid + Food Taster of Parties + + + 8,954 + 6-2001 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 8,955 + 3-2019 + B2 + Underpaid + Assassin Trainer + + + 8,956 + 6-2021 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 8,957 + 4-2022 + A1 + Massively Overpaid + Builder Trainer + + + 8,958 + 5-2010 + C1 + Massively Underpaid + Software Developer Trainer + + + 8,959 + 3-2000 + B1 + Fairly Paid + Vigilante Laureate + + + 8,960 + 8-1994 + A1 + Massively Overpaid + Assassin Trainer + + + 8,961 + 3-2019 + C1 + Massively Underpaid + Author Extraordinaire + + + 8,962 + 10-1991 + C1 + Massively Underpaid + Author Laureate + + + 8,963 + 6-2006 + B1 + Fairly Paid + Food Taster (Trainee) + + + 8,964 + 5-2017 + B1 + Fairly Paid + Builder Laureate + + + 8,965 + 8-2009 + A2 + Overpaid + Food Taster of Parties + + + 8,966 + 2-2006 + B2 + Underpaid + Historian Laureate + + + 8,967 + 8-1994 + C2 + Slave Labour + Food Taster Extraordinaire + + + 8,968 + 3-2008 + B1 + Fairly Paid + Assassin Trainer + + + 8,969 + 8-1991 + C2 + Slave Labour + Skydiving Instructor of Doom + + + 8,970 + 10-2019 + C2 + Slave Labour + Builder for the Environment + + + 8,971 + 11-2007 + C1 + Massively Underpaid + Author for Eternity + + + 8,972 + 7-2018 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 8,973 + 12-2003 + B2 + Underpaid + Vigilante Extraordinaire + + + 8,974 + 4-2011 + A1 + Massively Overpaid + Author Trainer + + + 8,975 + 8-2000 + B2 + Underpaid + Builder Trainer + + + 8,976 + 12-1999 + C2 + Slave Labour + Software Developer for Eternity + + + 8,977 + 11-2009 + A2 + Overpaid + Vigilante (Trainee) + + + 8,978 + 3-2008 + A1 + Massively Overpaid + Author of Doom + + + 8,979 + 3-2019 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 8,980 + 11-2014 + C1 + Massively Underpaid + Skydiving Instructor of Doom + + + 8,981 + 12-1994 + C2 + Slave Labour + Vigilante in Chief + + + 8,982 + 5-2009 + C2 + Slave Labour + Food Taster of Doom + + + 8,983 + 5-1994 + B1 + Fairly Paid + Software Developer of Doom + + + 8,984 + 8-2014 + C1 + Massively Underpaid + Builder of Parties + + + 8,985 + 11-2019 + C2 + Slave Labour + Author of Parties + + + 8,986 + 5-2020 + A2 + Overpaid + Software Developer Trainer + + + 8,987 + 11-1990 + A2 + Overpaid + Builder in Chief + + + 8,988 + 8-2003 + A2 + Overpaid + Builder for the Environment + + + 8,989 + 11-1990 + C2 + Slave Labour + Author in Chief + + + 8,990 + 8-2015 + C2 + Slave Labour + Assassin in Chief + + + 8,991 + 10-2021 + B2 + Underpaid + Food Taster for Eternity + + + 8,992 + 7-2001 + B2 + Underpaid + Historian (Trainee) + + + 8,993 + 7-2008 + A1 + Massively Overpaid + Author of Doom + + + 8,994 + 1-2000 + A2 + Overpaid + Builder of Doom + + + 8,995 + 9-2013 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 8,996 + 6-1999 + A2 + Overpaid + Author (Trainee) + + + 8,997 + 5-2003 + A1 + Massively Overpaid + Assassin of Doom + + + 8,998 + 6-2014 + C1 + Massively Underpaid + Historian of Doom + + + 8,999 + 3-1990 + A1 + Massively Overpaid + Assassin Trainer + + + 9,000 + 4-2018 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 9,001 + 12-2000 + A1 + Massively Overpaid + Assassin Trainer + + + 9,002 + 11-1997 + B2 + Underpaid + Assassin of Doom + + + 9,003 + 5-1994 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 9,004 + 9-2009 + B1 + Fairly Paid + Historian Laureate + + + 9,005 + 1-2007 + C1 + Massively Underpaid + Builder for the Environment + + + 9,006 + 5-2015 + B2 + Underpaid + Food Taster in Chief + + + 9,007 + 3-2006 + B2 + Underpaid + Historian for Schools + + + 9,008 + 7-2010 + C2 + Slave Labour + Skydiving Instructor for Schools + + + 9,009 + 2-1991 + B2 + Underpaid + Assassin of Cattle + + + 9,010 + 11-2018 + A1 + Massively Overpaid + Skydiving Instructor for Eternity + + + 9,011 + 4-2000 + A2 + Overpaid + Vigilante for Schools + + + 9,012 + 2-2020 + C2 + Slave Labour + Philosopher Laureate + + + 9,013 + 4-2002 + B2 + Underpaid + Vigilante Extraordinaire + + + 9,014 + 4-2003 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 9,015 + 4-2012 + B1 + Fairly Paid + Vigilante of Cattle + + + 9,016 + 5-2014 + C2 + Slave Labour + Builder Extraordinaire + + + 9,017 + 7-1990 + B1 + Fairly Paid + Builder for Schools + + + 9,018 + 2-1992 + B2 + Underpaid + Software Developer for the Environment + + + 9,019 + 8-2002 + C2 + Slave Labour + Historian of Doom + + + 9,020 + 7-1996 + C1 + Massively Underpaid + Skydiving Instructor for Schools + + + 9,021 + 3-2021 + B1 + Fairly Paid + Author (Trainee) + + + 9,022 + 12-1997 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 9,023 + 9-2004 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 9,024 + 8-2010 + C2 + Slave Labour + Historian for the Environment + + + 9,025 + 2-2008 + B1 + Fairly Paid + Skydiving Instructor Laureate + + + 9,026 + 7-2016 + B1 + Fairly Paid + Vigilante in Chief + + + 9,027 + 1-2020 + A2 + Overpaid + Historian for Eternity + + + 9,028 + 3-2020 + C1 + Massively Underpaid + Builder for Eternity + + + 9,029 + 4-2010 + B1 + Fairly Paid + Software Developer for the Environment + + + 9,030 + 2-2016 + A1 + Massively Overpaid + Author of Doom + + + 9,031 + 11-1997 + A1 + Massively Overpaid + Builder Laureate + + + 9,032 + 5-1991 + B1 + Fairly Paid + Food Taster of Doom + + + 9,033 + 12-2023 + B1 + Fairly Paid + Assassin in Chief + + + 9,034 + 8-2010 + B2 + Underpaid + Historian for the Environment + + + 9,035 + 2-2017 + A2 + Overpaid + Historian for Schools + + + 9,036 + 5-2003 + B1 + Fairly Paid + Historian of Parties + + + 9,037 + 2-2005 + A2 + Overpaid + Software Developer of Cattle + + + 9,038 + 12-2019 + C1 + Massively Underpaid + Vigilante of Parties + + + 9,039 + 9-2013 + C2 + Slave Labour + Author for Eternity + + + 9,040 + 3-1990 + B2 + Underpaid + Author for Schools + + + 9,041 + 2-1995 + B2 + Underpaid + Historian for the Environment + + + 9,042 + 12-2023 + B2 + Underpaid + Assassin for the Environment + + + 9,043 + 5-1990 + A2 + Overpaid + Food Taster in Chief + + + 9,044 + 4-2015 + B1 + Fairly Paid + Vigilante of Parties + + + 9,045 + 8-2012 + A2 + Overpaid + Food Taster Extraordinaire + + + 9,046 + 11-2011 + B1 + Fairly Paid + Historian Trainer + + + 9,047 + 4-1996 + A2 + Overpaid + Sports Mascot for the Environment + + + 9,048 + 3-2007 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 9,049 + 12-1991 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 9,050 + 12-1997 + C1 + Massively Underpaid + Food Taster of Parties + + + 9,051 + 2-2016 + A1 + Massively Overpaid + Philosopher for the Environment + + + 9,052 + 2-2017 + A1 + Massively Overpaid + Food Taster of Parties + + + 9,053 + 9-1997 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 9,054 + 12-1999 + C1 + Massively Underpaid + Skydiving Instructor for Eternity + + + 9,055 + 11-2015 + B2 + Underpaid + Software Developer (Trainee) + + + 9,056 + 4-2018 + B1 + Fairly Paid + Philosopher of Doom + + + 9,057 + 7-1997 + C1 + Massively Underpaid + Historian Extraordinaire + + + 9,058 + 12-2001 + A1 + Massively Overpaid + Builder of Cattle + + + 9,059 + 8-1996 + C2 + Slave Labour + Sports Mascot for Schools + + + 9,060 + 10-2017 + A2 + Overpaid + Sports Mascot of Parties + + + 9,061 + 7-1999 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 9,062 + 1-2000 + A2 + Overpaid + Historian of Cattle + + + 9,063 + 10-2014 + A2 + Overpaid + Philosopher for the Environment + + + 9,064 + 9-1993 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 9,065 + 7-1990 + A2 + Overpaid + Builder for Eternity + + + 9,066 + 12-1995 + B2 + Underpaid + Author for the Environment + + + 9,067 + 1-2015 + A1 + Massively Overpaid + Author of Cattle + + + 9,068 + 3-1993 + B2 + Underpaid + Food Taster of Doom + + + 9,069 + 7-1991 + B1 + Fairly Paid + Sports Mascot (Trainee) + + + 9,070 + 2-2020 + B1 + Fairly Paid + Food Taster (Trainee) + + + 9,071 + 10-2023 + C1 + Massively Underpaid + Philosopher Laureate + + + 9,072 + 8-2021 + A2 + Overpaid + Author for Schools + + + 9,073 + 9-2020 + B2 + Underpaid + Historian for the Environment + + + 9,074 + 6-2013 + A2 + Overpaid + Food Taster in Chief + + + 9,075 + 10-2013 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 9,076 + 1-2017 + A2 + Overpaid + Builder Extraordinaire + + + 9,077 + 1-2012 + B1 + Fairly Paid + Food Taster for the Environment + + + 9,078 + 5-1994 + B1 + Fairly Paid + Builder for Schools + + + 9,079 + 11-1999 + C1 + Massively Underpaid + Author Laureate + + + 9,080 + 7-1995 + B2 + Underpaid + Vigilante for Eternity + + + 9,081 + 10-2017 + B1 + Fairly Paid + Software Developer for Eternity + + + 9,082 + 4-2000 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 9,083 + 4-2021 + C2 + Slave Labour + Historian of Doom + + + 9,084 + 4-1993 + A2 + Overpaid + Vigilante of Cattle + + + 9,085 + 6-1993 + C1 + Massively Underpaid + Historian Laureate + + + 9,086 + 8-2002 + C1 + Massively Underpaid + Vigilante for the Environment + + + 9,087 + 9-2009 + A2 + Overpaid + Builder Laureate + + + 9,088 + 6-2023 + C1 + Massively Underpaid + Food Taster Laureate + + + 9,089 + 6-2005 + B1 + Fairly Paid + Food Taster of Parties + + + 9,090 + 4-1997 + B2 + Underpaid + Author in Chief + + + 9,091 + 12-1994 + C2 + Slave Labour + Author for Schools + + + 9,092 + 6-1999 + C2 + Slave Labour + Food Taster Extraordinaire + + + 9,093 + 4-2003 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 9,094 + 4-2020 + C1 + Massively Underpaid + Vigilante Laureate + + + 9,095 + 11-2015 + B2 + Underpaid + Author of Cattle + + + 9,096 + 6-2019 + C1 + Massively Underpaid + Builder for Schools + + + 9,097 + 2-2012 + C1 + Massively Underpaid + Builder in Chief + + + 9,098 + 1-2009 + B2 + Underpaid + Sports Mascot Trainer + + + 9,099 + 11-2016 + B1 + Fairly Paid + Builder of Doom + + + 9,100 + 3-2002 + A1 + Massively Overpaid + Food Taster for Eternity + + + 9,101 + 7-1990 + C1 + Massively Underpaid + Historian of Doom + + + 9,102 + 5-2008 + A1 + Massively Overpaid + Assassin in Chief + + + 9,103 + 3-1995 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 9,104 + 9-2005 + B1 + Fairly Paid + Software Developer of Parties + + + 9,105 + 6-2015 + A1 + Massively Overpaid + Philosopher Trainer + + + 9,106 + 12-1997 + A1 + Massively Overpaid + Historian Extraordinaire + + + 9,107 + 3-2020 + B2 + Underpaid + Vigilante for the Environment + + + 9,108 + 12-2013 + A1 + Massively Overpaid + Software Developer of Parties + + + 9,109 + 12-2020 + C1 + Massively Underpaid + Food Taster for Schools + + + 9,110 + 11-1998 + A2 + Overpaid + Assassin for the Environment + + + 9,111 + 3-2003 + A1 + Massively Overpaid + Author for Eternity + + + 9,112 + 4-2003 + A1 + Massively Overpaid + Assassin for the Environment + + + 9,113 + 6-2015 + B2 + Underpaid + Software Developer Extraordinaire + + + 9,114 + 10-2018 + C1 + Massively Underpaid + Builder in Chief + + + 9,115 + 3-2016 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 9,116 + 3-1991 + B2 + Underpaid + Author for Eternity + + + 9,117 + 12-2003 + C2 + Slave Labour + Software Developer Trainer + + + 9,118 + 8-2016 + A2 + Overpaid + Sports Mascot for the Environment + + + 9,119 + 12-1996 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 9,120 + 11-1994 + A2 + Overpaid + Historian of Cattle + + + 9,121 + 8-2016 + A1 + Massively Overpaid + Food Taster for Eternity + + + 9,122 + 6-2017 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 9,123 + 6-1991 + B2 + Underpaid + Assassin Laureate + + + 9,124 + 11-2013 + C2 + Slave Labour + Sports Mascot of Cattle + + + 9,125 + 8-2022 + C2 + Slave Labour + Builder for the Environment + + + 9,126 + 9-2005 + A2 + Overpaid + Builder Laureate + + + 9,127 + 7-2011 + B1 + Fairly Paid + Philosopher Trainer + + + 9,128 + 5-2023 + A1 + Massively Overpaid + Software Developer of Doom + + + 9,129 + 8-2002 + C1 + Massively Underpaid + Historian of Parties + + + 9,130 + 1-1993 + A2 + Overpaid + Philosopher in Chief + + + 9,131 + 8-1997 + C1 + Massively Underpaid + Assassin in Chief + + + 9,132 + 9-1991 + B2 + Underpaid + Software Developer for Schools + + + 9,133 + 12-2005 + A2 + Overpaid + Assassin of Cattle + + + 9,134 + 12-2019 + C1 + Massively Underpaid + Historian in Chief + + + 9,135 + 2-2013 + A2 + Overpaid + Sports Mascot for Eternity + + + 9,136 + 6-2006 + B2 + Underpaid + Sports Mascot Laureate + + + 9,137 + 2-2017 + A2 + Overpaid + Software Developer Extraordinaire + + + 9,138 + 12-1997 + B1 + Fairly Paid + Assassin of Cattle + + + 9,139 + 11-1995 + B2 + Underpaid + Software Developer in Chief + + + 9,140 + 3-1992 + B2 + Underpaid + Author Trainer + + + 9,141 + 11-2007 + B2 + Underpaid + Philosopher for Eternity + + + 9,142 + 12-2007 + B1 + Fairly Paid + Historian Extraordinaire + + + 9,143 + 7-2004 + A2 + Overpaid + Assassin Laureate + + + 9,144 + 7-1999 + A1 + Massively Overpaid + Vigilante (Trainee) + + + 9,145 + 12-2023 + C1 + Massively Underpaid + Historian Laureate + + + 9,146 + 1-2023 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 9,147 + 11-2008 + C2 + Slave Labour + Assassin for Schools + + + 9,148 + 6-2018 + A2 + Overpaid + Software Developer Laureate + + + 9,149 + 4-1999 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 9,150 + 1-1994 + A1 + Massively Overpaid + Assassin for Eternity + + + 9,151 + 9-1998 + A2 + Overpaid + Food Taster in Chief + + + 9,152 + 10-1991 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 9,153 + 8-2003 + A1 + Massively Overpaid + Philosopher of Cattle + + + 9,154 + 7-2001 + C2 + Slave Labour + Author for Eternity + + + 9,155 + 1-2004 + C2 + Slave Labour + Sports Mascot of Parties + + + 9,156 + 4-2007 + B2 + Underpaid + Builder of Cattle + + + 9,157 + 7-2006 + B1 + Fairly Paid + Builder (Trainee) + + + 9,158 + 11-2013 + A2 + Overpaid + Historian Trainer + + + 9,159 + 8-2008 + B2 + Underpaid + Sports Mascot for Schools + + + 9,160 + 12-1997 + A2 + Overpaid + Food Taster of Cattle + + + 9,161 + 2-1993 + C2 + Slave Labour + Philosopher of Cattle + + + 9,162 + 5-2023 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 9,163 + 4-1992 + B1 + Fairly Paid + Author (Trainee) + + + 9,164 + 6-2003 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 9,165 + 12-2023 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 9,166 + 12-2018 + C2 + Slave Labour + Assassin Laureate + + + 9,167 + 1-2001 + B1 + Fairly Paid + Vigilante for Schools + + + 9,168 + 4-2008 + C2 + Slave Labour + Author for Eternity + + + 9,169 + 11-2017 + A1 + Massively Overpaid + Software Developer for the Environment + + + 9,170 + 12-1993 + C2 + Slave Labour + Food Taster Extraordinaire + + + 9,171 + 2-2009 + B1 + Fairly Paid + Philosopher for Schools + + + 9,172 + 9-2011 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 9,173 + 11-2006 + A1 + Massively Overpaid + Food Taster Laureate + + + 9,174 + 8-2007 + B1 + Fairly Paid + Sports Mascot Laureate + + + 9,175 + 3-2020 + A2 + Overpaid + Software Developer Laureate + + + 9,176 + 11-2009 + A2 + Overpaid + Vigilante for Eternity + + + 9,177 + 2-2015 + C2 + Slave Labour + Philosopher Trainer + + + 9,178 + 11-2002 + B2 + Underpaid + Historian Laureate + + + 9,179 + 3-2020 + A2 + Overpaid + Philosopher for Schools + + + 9,180 + 8-2008 + C1 + Massively Underpaid + Philosopher for Schools + + + 9,181 + 2-2020 + B1 + Fairly Paid + Software Developer of Doom + + + 9,182 + 6-2015 + A1 + Massively Overpaid + Author Laureate + + + 9,183 + 2-2021 + A1 + Massively Overpaid + Author of Parties + + + 9,184 + 12-2017 + A2 + Overpaid + Builder for Schools + + + 9,185 + 11-2008 + A2 + Overpaid + Historian Laureate + + + 9,186 + 7-2017 + B2 + Underpaid + Builder (Trainee) + + + 9,187 + 2-2002 + C2 + Slave Labour + Author for Schools + + + 9,188 + 11-2000 + A2 + Overpaid + Vigilante for Eternity + + + 9,189 + 1-2016 + A1 + Massively Overpaid + Author Extraordinaire + + + 9,190 + 6-1996 + C1 + Massively Underpaid + Software Developer Trainer + + + 9,191 + 12-2004 + C2 + Slave Labour + Philosopher for Schools + + + 9,192 + 11-2013 + A1 + Massively Overpaid + Assassin Trainer + + + 9,193 + 11-2017 + C2 + Slave Labour + Author Extraordinaire + + + 9,194 + 9-2017 + B1 + Fairly Paid + Food Taster for the Environment + + + 9,195 + 2-2018 + C1 + Massively Underpaid + Historian in Chief + + + 9,196 + 8-1993 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 9,197 + 9-2007 + A1 + Massively Overpaid + Skydiving Instructor of Cattle + + + 9,198 + 11-2016 + A1 + Massively Overpaid + Vigilante of Parties + + + 9,199 + 2-2021 + C2 + Slave Labour + Author in Chief + + + 9,200 + 3-2010 + C1 + Massively Underpaid + Food Taster Trainer + + + 9,201 + 5-2007 + B2 + Underpaid + Food Taster of Doom + + + 9,202 + 5-2008 + C1 + Massively Underpaid + Author Trainer + + + 9,203 + 6-1992 + C2 + Slave Labour + Author Laureate + + + 9,204 + 10-2021 + A2 + Overpaid + Skydiving Instructor in Chief + + + 9,205 + 4-2023 + A1 + Massively Overpaid + Software Developer for Schools + + + 9,206 + 3-1999 + A2 + Overpaid + Food Taster in Chief + + + 9,207 + 3-1991 + C2 + Slave Labour + Author (Trainee) + + + 9,208 + 9-2001 + B1 + Fairly Paid + Philosopher of Parties + + + 9,209 + 11-1996 + B1 + Fairly Paid + Author Extraordinaire + + + 9,210 + 6-2004 + B1 + Fairly Paid + Vigilante of Cattle + + + 9,211 + 8-2007 + A2 + Overpaid + Assassin for Schools + + + 9,212 + 11-2018 + C2 + Slave Labour + Food Taster Trainer + + + 9,213 + 5-2010 + A2 + Overpaid + Vigilante for Eternity + + + 9,214 + 7-2005 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 9,215 + 9-2014 + B2 + Underpaid + Historian in Chief + + + 9,216 + 8-1996 + C1 + Massively Underpaid + Food Taster of Parties + + + 9,217 + 4-1994 + A1 + Massively Overpaid + Builder Laureate + + + 9,218 + 10-2000 + A2 + Overpaid + Sports Mascot of Doom + + + 9,219 + 5-2018 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 9,220 + 7-2019 + C2 + Slave Labour + Philosopher Trainer + + + 9,221 + 10-2020 + B1 + Fairly Paid + Philosopher Extraordinaire + + + 9,222 + 6-2000 + A2 + Overpaid + Author for Schools + + + 9,223 + 5-2014 + B2 + Underpaid + Food Taster Laureate + + + 9,224 + 5-2023 + B1 + Fairly Paid + Author for the Environment + + + 9,225 + 3-2018 + A2 + Overpaid + Author for Schools + + + 9,226 + 8-2005 + C2 + Slave Labour + Historian of Doom + + + 9,227 + 3-2004 + A2 + Overpaid + Author in Chief + + + 9,228 + 4-1992 + B1 + Fairly Paid + Food Taster (Trainee) + + + 9,229 + 1-2016 + B1 + Fairly Paid + Author of Doom + + + 9,230 + 11-2009 + B1 + Fairly Paid + Author Trainer + + + 9,231 + 3-1993 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 9,232 + 10-2013 + A2 + Overpaid + Software Developer Laureate + + + 9,233 + 10-2021 + C1 + Massively Underpaid + Vigilante for the Environment + + + 9,234 + 10-2017 + A2 + Overpaid + Vigilante for Schools + + + 9,235 + 9-2023 + C1 + Massively Underpaid + Builder for the Environment + + + 9,236 + 10-2016 + B1 + Fairly Paid + Philosopher Laureate + + + 9,237 + 2-2006 + C1 + Massively Underpaid + Vigilante of Parties + + + 9,238 + 10-2020 + A1 + Massively Overpaid + Builder for Eternity + + + 9,239 + 7-2010 + B2 + Underpaid + Assassin Laureate + + + 9,240 + 9-1991 + B1 + Fairly Paid + Assassin in Chief + + + 9,241 + 4-2023 + C2 + Slave Labour + Builder in Chief + + + 9,242 + 3-2020 + A1 + Massively Overpaid + Vigilante of Cattle + + + 9,243 + 10-2013 + C2 + Slave Labour + Sports Mascot for Eternity + + + 9,244 + 6-2005 + B1 + Fairly Paid + Software Developer Laureate + + + 9,245 + 11-2017 + B1 + Fairly Paid + Assassin of Parties + + + 9,246 + 1-1999 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 9,247 + 5-2000 + B2 + Underpaid + Vigilante (Trainee) + + + 9,248 + 7-2013 + A1 + Massively Overpaid + Builder (Trainee) + + + 9,249 + 8-1994 + A2 + Overpaid + Builder in Chief + + + 9,250 + 12-2022 + B2 + Underpaid + Author Extraordinaire + + + 9,251 + 10-2012 + A2 + Overpaid + Food Taster for Schools + + + 9,252 + 8-2019 + A2 + Overpaid + Historian of Doom + + + 9,253 + 2-2016 + B2 + Underpaid + Philosopher for Eternity + + + 9,254 + 8-2019 + A2 + Overpaid + Vigilante Extraordinaire + + + 9,255 + 5-2016 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 9,256 + 3-1990 + B2 + Underpaid + Author Extraordinaire + + + 9,257 + 3-2013 + A1 + Massively Overpaid + Author for Eternity + + + 9,258 + 8-1993 + C1 + Massively Underpaid + Software Developer for Eternity + + + 9,259 + 10-2019 + B2 + Underpaid + Historian Laureate + + + 9,260 + 6-2016 + A1 + Massively Overpaid + Historian for the Environment + + + 9,261 + 5-2010 + A2 + Overpaid + Skydiving Instructor of Doom + + + 9,262 + 7-2022 + A1 + Massively Overpaid + Assassin of Doom + + + 9,263 + 3-2023 + C2 + Slave Labour + Historian for Eternity + + + 9,264 + 10-2009 + B2 + Underpaid + Author in Chief + + + 9,265 + 9-2005 + A1 + Massively Overpaid + Skydiving Instructor (Trainee) + + + 9,266 + 5-2013 + B1 + Fairly Paid + Assassin of Doom + + + 9,267 + 12-2011 + B2 + Underpaid + Software Developer Laureate + + + 9,268 + 5-2015 + B1 + Fairly Paid + Vigilante of Doom + + + 9,269 + 4-2014 + A1 + Massively Overpaid + Builder in Chief + + + 9,270 + 12-2011 + B2 + Underpaid + Philosopher (Trainee) + + + 9,271 + 4-2003 + C2 + Slave Labour + Builder in Chief + + + 9,272 + 6-1992 + A1 + Massively Overpaid + Author for the Environment + + + 9,273 + 7-2008 + A1 + Massively Overpaid + Sports Mascot for Eternity + + + 9,274 + 2-2006 + C1 + Massively Underpaid + Historian in Chief + + + 9,275 + 12-2022 + A1 + Massively Overpaid + Historian of Parties + + + 9,276 + 9-2010 + B2 + Underpaid + Vigilante Trainer + + + 9,277 + 7-1996 + B2 + Underpaid + Philosopher (Trainee) + + + 9,278 + 11-2014 + C2 + Slave Labour + Historian of Cattle + + + 9,279 + 2-2007 + A1 + Massively Overpaid + Builder for the Environment + + + 9,280 + 5-2003 + B2 + Underpaid + Assassin in Chief + + + 9,281 + 2-1994 + B2 + Underpaid + Vigilante of Parties + + + 9,282 + 4-2021 + A2 + Overpaid + Food Taster Extraordinaire + + + 9,283 + 8-2008 + A2 + Overpaid + Software Developer Laureate + + + 9,284 + 6-2003 + C1 + Massively Underpaid + Builder of Doom + + + 9,285 + 9-1991 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 9,286 + 3-2015 + B1 + Fairly Paid + Vigilante Laureate + + + 9,287 + 3-2011 + C2 + Slave Labour + Builder for Eternity + + + 9,288 + 4-2017 + C1 + Massively Underpaid + Author of Doom + + + 9,289 + 10-2009 + A1 + Massively Overpaid + Assassin for Schools + + + 9,290 + 3-2010 + C1 + Massively Underpaid + Historian of Doom + + + 9,291 + 12-2023 + C2 + Slave Labour + Sports Mascot Trainer + + + 9,292 + 11-1994 + C1 + Massively Underpaid + Historian of Cattle + + + 9,293 + 5-1991 + C1 + Massively Underpaid + Vigilante of Parties + + + 9,294 + 12-2016 + B1 + Fairly Paid + Assassin (Trainee) + + + 9,295 + 10-2015 + B1 + Fairly Paid + Historian (Trainee) + + + 9,296 + 6-1993 + A1 + Massively Overpaid + Builder in Chief + + + 9,297 + 3-2018 + B2 + Underpaid + Food Taster for Eternity + + + 9,298 + 8-2023 + B1 + Fairly Paid + Vigilante for the Environment + + + 9,299 + 6-1999 + A1 + Massively Overpaid + Builder in Chief + + + 9,300 + 3-1993 + A1 + Massively Overpaid + Philosopher Laureate + + + 9,301 + 12-2023 + A2 + Overpaid + Sports Mascot of Parties + + + 9,302 + 1-1994 + B1 + Fairly Paid + Skydiving Instructor of Cattle + + + 9,303 + 8-1999 + B1 + Fairly Paid + Sports Mascot for Schools + + + 9,304 + 12-2012 + B1 + Fairly Paid + Sports Mascot for Schools + + + 9,305 + 3-1997 + C2 + Slave Labour + Builder in Chief + + + 9,306 + 2-1990 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 9,307 + 11-2011 + B2 + Underpaid + Builder in Chief + + + 9,308 + 3-1990 + B1 + Fairly Paid + Historian for Eternity + + + 9,309 + 8-2020 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 9,310 + 1-1990 + B1 + Fairly Paid + Historian for the Environment + + + 9,311 + 5-2011 + C2 + Slave Labour + Skydiving Instructor Extraordinaire + + + 9,312 + 9-1998 + B2 + Underpaid + Philosopher in Chief + + + 9,313 + 6-1996 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 9,314 + 11-1993 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 9,315 + 7-2013 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 9,316 + 9-2013 + B2 + Underpaid + Builder of Doom + + + 9,317 + 7-2004 + B1 + Fairly Paid + Author for the Environment + + + 9,318 + 7-2004 + B2 + Underpaid + Sports Mascot for the Environment + + + 9,319 + 12-2015 + C1 + Massively Underpaid + Builder Laureate + + + 9,320 + 1-2018 + A1 + Massively Overpaid + Historian Trainer + + + 9,321 + 12-2012 + C2 + Slave Labour + Sports Mascot of Parties + + + 9,322 + 2-2007 + B2 + Underpaid + Software Developer (Trainee) + + + 9,323 + 5-1992 + C1 + Massively Underpaid + Vigilante for the Environment + + + 9,324 + 5-2003 + B1 + Fairly Paid + Assassin for Schools + + + 9,325 + 5-2021 + A1 + Massively Overpaid + Historian for Eternity + + + 9,326 + 4-2010 + C2 + Slave Labour + Philosopher for Eternity + + + 9,327 + 5-2005 + A2 + Overpaid + Sports Mascot in Chief + + + 9,328 + 7-2023 + A2 + Overpaid + Philosopher (Trainee) + + + 9,329 + 10-2015 + A1 + Massively Overpaid + Vigilante of Doom + + + 9,330 + 12-2000 + C1 + Massively Underpaid + Philosopher of Parties + + + 9,331 + 12-2004 + C2 + Slave Labour + Skydiving Instructor for Eternity + + + 9,332 + 12-1999 + A2 + Overpaid + Philosopher Extraordinaire + + + 9,333 + 9-2010 + C2 + Slave Labour + Vigilante for Eternity + + + 9,334 + 9-2012 + C2 + Slave Labour + Author of Parties + + + 9,335 + 5-1990 + C1 + Massively Underpaid + Assassin of Parties + + + 9,336 + 4-2011 + A2 + Overpaid + Sports Mascot of Doom + + + 9,337 + 1-2008 + A1 + Massively Overpaid + Philosopher of Doom + + + 9,338 + 12-2000 + C2 + Slave Labour + Vigilante Extraordinaire + + + 9,339 + 11-2016 + A1 + Massively Overpaid + Software Developer in Chief + + + 9,340 + 2-2013 + C1 + Massively Underpaid + Sports Mascot for Schools + + + 9,341 + 8-2022 + A1 + Massively Overpaid + Assassin for Schools + + + 9,342 + 7-2009 + C1 + Massively Underpaid + Software Developer Trainer + + + 9,343 + 8-1993 + B2 + Underpaid + Software Developer in Chief + + + 9,344 + 3-2018 + C1 + Massively Underpaid + Skydiving Instructor of Parties + + + 9,345 + 5-2007 + B2 + Underpaid + Skydiving Instructor for Schools + + + 9,346 + 5-2011 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 9,347 + 9-1993 + A2 + Overpaid + Author for Schools + + + 9,348 + 1-1992 + B2 + Underpaid + Philosopher of Parties + + + 9,349 + 7-2023 + B1 + Fairly Paid + Historian for Schools + + + 9,350 + 9-2022 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 9,351 + 10-2006 + B1 + Fairly Paid + Sports Mascot Laureate + + + 9,352 + 9-2004 + A2 + Overpaid + Author for Eternity + + + 9,353 + 2-2000 + B2 + Underpaid + Skydiving Instructor for the Environment + + + 9,354 + 12-2000 + C1 + Massively Underpaid + Food Taster Laureate + + + 9,355 + 8-2017 + A1 + Massively Overpaid + Historian for the Environment + + + 9,356 + 6-2002 + B2 + Underpaid + Builder for the Environment + + + 9,357 + 5-2018 + C1 + Massively Underpaid + Builder Extraordinaire + + + 9,358 + 2-2021 + C1 + Massively Underpaid + Food Taster Laureate + + + 9,359 + 6-2007 + C1 + Massively Underpaid + Builder Laureate + + + 9,360 + 12-2020 + C2 + Slave Labour + Vigilante for Eternity + + + 9,361 + 10-2007 + B2 + Underpaid + Software Developer in Chief + + + 9,362 + 5-1995 + A1 + Massively Overpaid + Sports Mascot of Parties + + + 9,363 + 10-1999 + C1 + Massively Underpaid + Vigilante Extraordinaire + + + 9,364 + 4-1994 + C2 + Slave Labour + Software Developer of Parties + + + 9,365 + 2-2013 + B1 + Fairly Paid + Builder Trainer + + + 9,366 + 9-1998 + B1 + Fairly Paid + Software Developer Trainer + + + 9,367 + 2-2012 + A2 + Overpaid + Author of Cattle + + + 9,368 + 2-2019 + C1 + Massively Underpaid + Vigilante for the Environment + + + 9,369 + 8-2017 + C1 + Massively Underpaid + Historian (Trainee) + + + 9,370 + 4-2017 + B2 + Underpaid + Builder for Schools + + + 9,371 + 11-1990 + B2 + Underpaid + Philosopher Trainer + + + 9,372 + 5-1990 + B1 + Fairly Paid + Skydiving Instructor Extraordinaire + + + 9,373 + 1-2019 + C2 + Slave Labour + Author Trainer + + + 9,374 + 2-2000 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 9,375 + 1-2016 + C1 + Massively Underpaid + Assassin of Doom + + + 9,376 + 12-1996 + B2 + Underpaid + Software Developer of Parties + + + 9,377 + 6-2010 + C1 + Massively Underpaid + Software Developer Laureate + + + 9,378 + 5-1995 + B1 + Fairly Paid + Builder in Chief + + + 9,379 + 12-2001 + B1 + Fairly Paid + Author of Doom + + + 9,380 + 2-2003 + B1 + Fairly Paid + Software Developer of Doom + + + 9,381 + 11-1990 + A2 + Overpaid + Vigilante of Doom + + + 9,382 + 8-2005 + B2 + Underpaid + Author Trainer + + + 9,383 + 3-1992 + C2 + Slave Labour + Sports Mascot for Schools + + + 9,384 + 5-2020 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 9,385 + 4-2006 + A2 + Overpaid + Historian (Trainee) + + + 9,386 + 3-2021 + B2 + Underpaid + Author of Cattle + + + 9,387 + 10-1995 + C1 + Massively Underpaid + Author in Chief + + + 9,388 + 12-2012 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 9,389 + 1-1992 + A2 + Overpaid + Sports Mascot of Doom + + + 9,390 + 2-2023 + B1 + Fairly Paid + Philosopher of Parties + + + 9,391 + 3-2017 + C2 + Slave Labour + Historian Laureate + + + 9,392 + 10-2006 + A2 + Overpaid + Software Developer Extraordinaire + + + 9,393 + 5-1996 + B2 + Underpaid + Philosopher for Schools + + + 9,394 + 6-2004 + A2 + Overpaid + Historian Extraordinaire + + + 9,395 + 11-2007 + A2 + Overpaid + Sports Mascot Trainer + + + 9,396 + 11-2021 + C1 + Massively Underpaid + Vigilante for the Environment + + + 9,397 + 5-2018 + C2 + Slave Labour + Assassin of Parties + + + 9,398 + 8-2011 + C1 + Massively Underpaid + Food Taster of Doom + + + 9,399 + 7-2021 + B2 + Underpaid + Sports Mascot in Chief + + + 9,400 + 1-1992 + A2 + Overpaid + Author of Doom + + + 9,401 + 1-2008 + C2 + Slave Labour + Sports Mascot for Schools + + + 9,402 + 6-2003 + C1 + Massively Underpaid + Historian of Cattle + + + 9,403 + 8-2005 + A2 + Overpaid + Food Taster of Doom + + + 9,404 + 8-2016 + A2 + Overpaid + Builder for Eternity + + + 9,405 + 12-2021 + B2 + Underpaid + Builder Extraordinaire + + + 9,406 + 5-1997 + A1 + Massively Overpaid + Assassin (Trainee) + + + 9,407 + 10-2006 + B1 + Fairly Paid + Vigilante for Eternity + + + 9,408 + 4-2004 + B2 + Underpaid + Assassin of Parties + + + 9,409 + 1-2004 + C1 + Massively Underpaid + Historian (Trainee) + + + 9,410 + 9-2005 + A2 + Overpaid + Historian (Trainee) + + + 9,411 + 4-1993 + A1 + Massively Overpaid + Author Extraordinaire + + + 9,412 + 8-2005 + B1 + Fairly Paid + Vigilante Laureate + + + 9,413 + 9-2014 + B1 + Fairly Paid + Sports Mascot Trainer + + + 9,414 + 8-2015 + B1 + Fairly Paid + Philosopher (Trainee) + + + 9,415 + 5-2014 + B2 + Underpaid + Author in Chief + + + 9,416 + 4-2012 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 9,417 + 8-2017 + B2 + Underpaid + Software Developer Extraordinaire + + + 9,418 + 9-1998 + B2 + Underpaid + Sports Mascot for Eternity + + + 9,419 + 11-1991 + C1 + Massively Underpaid + Software Developer of Doom + + + 9,420 + 12-2013 + B1 + Fairly Paid + Builder for Schools + + + 9,421 + 1-1997 + B1 + Fairly Paid + Skydiving Instructor for the Environment + + + 9,422 + 5-2006 + A1 + Massively Overpaid + Author Laureate + + + 9,423 + 3-2019 + B1 + Fairly Paid + Philosopher Trainer + + + 9,424 + 12-2002 + A2 + Overpaid + Author for Schools + + + 9,425 + 2-2014 + A1 + Massively Overpaid + Software Developer for Schools + + + 9,426 + 12-2013 + C2 + Slave Labour + Philosopher in Chief + + + 9,427 + 4-2011 + C1 + Massively Underpaid + Philosopher Laureate + + + 9,428 + 1-2002 + C1 + Massively Underpaid + Food Taster Trainer + + + 9,429 + 12-1998 + A1 + Massively Overpaid + Assassin Trainer + + + 9,430 + 2-2013 + B2 + Underpaid + Historian of Doom + + + 9,431 + 5-2016 + A1 + Massively Overpaid + Historian Laureate + + + 9,432 + 1-2018 + A2 + Overpaid + Author Laureate + + + 9,433 + 7-2002 + B1 + Fairly Paid + Builder Trainer + + + 9,434 + 10-2022 + A2 + Overpaid + Builder for Eternity + + + 9,435 + 9-2010 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 9,436 + 11-2016 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 9,437 + 2-2006 + B2 + Underpaid + Historian Laureate + + + 9,438 + 12-2008 + A2 + Overpaid + Food Taster Trainer + + + 9,439 + 9-2009 + B2 + Underpaid + Software Developer (Trainee) + + + 9,440 + 10-1994 + C2 + Slave Labour + Philosopher of Cattle + + + 9,441 + 11-2007 + C2 + Slave Labour + Historian of Cattle + + + 9,442 + 11-1998 + A1 + Massively Overpaid + Author Extraordinaire + + + 9,443 + 7-2022 + A1 + Massively Overpaid + Software Developer Trainer + + + 9,444 + 3-1997 + A2 + Overpaid + Assassin of Parties + + + 9,445 + 2-2009 + B2 + Underpaid + Assassin of Parties + + + 9,446 + 1-2002 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 9,447 + 10-2006 + A2 + Overpaid + Sports Mascot for Eternity + + + 9,448 + 11-2015 + C2 + Slave Labour + Author of Doom + + + 9,449 + 4-1991 + B1 + Fairly Paid + Skydiving Instructor (Trainee) + + + 9,450 + 11-2015 + C1 + Massively Underpaid + Vigilante Laureate + + + 9,451 + 7-1990 + A2 + Overpaid + Assassin for Eternity + + + 9,452 + 3-1994 + B2 + Underpaid + Assassin Extraordinaire + + + 9,453 + 4-1990 + B1 + Fairly Paid + Sports Mascot for the Environment + + + 9,454 + 2-2007 + C2 + Slave Labour + Sports Mascot of Parties + + + 9,455 + 10-2009 + C2 + Slave Labour + Author for the Environment + + + 9,456 + 7-2007 + B1 + Fairly Paid + Food Taster for Eternity + + + 9,457 + 6-1995 + C1 + Massively Underpaid + Vigilante Laureate + + + 9,458 + 11-2017 + A2 + Overpaid + Historian Extraordinaire + + + 9,459 + 5-2016 + B2 + Underpaid + Software Developer of Parties + + + 9,460 + 6-2000 + A2 + Overpaid + Assassin in Chief + + + 9,461 + 8-1992 + C2 + Slave Labour + Software Developer Laureate + + + 9,462 + 12-2010 + A1 + Massively Overpaid + Vigilante for Schools + + + 9,463 + 10-2007 + B1 + Fairly Paid + Software Developer of Doom + + + 9,464 + 12-2018 + B2 + Underpaid + Food Taster Laureate + + + 9,465 + 10-1996 + C2 + Slave Labour + Philosopher for Eternity + + + 9,466 + 7-2015 + A2 + Overpaid + Software Developer Extraordinaire + + + 9,467 + 12-2023 + A2 + Overpaid + Vigilante for Schools + + + 9,468 + 2-2013 + B1 + Fairly Paid + Assassin for Schools + + + 9,469 + 1-2022 + B1 + Fairly Paid + Food Taster (Trainee) + + + 9,470 + 8-1991 + A1 + Massively Overpaid + Builder for Eternity + + + 9,471 + 1-2005 + B1 + Fairly Paid + Vigilante in Chief + + + 9,472 + 11-1996 + A2 + Overpaid + Software Developer Laureate + + + 9,473 + 3-1997 + C2 + Slave Labour + Assassin for Schools + + + 9,474 + 8-2004 + C1 + Massively Underpaid + Builder Trainer + + + 9,475 + 11-1998 + C2 + Slave Labour + Software Developer in Chief + + + 9,476 + 7-2018 + A1 + Massively Overpaid + Author Extraordinaire + + + 9,477 + 2-1990 + A2 + Overpaid + Historian for Schools + + + 9,478 + 12-1990 + B2 + Underpaid + Food Taster of Cattle + + + 9,479 + 5-2021 + A2 + Overpaid + Builder Trainer + + + 9,480 + 1-2010 + C1 + Massively Underpaid + Vigilante for Eternity + + + 9,481 + 4-2015 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 9,482 + 1-2021 + C2 + Slave Labour + Sports Mascot for Schools + + + 9,483 + 5-2012 + A2 + Overpaid + Philosopher Trainer + + + 9,484 + 11-2014 + C2 + Slave Labour + Philosopher of Doom + + + 9,485 + 4-2017 + C2 + Slave Labour + Software Developer Trainer + + + 9,486 + 5-1995 + B1 + Fairly Paid + Vigilante Trainer + + + 9,487 + 4-1990 + C1 + Massively Underpaid + Historian in Chief + + + 9,488 + 11-2009 + B1 + Fairly Paid + Vigilante (Trainee) + + + 9,489 + 11-2013 + A2 + Overpaid + Philosopher Laureate + + + 9,490 + 11-1994 + C2 + Slave Labour + Philosopher for the Environment + + + 9,491 + 9-1999 + C2 + Slave Labour + Historian Extraordinaire + + + 9,492 + 6-1992 + A1 + Massively Overpaid + Vigilante for the Environment + + + 9,493 + 1-1996 + A2 + Overpaid + Philosopher (Trainee) + + + 9,494 + 9-2000 + B1 + Fairly Paid + Author Laureate + + + 9,495 + 4-2003 + A1 + Massively Overpaid + Sports Mascot for the Environment + + + 9,496 + 3-2014 + B1 + Fairly Paid + Philosopher for Schools + + + 9,497 + 7-2008 + C2 + Slave Labour + Builder Trainer + + + 9,498 + 3-1996 + A1 + Massively Overpaid + Assassin for Eternity + + + 9,499 + 6-2001 + B2 + Underpaid + Food Taster Laureate + + + 9,500 + 5-2019 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 9,501 + 2-2019 + A2 + Overpaid + Food Taster Trainer + + + 9,502 + 8-2000 + B1 + Fairly Paid + Assassin of Doom + + + 9,503 + 3-2015 + B2 + Underpaid + Food Taster for Schools + + + 9,504 + 6-2014 + A1 + Massively Overpaid + Historian Laureate + + + 9,505 + 2-2011 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 9,506 + 12-2023 + A2 + Overpaid + Software Developer Extraordinaire + + + 9,507 + 12-2002 + C2 + Slave Labour + Historian for Eternity + + + 9,508 + 1-1998 + C1 + Massively Underpaid + Skydiving Instructor Extraordinaire + + + 9,509 + 9-1993 + B1 + Fairly Paid + Software Developer of Cattle + + + 9,510 + 12-2010 + C1 + Massively Underpaid + Food Taster (Trainee) + + + 9,511 + 6-2022 + C1 + Massively Underpaid + Food Taster of Parties + + + 9,512 + 3-2005 + C1 + Massively Underpaid + Sports Mascot of Doom + + + 9,513 + 11-2018 + A1 + Massively Overpaid + Assassin Extraordinaire + + + 9,514 + 11-1994 + A2 + Overpaid + Historian of Cattle + + + 9,515 + 9-1999 + A2 + Overpaid + Software Developer of Doom + + + 9,516 + 10-2012 + A1 + Massively Overpaid + Assassin for Eternity + + + 9,517 + 3-1990 + B1 + Fairly Paid + Builder for Schools + + + 9,518 + 7-1995 + A2 + Overpaid + Philosopher for Eternity + + + 9,519 + 1-2002 + B1 + Fairly Paid + Food Taster for Eternity + + + 9,520 + 11-2016 + C1 + Massively Underpaid + Author (Trainee) + + + 9,521 + 9-2008 + A2 + Overpaid + Skydiving Instructor for the Environment + + + 9,522 + 8-1999 + A1 + Massively Overpaid + Author of Doom + + + 9,523 + 1-2021 + C1 + Massively Underpaid + Historian in Chief + + + 9,524 + 1-2005 + C1 + Massively Underpaid + Assassin of Cattle + + + 9,525 + 11-2000 + A2 + Overpaid + Assassin for the Environment + + + 9,526 + 9-2019 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 9,527 + 9-2005 + B1 + Fairly Paid + Sports Mascot Trainer + + + 9,528 + 8-1992 + C1 + Massively Underpaid + Vigilante for the Environment + + + 9,529 + 1-2000 + C1 + Massively Underpaid + Sports Mascot for Eternity + + + 9,530 + 8-2004 + C2 + Slave Labour + Author of Parties + + + 9,531 + 2-1999 + B1 + Fairly Paid + Philosopher of Doom + + + 9,532 + 12-2005 + A2 + Overpaid + Sports Mascot for Schools + + + 9,533 + 1-2016 + A2 + Overpaid + Food Taster for Schools + + + 9,534 + 8-2002 + C1 + Massively Underpaid + Vigilante in Chief + + + 9,535 + 1-1999 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 9,536 + 12-1994 + B2 + Underpaid + Author (Trainee) + + + 9,537 + 11-2012 + B1 + Fairly Paid + Software Developer for the Environment + + + 9,538 + 10-2010 + A1 + Massively Overpaid + Historian of Cattle + + + 9,539 + 12-2003 + C2 + Slave Labour + Vigilante of Doom + + + 9,540 + 3-2020 + B1 + Fairly Paid + Historian of Doom + + + 9,541 + 11-2022 + A2 + Overpaid + Software Developer for Eternity + + + 9,542 + 6-2011 + A2 + Overpaid + Historian of Doom + + + 9,543 + 1-2019 + C2 + Slave Labour + Assassin for the Environment + + + 9,544 + 9-2012 + C1 + Massively Underpaid + Historian Extraordinaire + + + 9,545 + 1-2018 + A1 + Massively Overpaid + Software Developer Trainer + + + 9,546 + 8-1992 + C1 + Massively Underpaid + Skydiving Instructor in Chief + + + 9,547 + 9-1998 + A1 + Massively Overpaid + Philosopher for Eternity + + + 9,548 + 3-1995 + B2 + Underpaid + Assassin of Cattle + + + 9,549 + 10-2004 + A2 + Overpaid + Food Taster of Doom + + + 9,550 + 2-2014 + B2 + Underpaid + Assassin Trainer + + + 9,551 + 10-2010 + C2 + Slave Labour + Software Developer of Cattle + + + 9,552 + 11-2017 + C2 + Slave Labour + Sports Mascot in Chief + + + 9,553 + 7-2021 + B1 + Fairly Paid + Author in Chief + + + 9,554 + 9-2008 + A2 + Overpaid + Sports Mascot Trainer + + + 9,555 + 5-2015 + B2 + Underpaid + Historian of Cattle + + + 9,556 + 11-1990 + B2 + Underpaid + Vigilante (Trainee) + + + 9,557 + 2-2011 + A1 + Massively Overpaid + Philosopher Laureate + + + 9,558 + 4-2002 + A2 + Overpaid + Builder of Parties + + + 9,559 + 3-2007 + B1 + Fairly Paid + Philosopher for the Environment + + + 9,560 + 11-2002 + C2 + Slave Labour + Vigilante (Trainee) + + + 9,561 + 5-2022 + C1 + Massively Underpaid + Skydiving Instructor of Cattle + + + 9,562 + 6-1993 + A1 + Massively Overpaid + Software Developer for Schools + + + 9,563 + 2-1994 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 9,564 + 7-1996 + C1 + Massively Underpaid + Assassin of Cattle + + + 9,565 + 5-2023 + B2 + Underpaid + Assassin Laureate + + + 9,566 + 12-1995 + A1 + Massively Overpaid + Sports Mascot Trainer + + + 9,567 + 1-2002 + B2 + Underpaid + Historian of Doom + + + 9,568 + 12-2002 + C1 + Massively Underpaid + Skydiving Instructor (Trainee) + + + 9,569 + 7-2004 + B2 + Underpaid + Builder Trainer + + + 9,570 + 4-1995 + B2 + Underpaid + Assassin for the Environment + + + 9,571 + 9-1994 + B2 + Underpaid + Sports Mascot Laureate + + + 9,572 + 10-2013 + C1 + Massively Underpaid + Software Developer of Doom + + + 9,573 + 4-2016 + A1 + Massively Overpaid + Author for Eternity + + + 9,574 + 12-2019 + C2 + Slave Labour + Builder Laureate + + + 9,575 + 7-1998 + C1 + Massively Underpaid + Vigilante of Cattle + + + 9,576 + 10-2015 + A1 + Massively Overpaid + Skydiving Instructor of Doom + + + 9,577 + 7-2007 + B1 + Fairly Paid + Vigilante Laureate + + + 9,578 + 9-2014 + A1 + Massively Overpaid + Historian Laureate + + + 9,579 + 11-2016 + C1 + Massively Underpaid + Author (Trainee) + + + 9,580 + 9-1993 + A1 + Massively Overpaid + Builder of Doom + + + 9,581 + 10-2023 + A2 + Overpaid + Historian for Schools + + + 9,582 + 12-2020 + B2 + Underpaid + Historian Extraordinaire + + + 9,583 + 9-1990 + C2 + Slave Labour + Software Developer for Eternity + + + 9,584 + 12-1998 + B2 + Underpaid + Builder of Cattle + + + 9,585 + 9-2008 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 9,586 + 8-1993 + A1 + Massively Overpaid + Author of Parties + + + 9,587 + 8-2022 + B2 + Underpaid + Skydiving Instructor Trainer + + + 9,588 + 1-2007 + A2 + Overpaid + Historian Laureate + + + 9,589 + 10-1999 + A2 + Overpaid + Sports Mascot Extraordinaire + + + 9,590 + 11-2018 + C2 + Slave Labour + Philosopher of Cattle + + + 9,591 + 9-1999 + B2 + Underpaid + Historian of Parties + + + 9,592 + 4-1992 + B1 + Fairly Paid + Vigilante of Parties + + + 9,593 + 2-1994 + B2 + Underpaid + Sports Mascot Laureate + + + 9,594 + 11-1994 + C1 + Massively Underpaid + Historian Extraordinaire + + + 9,595 + 7-1995 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 9,596 + 6-1999 + C2 + Slave Labour + Software Developer Laureate + + + 9,597 + 4-2010 + C1 + Massively Underpaid + Sports Mascot Laureate + + + 9,598 + 1-1999 + C2 + Slave Labour + Builder (Trainee) + + + 9,599 + 9-2018 + B1 + Fairly Paid + Philosopher for the Environment + + + 9,600 + 11-1993 + C2 + Slave Labour + Skydiving Instructor Laureate + + + 9,601 + 1-2001 + A2 + Overpaid + Author Laureate + + + 9,602 + 4-2010 + B1 + Fairly Paid + Builder in Chief + + + 9,603 + 11-2000 + B2 + Underpaid + Sports Mascot of Doom + + + 9,604 + 6-1998 + A1 + Massively Overpaid + Software Developer of Parties + + + 9,605 + 12-2010 + A1 + Massively Overpaid + Vigilante of Doom + + + 9,606 + 4-2021 + C2 + Slave Labour + Assassin of Cattle + + + 9,607 + 7-2023 + B2 + Underpaid + Philosopher for the Environment + + + 9,608 + 6-2002 + A2 + Overpaid + Skydiving Instructor for Schools + + + 9,609 + 12-2000 + A1 + Massively Overpaid + Software Developer Laureate + + + 9,610 + 2-2006 + C1 + Massively Underpaid + Author Laureate + + + 9,611 + 11-2012 + A1 + Massively Overpaid + Vigilante Laureate + + + 9,612 + 6-2005 + C2 + Slave Labour + Food Taster for Schools + + + 9,613 + 9-2005 + C1 + Massively Underpaid + Vigilante for Eternity + + + 9,614 + 1-1997 + A2 + Overpaid + Vigilante Extraordinaire + + + 9,615 + 8-2001 + B2 + Underpaid + Assassin in Chief + + + 9,616 + 10-2013 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 9,617 + 4-1998 + A2 + Overpaid + Sports Mascot Laureate + + + 9,618 + 3-2011 + B2 + Underpaid + Food Taster Extraordinaire + + + 9,619 + 11-2008 + C1 + Massively Underpaid + Software Developer Laureate + + + 9,620 + 9-2009 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 9,621 + 7-2015 + C1 + Massively Underpaid + Assassin for Schools + + + 9,622 + 5-2001 + B2 + Underpaid + Assassin of Cattle + + + 9,623 + 11-2009 + B2 + Underpaid + Software Developer Trainer + + + 9,624 + 1-1991 + B2 + Underpaid + Philosopher of Doom + + + 9,625 + 8-2010 + B1 + Fairly Paid + Philosopher for the Environment + + + 9,626 + 1-2020 + A2 + Overpaid + Skydiving Instructor Laureate + + + 9,627 + 12-2003 + C1 + Massively Underpaid + Historian Laureate + + + 9,628 + 6-2001 + B1 + Fairly Paid + Assassin Laureate + + + 9,629 + 9-2010 + A2 + Overpaid + Philosopher of Doom + + + 9,630 + 6-1993 + C1 + Massively Underpaid + Software Developer (Trainee) + + + 9,631 + 11-2004 + C2 + Slave Labour + Philosopher for Schools + + + 9,632 + 4-2015 + C2 + Slave Labour + Historian in Chief + + + 9,633 + 1-2007 + C1 + Massively Underpaid + Historian for Eternity + + + 9,634 + 3-2002 + C2 + Slave Labour + Philosopher of Parties + + + 9,635 + 8-2008 + A2 + Overpaid + Skydiving Instructor Trainer + + + 9,636 + 4-2020 + B1 + Fairly Paid + Assassin for Eternity + + + 9,637 + 11-2012 + A2 + Overpaid + Food Taster (Trainee) + + + 9,638 + 9-2009 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 9,639 + 4-2010 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 9,640 + 7-2016 + B1 + Fairly Paid + Assassin Laureate + + + 9,641 + 5-2010 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 9,642 + 3-1999 + C2 + Slave Labour + Sports Mascot for the Environment + + + 9,643 + 3-1998 + A1 + Massively Overpaid + Author (Trainee) + + + 9,644 + 4-2002 + B2 + Underpaid + Philosopher of Parties + + + 9,645 + 12-1998 + B2 + Underpaid + Food Taster of Doom + + + 9,646 + 11-2000 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 9,647 + 10-2013 + C1 + Massively Underpaid + Vigilante for Schools + + + 9,648 + 4-1996 + A1 + Massively Overpaid + Food Taster in Chief + + + 9,649 + 7-1990 + B2 + Underpaid + Assassin of Cattle + + + 9,650 + 2-2008 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 9,651 + 10-2016 + C1 + Massively Underpaid + Historian for Schools + + + 9,652 + 4-2003 + B1 + Fairly Paid + Software Developer in Chief + + + 9,653 + 9-1997 + C2 + Slave Labour + Software Developer for Schools + + + 9,654 + 2-2008 + B1 + Fairly Paid + Historian in Chief + + + 9,655 + 2-2015 + C1 + Massively Underpaid + Historian for Eternity + + + 9,656 + 9-2005 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 9,657 + 4-2017 + A1 + Massively Overpaid + Vigilante for the Environment + + + 9,658 + 5-2002 + C1 + Massively Underpaid + Assassin in Chief + + + 9,659 + 7-1997 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 9,660 + 9-2022 + C1 + Massively Underpaid + Builder of Cattle + + + 9,661 + 3-1992 + A2 + Overpaid + Food Taster for the Environment + + + 9,662 + 11-2000 + C1 + Massively Underpaid + Author of Cattle + + + 9,663 + 2-2006 + A2 + Overpaid + Historian for Schools + + + 9,664 + 4-2016 + C1 + Massively Underpaid + Food Taster for Schools + + + 9,665 + 8-2018 + B1 + Fairly Paid + Food Taster of Parties + + + 9,666 + 12-2011 + A1 + Massively Overpaid + Skydiving Instructor for Schools + + + 9,667 + 6-2017 + B2 + Underpaid + Philosopher Extraordinaire + + + 9,668 + 6-1999 + C2 + Slave Labour + Vigilante for Schools + + + 9,669 + 8-2002 + A1 + Massively Overpaid + Food Taster of Parties + + + 9,670 + 12-2006 + A1 + Massively Overpaid + Historian of Doom + + + 9,671 + 12-2001 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 9,672 + 2-2001 + B2 + Underpaid + Builder Laureate + + + 9,673 + 5-2001 + B1 + Fairly Paid + Historian Laureate + + + 9,674 + 11-2001 + C2 + Slave Labour + Skydiving Instructor of Cattle + + + 9,675 + 1-1997 + B2 + Underpaid + Sports Mascot for Schools + + + 9,676 + 5-2005 + B1 + Fairly Paid + Historian of Parties + + + 9,677 + 7-2002 + B2 + Underpaid + Sports Mascot of Doom + + + 9,678 + 8-2021 + C1 + Massively Underpaid + Skydiving Instructor Laureate + + + 9,679 + 11-2006 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 9,680 + 2-2009 + A2 + Overpaid + Builder for Schools + + + 9,681 + 10-1992 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 9,682 + 3-2006 + C2 + Slave Labour + Builder Laureate + + + 9,683 + 4-2005 + C1 + Massively Underpaid + Software Developer of Parties + + + 9,684 + 11-2006 + A1 + Massively Overpaid + Philosopher Extraordinaire + + + 9,685 + 7-2007 + A2 + Overpaid + Software Developer of Doom + + + 9,686 + 3-1991 + A1 + Massively Overpaid + Historian of Doom + + + 9,687 + 4-2023 + C1 + Massively Underpaid + Food Taster for the Environment + + + 9,688 + 2-2020 + C2 + Slave Labour + Historian of Cattle + + + 9,689 + 5-1996 + C2 + Slave Labour + Sports Mascot for Schools + + + 9,690 + 7-2022 + B1 + Fairly Paid + Software Developer of Doom + + + 9,691 + 5-2010 + A1 + Massively Overpaid + Food Taster of Parties + + + 9,692 + 12-1991 + A2 + Overpaid + Vigilante (Trainee) + + + 9,693 + 4-2006 + B2 + Underpaid + Software Developer for the Environment + + + 9,694 + 5-1996 + B1 + Fairly Paid + Skydiving Instructor of Parties + + + 9,695 + 2-2010 + B1 + Fairly Paid + Sports Mascot for Eternity + + + 9,696 + 6-1992 + C1 + Massively Underpaid + Software Developer for Schools + + + 9,697 + 1-2006 + B2 + Underpaid + Skydiving Instructor for Schools + + + 9,698 + 1-1997 + B1 + Fairly Paid + Vigilante (Trainee) + + + 9,699 + 1-2006 + A2 + Overpaid + Food Taster of Cattle + + + 9,700 + 5-2000 + A2 + Overpaid + Philosopher Trainer + + + 9,701 + 5-1993 + C2 + Slave Labour + Software Developer (Trainee) + + + 9,702 + 11-2013 + C1 + Massively Underpaid + Sports Mascot (Trainee) + + + 9,703 + 6-1992 + B2 + Underpaid + Assassin Laureate + + + 9,704 + 5-2017 + C2 + Slave Labour + Philosopher of Parties + + + 9,705 + 12-2018 + C1 + Massively Underpaid + Philosopher in Chief + + + 9,706 + 7-2006 + B2 + Underpaid + Food Taster (Trainee) + + + 9,707 + 1-2006 + A2 + Overpaid + Skydiving Instructor for Eternity + + + 9,708 + 4-2019 + B1 + Fairly Paid + Software Developer Extraordinaire + + + 9,709 + 10-2019 + A2 + Overpaid + Author for the Environment + + + 9,710 + 11-1994 + A2 + Overpaid + Sports Mascot of Parties + + + 9,711 + 12-2010 + C2 + Slave Labour + Author Laureate + + + 9,712 + 10-2016 + A2 + Overpaid + Author (Trainee) + + + 9,713 + 2-1997 + B1 + Fairly Paid + Food Taster of Doom + + + 9,714 + 12-2009 + A2 + Overpaid + Vigilante Trainer + + + 9,715 + 2-1998 + C2 + Slave Labour + Historian of Cattle + + + 9,716 + 4-2011 + B1 + Fairly Paid + Author for Schools + + + 9,717 + 11-2014 + C1 + Massively Underpaid + Philosopher Laureate + + + 9,718 + 7-2015 + B1 + Fairly Paid + Author (Trainee) + + + 9,719 + 3-2015 + C2 + Slave Labour + Vigilante Extraordinaire + + + 9,720 + 12-1994 + C2 + Slave Labour + Historian of Parties + + + 9,721 + 4-2009 + C2 + Slave Labour + Assassin (Trainee) + + + 9,722 + 3-2018 + A2 + Overpaid + Sports Mascot (Trainee) + + + 9,723 + 1-2011 + C2 + Slave Labour + Assassin for the Environment + + + 9,724 + 7-2023 + C1 + Massively Underpaid + Food Taster of Parties + + + 9,725 + 10-2010 + C1 + Massively Underpaid + Sports Mascot Trainer + + + 9,726 + 6-1998 + A1 + Massively Overpaid + Builder Extraordinaire + + + 9,727 + 4-1996 + B1 + Fairly Paid + Vigilante Extraordinaire + + + 9,728 + 3-1991 + A2 + Overpaid + Philosopher Laureate + + + 9,729 + 1-1998 + C1 + Massively Underpaid + Author for Schools + + + 9,730 + 12-2006 + C2 + Slave Labour + Philosopher (Trainee) + + + 9,731 + 6-1990 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 9,732 + 10-1994 + B1 + Fairly Paid + Skydiving Instructor for Eternity + + + 9,733 + 12-1993 + B2 + Underpaid + Skydiving Instructor for Eternity + + + 9,734 + 12-1991 + A2 + Overpaid + Assassin of Cattle + + + 9,735 + 11-2017 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 9,736 + 4-2008 + A1 + Massively Overpaid + Builder for Eternity + + + 9,737 + 7-1993 + B1 + Fairly Paid + Author of Cattle + + + 9,738 + 8-2000 + B2 + Underpaid + Food Taster for Eternity + + + 9,739 + 5-2000 + B1 + Fairly Paid + Food Taster (Trainee) + + + 9,740 + 1-2010 + A1 + Massively Overpaid + Historian of Parties + + + 9,741 + 9-2004 + A1 + Massively Overpaid + Vigilante Trainer + + + 9,742 + 9-2003 + A2 + Overpaid + Philosopher of Doom + + + 9,743 + 3-2012 + C2 + Slave Labour + Builder for the Environment + + + 9,744 + 1-2009 + B1 + Fairly Paid + Assassin of Doom + + + 9,745 + 10-1997 + C1 + Massively Underpaid + Assassin Trainer + + + 9,746 + 2-2023 + A1 + Massively Overpaid + Builder Laureate + + + 9,747 + 3-2003 + B2 + Underpaid + Food Taster of Cattle + + + 9,748 + 12-1998 + B1 + Fairly Paid + Sports Mascot for Schools + + + 9,749 + 3-2011 + B1 + Fairly Paid + Historian Trainer + + + 9,750 + 6-2011 + C1 + Massively Underpaid + Author of Cattle + + + 9,751 + 10-2003 + A2 + Overpaid + Skydiving Instructor of Parties + + + 9,752 + 8-2013 + C2 + Slave Labour + Historian of Parties + + + 9,753 + 8-2016 + C2 + Slave Labour + Author for the Environment + + + 9,754 + 4-2001 + C2 + Slave Labour + Builder for the Environment + + + 9,755 + 5-2009 + A2 + Overpaid + Assassin (Trainee) + + + 9,756 + 3-2007 + A2 + Overpaid + Food Taster Laureate + + + 9,757 + 2-2015 + C1 + Massively Underpaid + Historian of Cattle + + + 9,758 + 8-2021 + C2 + Slave Labour + Sports Mascot for Schools + + + 9,759 + 2-1992 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 9,760 + 7-2018 + B2 + Underpaid + Builder of Parties + + + 9,761 + 7-2023 + B2 + Underpaid + Assassin in Chief + + + 9,762 + 2-1997 + C2 + Slave Labour + Assassin for Schools + + + 9,763 + 5-1996 + B2 + Underpaid + Food Taster Extraordinaire + + + 9,764 + 3-2002 + A2 + Overpaid + Software Developer (Trainee) + + + 9,765 + 11-1992 + B2 + Underpaid + Author (Trainee) + + + 9,766 + 3-2001 + A2 + Overpaid + Philosopher of Cattle + + + 9,767 + 5-2018 + A1 + Massively Overpaid + Historian Extraordinaire + + + 9,768 + 2-1993 + C2 + Slave Labour + Philosopher Trainer + + + 9,769 + 9-2006 + A1 + Massively Overpaid + Skydiving Instructor in Chief + + + 9,770 + 11-2009 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 9,771 + 3-1999 + C1 + Massively Underpaid + Food Taster of Doom + + + 9,772 + 11-2015 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 9,773 + 11-1999 + B2 + Underpaid + Software Developer for Eternity + + + 9,774 + 8-2005 + B1 + Fairly Paid + Builder for Eternity + + + 9,775 + 1-1999 + C2 + Slave Labour + Software Developer Trainer + + + 9,776 + 5-2019 + A1 + Massively Overpaid + Historian Laureate + + + 9,777 + 3-2010 + A1 + Massively Overpaid + Sports Mascot Extraordinaire + + + 9,778 + 1-2015 + C1 + Massively Underpaid + Sports Mascot of Cattle + + + 9,779 + 5-2005 + C2 + Slave Labour + Builder in Chief + + + 9,780 + 12-1998 + B2 + Underpaid + Author (Trainee) + + + 9,781 + 8-2008 + A1 + Massively Overpaid + Builder (Trainee) + + + 9,782 + 8-2000 + B2 + Underpaid + Philosopher of Cattle + + + 9,783 + 5-2015 + C1 + Massively Underpaid + Vigilante (Trainee) + + + 9,784 + 9-2014 + A1 + Massively Overpaid + Philosopher Trainer + + + 9,785 + 5-1992 + C1 + Massively Underpaid + Food Taster for Eternity + + + 9,786 + 11-2017 + C2 + Slave Labour + Historian Laureate + + + 9,787 + 10-1995 + A1 + Massively Overpaid + Historian for the Environment + + + 9,788 + 6-2012 + A1 + Massively Overpaid + Vigilante of Parties + + + 9,789 + 12-1993 + C1 + Massively Underpaid + Assassin of Doom + + + 9,790 + 7-2008 + B1 + Fairly Paid + Author in Chief + + + 9,791 + 6-1994 + A2 + Overpaid + Author for Schools + + + 9,792 + 1-2005 + C1 + Massively Underpaid + Author Extraordinaire + + + 9,793 + 7-2001 + C2 + Slave Labour + Skydiving Instructor Trainer + + + 9,794 + 11-1992 + A1 + Massively Overpaid + Builder in Chief + + + 9,795 + 2-1995 + A1 + Massively Overpaid + Skydiving Instructor Trainer + + + 9,796 + 9-2021 + C1 + Massively Underpaid + Software Developer of Doom + + + 9,797 + 4-2019 + A1 + Massively Overpaid + Assassin Trainer + + + 9,798 + 10-1991 + A2 + Overpaid + Vigilante of Doom + + + 9,799 + 2-2005 + C1 + Massively Underpaid + Assassin (Trainee) + + + 9,800 + 4-2008 + B2 + Underpaid + Skydiving Instructor for Schools + + + 9,801 + 7-2013 + B1 + Fairly Paid + Historian Laureate + + + 9,802 + 7-2014 + B1 + Fairly Paid + Historian for the Environment + + + 9,803 + 7-1993 + C1 + Massively Underpaid + Author Extraordinaire + + + 9,804 + 4-2022 + A1 + Massively Overpaid + Assassin Laureate + + + 9,805 + 6-1992 + A2 + Overpaid + Builder of Cattle + + + 9,806 + 6-2003 + A2 + Overpaid + Food Taster of Doom + + + 9,807 + 11-2001 + C1 + Massively Underpaid + Vigilante of Parties + + + 9,808 + 2-1992 + A2 + Overpaid + Food Taster for Eternity + + + 9,809 + 4-2012 + C2 + Slave Labour + Software Developer Trainer + + + 9,810 + 9-2008 + C1 + Massively Underpaid + Vigilante in Chief + + + 9,811 + 10-2014 + B1 + Fairly Paid + Vigilante for the Environment + + + 9,812 + 10-2022 + A1 + Massively Overpaid + Software Developer of Cattle + + + 9,813 + 6-1991 + B1 + Fairly Paid + Skydiving Instructor in Chief + + + 9,814 + 10-2017 + C2 + Slave Labour + Assassin of Cattle + + + 9,815 + 8-1998 + B2 + Underpaid + Historian Laureate + + + 9,816 + 12-2019 + C1 + Massively Underpaid + Assassin of Doom + + + 9,817 + 8-2006 + B1 + Fairly Paid + Sports Mascot in Chief + + + 9,818 + 12-1998 + A2 + Overpaid + Vigilante (Trainee) + + + 9,819 + 3-2015 + A1 + Massively Overpaid + Vigilante for Eternity + + + 9,820 + 5-2011 + A1 + Massively Overpaid + Vigilante Extraordinaire + + + 9,821 + 12-1998 + C1 + Massively Underpaid + Software Developer for Eternity + + + 9,822 + 5-2017 + A1 + Massively Overpaid + Philosopher for Eternity + + + 9,823 + 10-2002 + A2 + Overpaid + Philosopher for the Environment + + + 9,824 + 9-2022 + A2 + Overpaid + Historian of Doom + + + 9,825 + 6-2018 + C1 + Massively Underpaid + Author of Doom + + + 9,826 + 6-1999 + C1 + Massively Underpaid + Philosopher of Doom + + + 9,827 + 8-2021 + C2 + Slave Labour + Vigilante Trainer + + + 9,828 + 5-1998 + A2 + Overpaid + Vigilante in Chief + + + 9,829 + 4-2003 + A2 + Overpaid + Skydiving Instructor Extraordinaire + + + 9,830 + 3-2003 + B2 + Underpaid + Skydiving Instructor Extraordinaire + + + 9,831 + 8-2014 + C2 + Slave Labour + Sports Mascot Laureate + + + 9,832 + 5-2023 + C2 + Slave Labour + Software Developer for Eternity + + + 9,833 + 4-2008 + B1 + Fairly Paid + Historian for the Environment + + + 9,834 + 2-2002 + C1 + Massively Underpaid + Software Developer Laureate + + + 9,835 + 6-2001 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 9,836 + 11-2003 + C2 + Slave Labour + Assassin of Parties + + + 9,837 + 4-2018 + A2 + Overpaid + Software Developer of Parties + + + 9,838 + 3-1996 + B1 + Fairly Paid + Software Developer for the Environment + + + 9,839 + 8-2022 + B1 + Fairly Paid + Builder in Chief + + + 9,840 + 4-2016 + B2 + Underpaid + Author for the Environment + + + 9,841 + 2-2019 + C2 + Slave Labour + Vigilante of Cattle + + + 9,842 + 6-2002 + B1 + Fairly Paid + Assassin Laureate + + + 9,843 + 2-2023 + B1 + Fairly Paid + Builder of Cattle + + + 9,844 + 12-1997 + B1 + Fairly Paid + Author Extraordinaire + + + 9,845 + 6-1993 + A2 + Overpaid + Skydiving Instructor (Trainee) + + + 9,846 + 7-1990 + A1 + Massively Overpaid + Assassin for the Environment + + + 9,847 + 3-2003 + B2 + Underpaid + Food Taster Laureate + + + 9,848 + 1-1996 + B1 + Fairly Paid + Philosopher of Parties + + + 9,849 + 8-2011 + C2 + Slave Labour + Sports Mascot Laureate + + + 9,850 + 3-1999 + A2 + Overpaid + Sports Mascot of Cattle + + + 9,851 + 1-2020 + B2 + Underpaid + Food Taster Extraordinaire + + + 9,852 + 1-2018 + A1 + Massively Overpaid + Software Developer Trainer + + + 9,853 + 6-1993 + B2 + Underpaid + Assassin for Schools + + + 9,854 + 4-2007 + A1 + Massively Overpaid + Sports Mascot in Chief + + + 9,855 + 4-1990 + A1 + Massively Overpaid + Skydiving Instructor for the Environment + + + 9,856 + 12-2015 + B2 + Underpaid + Author for Eternity + + + 9,857 + 7-2019 + B2 + Underpaid + Philosopher of Doom + + + 9,858 + 5-2023 + B1 + Fairly Paid + Software Developer of Doom + + + 9,859 + 4-2015 + A1 + Massively Overpaid + Assassin of Parties + + + 9,860 + 5-2015 + A1 + Massively Overpaid + Sports Mascot of Cattle + + + 9,861 + 7-2000 + B1 + Fairly Paid + Builder Laureate + + + 9,862 + 5-2010 + C1 + Massively Underpaid + Vigilante of Cattle + + + 9,863 + 9-1994 + C1 + Massively Underpaid + Historian of Parties + + + 9,864 + 10-2002 + B1 + Fairly Paid + Software Developer for Schools + + + 9,865 + 5-2001 + B1 + Fairly Paid + Sports Mascot of Cattle + + + 9,866 + 10-1995 + A2 + Overpaid + Vigilante of Doom + + + 9,867 + 8-1998 + A1 + Massively Overpaid + Sports Mascot (Trainee) + + + 9,868 + 12-2006 + A2 + Overpaid + Philosopher for Schools + + + 9,869 + 7-2007 + C1 + Massively Underpaid + Historian (Trainee) + + + 9,870 + 9-1994 + C1 + Massively Underpaid + Builder of Doom + + + 9,871 + 4-1999 + C2 + Slave Labour + Skydiving Instructor of Parties + + + 9,872 + 9-2022 + B1 + Fairly Paid + Author of Parties + + + 9,873 + 8-2017 + B2 + Underpaid + Philosopher Laureate + + + 9,874 + 7-2013 + B2 + Underpaid + Historian Extraordinaire + + + 9,875 + 6-1995 + C2 + Slave Labour + Vigilante Trainer + + + 9,876 + 4-2012 + B2 + Underpaid + Skydiving Instructor (Trainee) + + + 9,877 + 10-2004 + B1 + Fairly Paid + Assassin Extraordinaire + + + 9,878 + 11-2017 + B2 + Underpaid + Vigilante Laureate + + + 9,879 + 3-2000 + C2 + Slave Labour + Vigilante Laureate + + + 9,880 + 11-2001 + B2 + Underpaid + Sports Mascot Trainer + + + 9,881 + 6-1991 + B2 + Underpaid + Builder for Schools + + + 9,882 + 8-2021 + B2 + Underpaid + Historian for Eternity + + + 9,883 + 8-2012 + A2 + Overpaid + Philosopher for Schools + + + 9,884 + 12-2002 + C2 + Slave Labour + Sports Mascot Laureate + + + 9,885 + 2-2015 + C2 + Slave Labour + Skydiving Instructor (Trainee) + + + 9,886 + 8-1999 + C2 + Slave Labour + Food Taster for Schools + + + 9,887 + 4-2012 + C1 + Massively Underpaid + Author for Schools + + + 9,888 + 10-1995 + C1 + Massively Underpaid + Builder for Schools + + + 9,889 + 7-2023 + A1 + Massively Overpaid + Software Developer Extraordinaire + + + 9,890 + 2-1990 + C1 + Massively Underpaid + Skydiving Instructor for the Environment + + + 9,891 + 2-2011 + A2 + Overpaid + Skydiving Instructor of Doom + + + 9,892 + 7-1999 + A1 + Massively Overpaid + Author Laureate + + + 9,893 + 5-2006 + C2 + Slave Labour + Historian of Cattle + + + 9,894 + 12-1995 + A1 + Massively Overpaid + Software Developer Trainer + + + 9,895 + 4-2009 + A2 + Overpaid + Assassin of Cattle + + + 9,896 + 6-2021 + C1 + Massively Underpaid + Philosopher Extraordinaire + + + 9,897 + 8-1994 + B2 + Underpaid + Vigilante for the Environment + + + 9,898 + 9-2017 + B2 + Underpaid + Philosopher Extraordinaire + + + 9,899 + 1-2011 + A1 + Massively Overpaid + Builder for Eternity + + + 9,900 + 11-1999 + B1 + Fairly Paid + Assassin Trainer + + + 9,901 + 9-2023 + A2 + Overpaid + Author of Doom + + + 9,902 + 10-2004 + B1 + Fairly Paid + Food Taster (Trainee) + + + 9,903 + 11-1993 + A1 + Massively Overpaid + Historian of Parties + + + 9,904 + 4-1995 + B1 + Fairly Paid + Software Developer for Schools + + + 9,905 + 6-2011 + B2 + Underpaid + Historian of Parties + + + 9,906 + 9-1993 + A1 + Massively Overpaid + Historian in Chief + + + 9,907 + 2-1997 + A1 + Massively Overpaid + Author of Cattle + + + 9,908 + 8-1994 + A1 + Massively Overpaid + Skydiving Instructor of Parties + + + 9,909 + 10-2014 + C1 + Massively Underpaid + Assassin of Parties + + + 9,910 + 3-2017 + C1 + Massively Underpaid + Philosopher for Schools + + + 9,911 + 6-2008 + B1 + Fairly Paid + Author of Parties + + + 9,912 + 7-2012 + B1 + Fairly Paid + Philosopher (Trainee) + + + 9,913 + 1-2018 + B2 + Underpaid + Author Trainer + + + 9,914 + 6-2005 + A2 + Overpaid + Sports Mascot for Eternity + + + 9,915 + 11-1998 + A2 + Overpaid + Sports Mascot Trainer + + + 9,916 + 11-2004 + C1 + Massively Underpaid + Builder for Eternity + + + 9,917 + 8-2006 + C2 + Slave Labour + Author in Chief + + + 9,918 + 11-1993 + B1 + Fairly Paid + Food Taster Extraordinaire + + + 9,919 + 4-2000 + A1 + Massively Overpaid + Sports Mascot Laureate + + + 9,920 + 6-2016 + C2 + Slave Labour + Philosopher of Doom + + + 9,921 + 1-2019 + C1 + Massively Underpaid + Author Laureate + + + 9,922 + 6-2001 + B1 + Fairly Paid + Skydiving Instructor Trainer + + + 9,923 + 9-2018 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 9,924 + 6-2023 + A2 + Overpaid + Sports Mascot for Eternity + + + 9,925 + 6-2002 + A2 + Overpaid + Assassin Laureate + + + 9,926 + 1-2009 + B2 + Underpaid + Sports Mascot of Parties + + + 9,927 + 9-2022 + C1 + Massively Underpaid + Author Laureate + + + 9,928 + 2-2003 + B2 + Underpaid + Historian for the Environment + + + 9,929 + 4-1997 + C2 + Slave Labour + Food Taster of Cattle + + + 9,930 + 11-2020 + B1 + Fairly Paid + Food Taster of Parties + + + 9,931 + 12-2007 + B2 + Underpaid + Philosopher of Cattle + + + 9,932 + 12-1993 + C1 + Massively Underpaid + Food Taster Extraordinaire + + + 9,933 + 11-1997 + B2 + Underpaid + Food Taster for Schools + + + 9,934 + 1-2011 + A2 + Overpaid + Sports Mascot (Trainee) + + + 9,935 + 2-2006 + B1 + Fairly Paid + Builder in Chief + + + 9,936 + 11-1991 + A1 + Massively Overpaid + Software Developer of Parties + + + 9,937 + 5-2010 + A1 + Massively Overpaid + Author Extraordinaire + + + 9,938 + 12-2000 + C2 + Slave Labour + Sports Mascot of Cattle + + + 9,939 + 2-2012 + A1 + Massively Overpaid + Builder Laureate + + + 9,940 + 10-1997 + A1 + Massively Overpaid + Builder Laureate + + + 9,941 + 4-2014 + B2 + Underpaid + Philosopher of Parties + + + 9,942 + 6-1997 + A1 + Massively Overpaid + Assassin (Trainee) + + + 9,943 + 6-2001 + C2 + Slave Labour + Vigilante of Parties + + + 9,944 + 12-1998 + C2 + Slave Labour + Historian of Doom + + + 9,945 + 1-1991 + C2 + Slave Labour + Builder Laureate + + + 9,946 + 10-1994 + B2 + Underpaid + Food Taster of Doom + + + 9,947 + 7-2018 + C1 + Massively Underpaid + Builder Trainer + + + 9,948 + 7-2001 + A1 + Massively Overpaid + Vigilante of Cattle + + + 9,949 + 12-1992 + C1 + Massively Underpaid + Vigilante for Schools + + + 9,950 + 11-2012 + C2 + Slave Labour + Author Trainer + + + 9,951 + 9-2016 + C2 + Slave Labour + Software Developer Laureate + + + 9,952 + 12-1993 + A1 + Massively Overpaid + Software Developer (Trainee) + + + 9,953 + 9-2001 + C1 + Massively Underpaid + Philosopher for Eternity + + + 9,954 + 5-2018 + C1 + Massively Underpaid + Vigilante of Doom + + + 9,955 + 6-2002 + B1 + Fairly Paid + Food Taster of Cattle + + + 9,956 + 1-2006 + A2 + Overpaid + Vigilante for the Environment + + + 9,957 + 2-1990 + B1 + Fairly Paid + Sports Mascot of Parties + + + 9,958 + 11-2013 + C2 + Slave Labour + Sports Mascot (Trainee) + + + 9,959 + 5-2019 + A1 + Massively Overpaid + Historian Trainer + + + 9,960 + 6-1993 + C1 + Massively Underpaid + Software Developer of Parties + + + 9,961 + 2-1992 + A2 + Overpaid + Philosopher Laureate + + + 9,962 + 6-1990 + C2 + Slave Labour + Software Developer of Doom + + + 9,963 + 2-2015 + A1 + Massively Overpaid + Sports Mascot for Schools + + + 9,964 + 11-2012 + A1 + Massively Overpaid + Sports Mascot of Doom + + + 9,965 + 9-2002 + C1 + Massively Underpaid + Author of Cattle + + + 9,966 + 3-2011 + C1 + Massively Underpaid + Skydiving Instructor Trainer + + + 9,967 + 7-2011 + B2 + Underpaid + Skydiving Instructor of Parties + + + 9,968 + 6-2017 + B1 + Fairly Paid + Historian (Trainee) + + + 9,969 + 4-2020 + C2 + Slave Labour + Vigilante of Cattle + + + 9,970 + 7-2015 + B1 + Fairly Paid + Food Taster (Trainee) + + + 9,971 + 9-2014 + B2 + Underpaid + Philosopher in Chief + + + 9,972 + 12-2018 + A2 + Overpaid + Historian of Cattle + + + 9,973 + 3-1993 + B2 + Underpaid + Philosopher (Trainee) + + + 9,974 + 12-1994 + B2 + Underpaid + Assassin for Eternity + + + 9,975 + 2-2012 + C1 + Massively Underpaid + Vigilante for the Environment + + + 9,976 + 10-1993 + A1 + Massively Overpaid + Author of Doom + + + 9,977 + 7-2017 + B2 + Underpaid + Historian for the Environment + + + 9,978 + 8-2017 + C2 + Slave Labour + Historian for the Environment + + + 9,979 + 8-2013 + C1 + Massively Underpaid + Assassin Extraordinaire + + + 9,980 + 6-2004 + C1 + Massively Underpaid + Builder for the Environment + + + 9,981 + 5-1994 + A2 + Overpaid + Author for Eternity + + + 9,982 + 3-2001 + C2 + Slave Labour + Software Developer for Schools + + + 9,983 + 8-2007 + C2 + Slave Labour + Skydiving Instructor in Chief + + + 9,984 + 10-2003 + A1 + Massively Overpaid + Philosopher in Chief + + + 9,985 + 12-1995 + A1 + Massively Overpaid + Historian of Parties + + + 9,986 + 10-1991 + A1 + Massively Overpaid + Vigilante for the Environment + + + 9,987 + 12-2016 + B1 + Fairly Paid + Builder of Doom + + + 9,988 + 2-1999 + A2 + Overpaid + Historian Trainer + + + 9,989 + 4-1994 + A1 + Massively Overpaid + Food Taster for Eternity + + + 9,990 + 2-2007 + C2 + Slave Labour + Builder for the Environment + + + 9,991 + 6-2001 + A1 + Massively Overpaid + Vigilante for the Environment + + + 9,992 + 12-2013 + C2 + Slave Labour + Historian for Schools + + + 9,993 + 12-2004 + B2 + Underpaid + Skydiving Instructor of Parties + + + 9,994 + 8-2000 + B1 + Fairly Paid + Author of Cattle + + + 9,995 + 11-1990 + B2 + Underpaid + Sports Mascot for the Environment + + + 9,996 + 4-2005 + B1 + Fairly Paid + Philosopher (Trainee) + + + 9,997 + 7-2010 + C2 + Slave Labour + Software Developer for the Environment + + + 9,998 + 6-2013 + B2 + Underpaid + Assassin of Doom + + + 9,999 + 8-2011 + C2 + Slave Labour + Food Taster in Chief + + + 10,000 + 2-2013 + B1 + Fairly Paid + Assassin (Trainee) + + \ No newline at end of file diff --git a/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderWithGlacierStorageTest.scala b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderWithGlacierStorageTest.scala new file mode 100644 index 000000000..8070c28d0 --- /dev/null +++ b/kafka-connect-aws-s3/src/it/scala/io/lenses/streamreactor/connect/aws/s3/source/S3SourceTaskXmlReaderWithGlacierStorageTest.scala @@ -0,0 +1,124 @@ +package io.lenses.streamreactor.connect.aws.s3.source + +import cats.implicits._ +import io.lenses.streamreactor.connect.aws.s3.storage.AwsS3StorageInterface +import io.lenses.streamreactor.connect.aws.s3.utils.S3ProxyContainerTest +import io.lenses.streamreactor.connect.cloud.common.model.UploadableFile +import io.lenses.streamreactor.connect.cloud.common.source.config.CloudSourceSettingsKeys +import org.apache.kafka.connect.source.SourceRecord +import org.scalatest.EitherValues +import org.scalatest.flatspec.AnyFlatSpecLike +import org.scalatest.matchers.should.Matchers +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.model.StorageClass + +import java.io.File +import scala.concurrent.duration.DurationInt +import scala.jdk.CollectionConverters.MapHasAsJava +import scala.util.Try +import scala.xml.XML + +class S3SourceTaskXmlReaderWithGlacierStorageTest + extends S3ProxyContainerTest + with AnyFlatSpecLike + with Matchers + with EitherValues + with CloudSourceSettingsKeys { + + def DefaultProps: Map[String, String] = defaultProps + ( + SOURCE_PARTITION_SEARCH_INTERVAL_MILLIS -> "1000", + ) + + val PrefixName = "streamReactorBackups" + val TopicName = "myTopic" + + override def cleanUp(): Unit = () + + override def setUpTestData(storageInterface: AwsS3StorageInterface): Either[Throwable, Unit] = { + val glacierFile = new File(getClass.getResource("/xml/employeedata0000-glacier.xml").toURI) + val result = for { + _ <- createClient().map { c => + c.putObject( + PutObjectRequest.builder() + .bucket(BucketName) + .key("streamReactorBackups/xml/employeedata0000-glacier.xml") + .contentLength(glacierFile.length()) + .storageClass(StorageClass.GLACIER) + .build(), + glacierFile.toPath, + ) + () + } + res <- Seq("/xml/employeedata0001.xml", "/xml/employeedata0002.xml").traverse { f => + val file = new File(getClass.getResource(f).toURI) + storageInterface.uploadFile(UploadableFile(file), BucketName, "streamReactorBackups" + f) + }.leftMap(e => new RuntimeException(e.message())) + } yield res + result should be(Right(Vector((), ()))) + ().asRight + } + + "task" should "extract from xml files when glacier storage is used" in { + + val task = new S3SourceTask() + + val props = (defaultProps ++ Map( + "connect.s3.kcql" -> s"insert into $TopicName select * from $BucketName:$PrefixName/xml STOREAS `TEXT` LIMIT 1000 PROPERTIES ( 'read.text.mode'='StartEndTag' , 'read.text.start.tag'='' , 'read.text.end.tag'='' )", + "connect.s3.source.partition.search.recurse.levels" -> "0", + )).asJava + + task.start(props) + + try { + val sourceRecords = SourceRecordsLoop.loop(task, 10.seconds.toMillis, 20000).value + val sourceRecordsMopUp = task.poll() + sourceRecordsMopUp should be(empty) + + val firstEmployee = Employee.fromSourceRecord(sourceRecords.head) + firstEmployee.value should be( + Employee( + "1", + "3-1991", + "B1", + "Fairly Paid", + "Skydiving Instructor Extraordinaire", + ), + ) + + val lastEmployee = Employee.fromSourceRecord(sourceRecords.last) + lastEmployee.value should be( + Employee( + "20,000", + "1-2011", + "C1", + "Massively Underpaid", + "Skydiving Instructor for Schools", + ), + ) + } finally { + task.stop() + } + + } + + case class Employee(number: String, startMonth: String, bracket: String, bracketDescription: String, category: String) + + object Employee { + + def fromSourceRecord(sourceRecord: SourceRecord): Either[Throwable, Employee] = + sourceRecord.value() match { + case empXml: String => + Try { + val xml = XML.loadString(empXml) + Employee( + (xml \ "Number").text, + (xml \ "StartMonth").text, + (xml \ "Bracket").text, + (xml \ "BracketDescription").text, + (xml \ "Category").text, + ) + }.toEither + } + } + +} diff --git a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/storage/AwsS3StorageInterface.scala b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/storage/AwsS3StorageInterface.scala index c9981c5a4..757d6024f 100644 --- a/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/storage/AwsS3StorageInterface.scala +++ b/kafka-connect-aws-s3/src/main/scala/io/lenses/streamreactor/connect/aws/s3/storage/AwsS3StorageInterface.scala @@ -45,8 +45,11 @@ import scala.util.Failure import scala.util.Success import scala.util.Try -class AwsS3StorageInterface(connectorTaskId: ConnectorTaskId, s3Client: S3Client, batchDelete: Boolean) - extends StorageInterface[S3FileMetadata] +class AwsS3StorageInterface( + connectorTaskId: ConnectorTaskId, + s3Client: S3Client, + batchDelete: Boolean, +) extends StorageInterface[S3FileMetadata] with LazyLogging { override def list( @@ -66,14 +69,26 @@ class AwsS3StorageInterface(connectorTaskId: ConnectorTaskId, s3Client: S3Client lastFile.foreach(lf => builder.startAfter(lf.file)) val listObjectsV2Response = s3Client.listObjectsV2(builder.build()) + val objects = listObjectsV2Response + .contents() + .asScala + .filterNot { o => + val filteredOut = o.storageClass() == ObjectStorageClass.GLACIER || + o.storageClass() == ObjectStorageClass.DEEP_ARCHIVE || + o.storageClass() == ObjectStorageClass.GLACIER_IR + if (filteredOut) { + logger.info( + s"[${connectorTaskId.show}] Skipping object ${o.key()} with storage class [${o.storageClass()}].", + ) + } + filteredOut + } + .map(o => S3FileMetadata(o.key(), o.lastModified())) + processAsKey( bucket, prefix, - listObjectsV2Response - .contents() - .asScala - .map(o => S3FileMetadata(o.key(), o.lastModified())) - .toSeq, + objects.toSeq, ) }.toEither.leftMap { diff --git a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala index e134bdee3..83212c746 100644 --- a/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala +++ b/kafka-connect-gcp-storage/src/test/scala/io/lenses/streamreactor/connect/gcp/storage/source/config/GCPStorageSourceConfigTest.scala @@ -21,7 +21,8 @@ import io.lenses.streamreactor.connect.gcp.common.auth.mode.CredentialsAuthMode import io.lenses.streamreactor.connect.gcp.storage.model.location.GCPStorageLocationValidator import org.apache.kafka.common.config.ConfigException import org.apache.kafka.common.config.types.Password -import org.scalatest.{EitherValues, OptionValues} +import org.scalatest.EitherValues +import org.scalatest.OptionValues import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers._