From ea7002ec330e9ada4e2e0f417b67f4ace94091b2 Mon Sep 17 00:00:00 2001 From: Jenny Brown <85510829+airbyte-jenny@users.noreply.github.com> Date: Mon, 28 Jun 2021 12:53:23 -0500 Subject: [PATCH] Add paged results to Job History retrieval (#4323) * Add paged results to job history retrieval. * Make job histories come back in a sort order controlled by the sql query * Increased default job history to show, to make better defaults for UI before paging * Code review cleanup, constants. * Update airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/DefaultJobPersistence.java Co-authored-by: Sherif A. Nada * Update airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/DefaultJobPersistence.java Co-authored-by: Sherif A. Nada * Use a Pagination object for pagesize and offset * NPE fix when pagination is not sent with the request and it falls back to defaults Co-authored-by: Sherif A. Nada --- airbyte-api/src/main/openapi/config.yaml | 14 ++- .../persistence/DefaultJobPersistence.java | 93 +++++++++---------- .../scheduler/persistence/JobPersistence.java | 4 +- .../DefaultJobPersistenceTest.java | 45 ++++++++- .../server/handlers/JobHistoryHandler.java | 32 +++---- .../handlers/JobHistoryHandlerTest.java | 22 ++++- .../api/generated-api-html/index.html | 14 ++- 7 files changed, 144 insertions(+), 80 deletions(-) diff --git a/airbyte-api/src/main/openapi/config.yaml b/airbyte-api/src/main/openapi/config.yaml index 5772552d9fb..849443c327c 100644 --- a/airbyte-api/src/main/openapi/config.yaml +++ b/airbyte-api/src/main/openapi/config.yaml @@ -4,13 +4,13 @@ info: Airbyte Configuration API [https://airbyte.io](https://airbyte.io). - This API is a collection HTTP RPC-style methods. While it is not a REST API, those familiar with REST should find the conventions of this API recognizable. + This API is a collection of HTTP RPC-style methods. While it is not a REST API, those familiar with REST should find the conventions of this API recognizable. Here are some conventions that this API follows: * All endpoints are http POST methods. * All endpoints accept data via `application/json` request bodies. The API does not accept any data via query params. * The naming convention for endpoints is: localhost:8000/{VERSION}/{METHOD_FAMILY}/{METHOD_NAME} e.g. `localhost:8000/v1/connections/create`. - * For all `update` method, the whole object must be passed in, even the fields that did not change. + * For all `update` methods, the whole object must be passed in, even the fields that did not change. Change Management: * The major version of the API endpoint can be determined / specified in the URL `localhost:8080/v1/connections/create` @@ -2387,6 +2387,9 @@ components: $ref: "#/components/schemas/JobConfigType" configId: type: string + pagination: + type: object + $ref: "#/components/schemas/Pagination" JobIdRequestBody: type: object required: @@ -2537,6 +2540,13 @@ components: type: boolean logs: $ref: "#/components/schemas/LogRead" + Pagination: + type: object + properties: + pageSize: + type: integer + rowOffset: + type: integer # Health HealthCheckRead: type: object diff --git a/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/DefaultJobPersistence.java b/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/DefaultJobPersistence.java index 5ab62dac046..8c1085a6011 100644 --- a/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/DefaultJobPersistence.java +++ b/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/DefaultJobPersistence.java @@ -55,8 +55,6 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -110,6 +108,8 @@ public class DefaultJobPersistence implements JobPersistence { + "FROM jobs LEFT OUTER JOIN attempts ON jobs.id = attempts.job_id "; private static final String AIRBYTE_METADATA_TABLE = "airbyte_metadata"; + public static final String ORDER_BY_JOB_TIME_ATTEMPT_TIME = + "ORDER BY jobs.created_at DESC, jobs.id DESC, attempts.created_at ASC, attempts.id ASC "; private final ExceptionWrappingDatabase database; private final Supplier timeSupplier; @@ -296,14 +296,19 @@ public class DefaultJobPersistence implements JobPersistence { } @Override - public List listJobs(ConfigType configType, String configId) throws IOException { + public List listJobs(ConfigType configType, String configId, int pagesize, int offset) throws IOException { + return listJobs(Set.of(configType), configId, pagesize, offset); + } + + @Override + public List listJobs(Set configTypes, String configId, int pagesize, int offset) throws IOException { return database.query(ctx -> getJobsFromResult(ctx.fetch( BASE_JOB_SELECT_AND_JOIN + "WHERE " + - "CAST(config_type AS VARCHAR) = ? AND " + - "scope = ? " + - "ORDER BY jobs.created_at DESC", - Sqls.toSqlName(configType), - configId))); + "CAST(config_type AS VARCHAR) in " + Sqls.toSqlInFragment(configTypes) + " " + + "AND scope = ? " + + ORDER_BY_JOB_TIME_ATTEMPT_TIME + + "LIMIT ? OFFSET ?", + configId, pagesize, offset))); } @Override @@ -317,7 +322,7 @@ public class DefaultJobPersistence implements JobPersistence { .fetch(BASE_JOB_SELECT_AND_JOIN + "WHERE " + "CAST(config_type AS VARCHAR) IN " + Sqls.toSqlInFragment(configTypes) + " AND " + "CAST(jobs.status AS VARCHAR) = ? " + - "ORDER BY jobs.created_at DESC", + ORDER_BY_JOB_TIME_ATTEMPT_TIME, Sqls.toSqlName(status)))); } @@ -376,46 +381,38 @@ public class DefaultJobPersistence implements JobPersistence { } private static List getJobsFromResult(Result result) { - final Map> jobIdToAttempts = result.stream().collect(Collectors.groupingBy(r -> r.getValue("job_id", Long.class))); + // keeps results strictly in order so the sql query controls the sort + List jobs = new ArrayList(); + Job currentJob = null; + for (Record entry : result) { + if (currentJob == null || currentJob.getId() != entry.get("job_id", Long.class)) { + currentJob = new Job(entry.get("job_id", Long.class), + Enums.toEnum(entry.get("config_type", String.class), ConfigType.class).orElseThrow(), + entry.get("scope", String.class), + Jsons.deserialize(entry.get("config", String.class), JobConfig.class), + new ArrayList(), + JobStatus.valueOf(entry.get("job_status", String.class).toUpperCase()), + Optional.ofNullable(entry.get("job_started_at")).map(value -> getEpoch(entry, "started_at")).orElse(null), + getEpoch(entry, "job_created_at"), + getEpoch(entry, "job_updated_at")); + jobs.add(currentJob); + } + if (entry.getValue("attempt_number") != null) { + currentJob.getAttempts().add(new Attempt( + entry.get("attempt_number", Long.class), + entry.get("job_id", Long.class), + Path.of(entry.get("log_path", String.class)), + entry.get("attempt_output", String.class) == null ? null : Jsons.deserialize(entry.get("attempt_output", String.class), JobOutput.class), + Enums.toEnum(entry.get("attempt_status", String.class), AttemptStatus.class).orElseThrow(), + getEpoch(entry, "attempt_created_at"), + getEpoch(entry, "attempt_updated_at"), + Optional.ofNullable(entry.get("attempt_ended_at")) + .map(value -> getEpoch(entry, "attempt_ended_at")) + .orElse(null))); + } + } - return jobIdToAttempts.values().stream() - .map(records -> { - final Record jobEntry = records.get(0); - - List attempts = Collections.emptyList(); - if (jobEntry.get("attempt_number") != null) { - attempts = records.stream().map(attemptRecord -> { - final String outputDb = attemptRecord.get("attempt_output", String.class); - final JobOutput output = outputDb == null ? null : Jsons.deserialize(outputDb, JobOutput.class); - return new Attempt( - attemptRecord.get("attempt_number", Long.class), - attemptRecord.get("job_id", Long.class), - Path.of(attemptRecord.get("log_path", String.class)), - output, - Enums.toEnum(attemptRecord.get("attempt_status", String.class), AttemptStatus.class).orElseThrow(), - getEpoch(attemptRecord, "attempt_created_at"), - getEpoch(attemptRecord, "attempt_updated_at"), - Optional.ofNullable(attemptRecord.get("attempt_ended_at")) - .map(value -> getEpoch(attemptRecord, "attempt_ended_at")) - .orElse(null)); - }) - .sorted(Comparator.comparingLong(Attempt::getId)) - .collect(Collectors.toList()); - } - final JobConfig jobConfig = Jsons.deserialize(jobEntry.get("config", String.class), JobConfig.class); - return new Job( - jobEntry.get("job_id", Long.class), - Enums.toEnum(jobEntry.get("config_type", String.class), ConfigType.class).orElseThrow(), - jobEntry.get("scope", String.class), - jobConfig, - attempts, - JobStatus.valueOf(jobEntry.get("job_status", String.class).toUpperCase()), - Optional.ofNullable(jobEntry.get("job_started_at")).map(value -> getEpoch(jobEntry, "started_at")).orElse(null), - getEpoch(jobEntry, "job_created_at"), - getEpoch(jobEntry, "job_updated_at")); - }) - .sorted(Comparator.comparingLong(Job::getCreatedAtInSecond).reversed()) - .collect(Collectors.toList()); + return jobs; } @VisibleForTesting diff --git a/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/JobPersistence.java b/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/JobPersistence.java index a8ea7a87749..5ed04b1ed4c 100644 --- a/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/JobPersistence.java +++ b/airbyte-scheduler/persistence/src/main/java/io/airbyte/scheduler/persistence/JobPersistence.java @@ -128,7 +128,9 @@ public interface JobPersistence { * @return lists job in descending order by created_at * @throws IOException - what you do when you IO */ - List listJobs(JobConfig.ConfigType configType, String configId) throws IOException; + List listJobs(Set configTypes, String configId, int limit, int offset) throws IOException; + + List listJobs(JobConfig.ConfigType configType, String configId, int limit, int offset) throws IOException; List listJobsWithStatus(JobStatus status) throws IOException; diff --git a/airbyte-scheduler/persistence/src/test/java/io/airbyte/scheduler/persistence/DefaultJobPersistenceTest.java b/airbyte-scheduler/persistence/src/test/java/io/airbyte/scheduler/persistence/DefaultJobPersistenceTest.java index 82cd93eedc0..df1637de96d 100644 --- a/airbyte-scheduler/persistence/src/test/java/io/airbyte/scheduler/persistence/DefaultJobPersistenceTest.java +++ b/airbyte-scheduler/persistence/src/test/java/io/airbyte/scheduler/persistence/DefaultJobPersistenceTest.java @@ -58,6 +58,7 @@ import java.io.IOException; import java.nio.file.Path; import java.sql.SQLException; import java.time.Instant; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -288,7 +289,7 @@ class DefaultJobPersistenceTest { jobPersistence.importDatabase("test", outputStreams); - final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString()); + final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString(), 9999, 0); final Job actual = actualList.get(0); final Job expected = createJob( @@ -789,15 +790,49 @@ class DefaultJobPersistenceTest { } @Nested - @DisplayName("When listing jobs") + @DisplayName("When listing jobs, use paged results") class ListJobs { + @Test + @DisplayName("Should return the correct page of results with multiple pages of history") + public void testListJobsByPage() throws IOException { + List ids = new ArrayList(); + for (int i = 0; i < 100; i++) { + final long jobId = jobPersistence.enqueueJob(CONNECTION_ID.toString(), SPEC_JOB_CONFIG).orElseThrow(); + ids.add(jobId); + } + int pagesize = 10; + int offset = 3; + + final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString(), pagesize, offset); + assertEquals(actualList.size(), pagesize); + assertEquals(ids.get(ids.size() - 1 - offset), actualList.get(0).getId()); + } + + @Test + @DisplayName("Should return the results in the correct sort order") + public void testListJobsSortsDescending() throws IOException { + List ids = new ArrayList(); + for (int i = 0; i < 100; i++) { + // These have strictly the same created_at due to the setup() above, so should come back sorted by + // id desc instead. + final long jobId = jobPersistence.enqueueJob(CONNECTION_ID.toString(), SPEC_JOB_CONFIG).orElseThrow(); + ids.add(jobId); + } + int pagesize = 200; + int offset = 0; + final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString(), pagesize, offset); + for (int i = 0; i < 100; i++) { + assertEquals(ids.get(ids.size() - (i + 1)), actualList.get(i).getId(), "Job ids should have been in order but weren't."); + } + } + @Test @DisplayName("Should list all jobs") public void testListJobs() throws IOException { final long jobId = jobPersistence.enqueueJob(SCOPE, SPEC_JOB_CONFIG).orElseThrow(); - final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString()); + final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString(), 9999, 0); final Job actual = actualList.get(0); final Job expected = createJob(jobId, SPEC_JOB_CONFIG, JobStatus.PENDING, Collections.emptyList(), NOW.getEpochSecond()); @@ -819,7 +854,7 @@ class DefaultJobPersistenceTest { jobPersistence.succeedAttempt(jobId, attemptNumber1); - final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString()); + final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString(), 9999, 0); final Job actual = actualList.get(0); final Job expected = createJob( @@ -854,7 +889,7 @@ class DefaultJobPersistenceTest { final var job2Attempt1 = jobPersistence.createAttempt(jobId2, job2Attempt1LogPath); jobPersistence.succeedAttempt(jobId2, job2Attempt1); - final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString()); + final List actualList = jobPersistence.listJobs(SPEC_JOB_CONFIG.getConfigType(), CONNECTION_ID.toString(), 9999, 0); assertEquals(2, actualList.size()); assertEquals(jobId2, actualList.get(0).getId()); diff --git a/airbyte-server/src/main/java/io/airbyte/server/handlers/JobHistoryHandler.java b/airbyte-server/src/main/java/io/airbyte/server/handlers/JobHistoryHandler.java index 0f7629edcbb..963c0c5f732 100644 --- a/airbyte-server/src/main/java/io/airbyte/server/handlers/JobHistoryHandler.java +++ b/airbyte-server/src/main/java/io/airbyte/server/handlers/JobHistoryHandler.java @@ -25,26 +25,25 @@ package io.airbyte.server.handlers; import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; import io.airbyte.api.model.JobIdRequestBody; import io.airbyte.api.model.JobInfoRead; import io.airbyte.api.model.JobListRequestBody; import io.airbyte.api.model.JobReadList; import io.airbyte.api.model.JobWithAttemptsRead; import io.airbyte.commons.enums.Enums; -import io.airbyte.commons.stream.MoreStreams; import io.airbyte.config.JobConfig; +import io.airbyte.config.JobConfig.ConfigType; import io.airbyte.scheduler.models.Job; import io.airbyte.scheduler.persistence.JobPersistence; import io.airbyte.server.converters.JobConverter; import java.io.IOException; -import java.util.Comparator; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; public class JobHistoryHandler { + public static final int DEFAULT_PAGE_SIZE = 200; private final JobPersistence jobPersistence; public JobHistoryHandler(JobPersistence jobPersistence) { @@ -56,24 +55,21 @@ public class JobHistoryHandler { Preconditions.checkNotNull(request.getConfigTypes(), "configType cannot be null."); Preconditions.checkState(!request.getConfigTypes().isEmpty(), "Must include at least one configType."); - final List configTypes = request.getConfigTypes() + final Set configTypes = request.getConfigTypes() .stream() .map(type -> Enums.convertTo(type, JobConfig.ConfigType.class)) - .collect(Collectors.toList()); + .collect(Collectors.toSet()); final String configId = request.getConfigId(); - // get jobs for each type and merge them into a single list sorted by created at. - Iterable jobReads = ImmutableList.of(); - for (final JobConfig.ConfigType configType : configTypes) { - final List jobReadsForType = jobPersistence.listJobs(configType, configId) - .stream() - .map(JobConverter::getJobWithAttemptsRead) - .collect(Collectors.toList()); - - jobReads = Iterables.mergeSorted(ImmutableList.of(jobReads, jobReadsForType), Comparator.comparing(v -> v.getJob().getCreatedAt())); - } - - return new JobReadList().jobs(MoreStreams.toStream(jobReads).collect(Collectors.toList())); + final List jobReads = jobPersistence.listJobs(configTypes, + configId, + (request.getPagination() != null && request.getPagination().getPageSize() != null) ? request.getPagination().getPageSize() + : DEFAULT_PAGE_SIZE, + (request.getPagination() != null && request.getPagination().getRowOffset() != null) ? request.getPagination().getRowOffset() : 0) + .stream() + .map(JobConverter::getJobWithAttemptsRead) + .collect(Collectors.toList()); + return new JobReadList().jobs(jobReads); } public JobInfoRead getJobInfo(JobIdRequestBody jobIdRequestBody) throws IOException { diff --git a/airbyte-server/src/test/java/io/airbyte/server/handlers/JobHistoryHandlerTest.java b/airbyte-server/src/test/java/io/airbyte/server/handlers/JobHistoryHandlerTest.java index 231c69a3fa0..216b26d9e55 100644 --- a/airbyte-server/src/test/java/io/airbyte/server/handlers/JobHistoryHandlerTest.java +++ b/airbyte-server/src/test/java/io/airbyte/server/handlers/JobHistoryHandlerTest.java @@ -40,6 +40,7 @@ import io.airbyte.api.model.JobRead; import io.airbyte.api.model.JobReadList; import io.airbyte.api.model.JobWithAttemptsRead; import io.airbyte.api.model.LogRead; +import io.airbyte.api.model.Pagination; import io.airbyte.commons.enums.Enums; import io.airbyte.config.JobCheckConnectionConfig; import io.airbyte.config.JobConfig; @@ -54,6 +55,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; @@ -130,6 +132,8 @@ public class JobHistoryHandlerTest { @DisplayName("Should return jobs with/without attempts in descending order") public void testListJobs() throws IOException { final var successfulJob = testJob; + final int pagesize = 25; + final int rowOffset = 0; final var jobId2 = JOB_ID + 100; final var createdAt2 = CREATED_AT + 1000; @@ -137,11 +141,13 @@ public class JobHistoryHandlerTest { new Job(jobId2, JOB_CONFIG.getConfigType(), JOB_CONFIG_ID, JOB_CONFIG, Collections.emptyList(), JobStatus.PENDING, null, createdAt2, createdAt2); - when(jobPersistence.listJobs(CONFIG_TYPE, JOB_CONFIG_ID)).thenReturn(List.of(latestJobNoAttempt, successfulJob)); + when(jobPersistence.listJobs(Set.of(Enums.convertTo(CONFIG_TYPE_FOR_API, ConfigType.class)), JOB_CONFIG_ID, pagesize, rowOffset)) + .thenReturn(List.of(latestJobNoAttempt, successfulJob)); final var requestBody = new JobListRequestBody() .configTypes(Collections.singletonList(CONFIG_TYPE_FOR_API)) - .configId(JOB_CONFIG_ID); + .configId(JOB_CONFIG_ID) + .pagination(new Pagination().pageSize(pagesize).rowOffset(rowOffset)); final var jobReadList = jobHistoryHandler.listJobsFor(requestBody); final var successfulJobWithAttemptRead = new JobWithAttemptsRead().job(toJobInfo(successfulJob)).attempts(ImmutableList.of(toAttemptRead( @@ -156,6 +162,8 @@ public class JobHistoryHandlerTest { @DisplayName("Should return jobs in descending order regardless of type") public void testListJobsFor() throws IOException { final var firstJob = testJob; + final int pagesize = 25; + final int rowOffset = 0; final var secondJobId = JOB_ID + 100; final var createdAt2 = CREATED_AT + 1000; @@ -163,16 +171,22 @@ public class JobHistoryHandlerTest { final var secondJob = new Job(secondJobId, ConfigType.DISCOVER_SCHEMA, JOB_CONFIG_ID, JOB_CONFIG, ImmutableList.of(secondJobAttempt), JobStatus.SUCCEEDED, null, createdAt2, createdAt2); + final Set configTypes = Set.of( + Enums.convertTo(CONFIG_TYPE_FOR_API, ConfigType.class), + Enums.convertTo(JobConfigType.SYNC, ConfigType.class), + Enums.convertTo(JobConfigType.DISCOVER_SCHEMA, ConfigType.class)); + final var latestJobId = secondJobId + 100; final var createdAt3 = createdAt2 + 1000; final var latestJob = new Job(latestJobId, ConfigType.SYNC, JOB_CONFIG_ID, JOB_CONFIG, Collections.emptyList(), JobStatus.PENDING, null, createdAt3, createdAt3); - when(jobPersistence.listJobs(CONFIG_TYPE, JOB_CONFIG_ID)).thenReturn(List.of(latestJob, secondJob, firstJob)); + when(jobPersistence.listJobs(configTypes, JOB_CONFIG_ID, pagesize, rowOffset)).thenReturn(List.of(latestJob, secondJob, firstJob)); final JobListRequestBody requestBody = new JobListRequestBody() .configTypes(List.of(CONFIG_TYPE_FOR_API, JobConfigType.SYNC, JobConfigType.DISCOVER_SCHEMA)) - .configId(JOB_CONFIG_ID); + .configId(JOB_CONFIG_ID) + .pagination(new Pagination().pageSize(pagesize).rowOffset(rowOffset)); final JobReadList jobReadList = jobHistoryHandler.listJobsFor(requestBody); final var firstJobWithAttemptRead = diff --git a/docs/reference/api/generated-api-html/index.html b/docs/reference/api/generated-api-html/index.html index 70be76a39da..098f6f750f3 100644 --- a/docs/reference/api/generated-api-html/index.html +++ b/docs/reference/api/generated-api-html/index.html @@ -182,13 +182,13 @@ font-style: italic;

Airbyte Configuration API

Airbyte Configuration API https://airbyte.io.

-

This API is a collection HTTP RPC-style methods. While it is not a REST API, those familiar with REST should find the conventions of this API recognizable.

+

This API is a collection of HTTP RPC-style methods. While it is not a REST API, those familiar with REST should find the conventions of this API recognizable.

Here are some conventions that this API follows:

  • All endpoints are http POST methods.
  • All endpoints accept data via application/json request bodies. The API does not accept any data via query params.
  • The naming convention for endpoints is: localhost:8000/{VERSION}/{METHOD_FAMILY}/{METHOD_NAME} e.g. localhost:8000/v1/connections/create.
  • -
  • For all update method, the whole object must be passed in, even the fields that did not change.
  • +
  • For all update methods, the whole object must be passed in, even the fields that did not change.

Change Management:

@@ -5834,6 +5836,14 @@ font-style: italic;
+
+

Pagination - Up

+
+
+
pageSize (optional)
+
rowOffset (optional)
+
+