Skip to content

Implement Job Service control loop for stream ingestion jobs - #1140

Closed
tsotnet wants to merge 7 commits into
feast-dev:masterfrom
tsotnet:jobservice-control-loop
Closed

Implement Job Service control loop for stream ingestion jobs#1140
tsotnet wants to merge 7 commits into
feast-dev:masterfrom
tsotnet:jobservice-control-loop

Conversation

@tsotnet

@tsotnet tsotnet commented Nov 5, 2020

Copy link
Copy Markdown
Collaborator

What this PR does / why we need it:
In this PR we implement the control loop in Job Service, which runs in the background thread and ensures that stream ingestion jobs are always running. It does this by generating a deterministic hash for each stream ingestion job and ensuring that all jobs with these hashes are in STARTING or RUNNING states, and also by ensuring that all other Spark jobs are terminated.
The implementation includes standalone & EMR modes. I haven't added the implementation for dataproc yet.

Does this PR introduce a user-facing change?:

You can now enable Feast Job Service's control loop by setting `FEAST_JOB_SERVICE_ENABLE_CONTROL_LOOP` env variable to `True`. This will start a background thread in Job Service, ensuring that all stream ingestion jobs are automatically running. This mean you won't have to call `client.start_stream_to_online_ingestion` manually again.

@feast-ci-bot

Copy link
Copy Markdown
Collaborator

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: tsotnet
To complete the pull request process, please assign pyalex
You can assign the PR to them by writing /assign @pyalex in a comment when ready.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@tsotnet tsotnet added kind/feature New feature or request and removed size/L labels Nov 5, 2020
@tsotnet
tsotnet force-pushed the jobservice-control-loop branch from c23f09a to 41512ed Compare November 5, 2020 09:04
Comment thread sdk/python/feast/job_service.py Outdated
Comment thread sdk/python/feast/job_service.py Outdated
Comment thread sdk/python/feast/pyspark/abc.py Outdated
Comment thread sdk/python/feast/job_service.py Outdated
Comment thread sdk/python/feast/job_service.py Outdated
Comment thread sdk/python/feast/job_service.py Outdated
Comment thread sdk/python/feast/job_service.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This log is a bit vague? What do I as a a Feast admin understand from this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, why is JOB_CACHE all caps?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a global constant variable (in a sense that we assign value to it once and never change the reference). Do you have a different naming convention for such variables?

Comment thread sdk/python/feast/job_service.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So how is this client different from self.client?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't want to use the same object from 2 different threads, and it didn't look like having 2 different client objects added a lot of overhead.

Comment thread sdk/python/feast/job_service.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would expected_job_hashes be a better name?

Comment thread sdk/python/feast/job_service.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try and avoid using the word metadata since its typically used when people cant think of a good name. Can we be more descriptive with this variable? job_hash_to_table_references?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about job_hash_to_table_refs to make it little shorter?

Comment thread sdk/python/feast/job_service.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this run_control_loop function can be written a little bit cleaner by pulling out some parts of these deeply nested loops into their own private methods

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as another 2c, at least until this becomes too unwieldy, I would rather err on the side of having more logic inline rather than extracting it into helper functions here (see this )

@tsotnet

tsotnet commented Nov 5, 2020

Copy link
Copy Markdown
Collaborator Author

/test test-end-to-end

@tsotnet

tsotnet commented Nov 5, 2020

Copy link
Copy Markdown
Collaborator Author

/test DCO

tsotnet and others added 4 commits November 5, 2020 22:41
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
Co-authored-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
Co-authored-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
…small fixes

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
@tsotnet
tsotnet force-pushed the jobservice-control-loop branch from 6503931 to d85115a Compare November 5, 2020 18:42
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
Comment thread sdk/python/feast/job_service.py Outdated
def __init__(self, client):
self.client = client

def _job_to_proto(self, spark_job: SparkJob) -> JobProto:

@woop woop Nov 6, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why is _job_to_proto a method and not a function? or better yet, why not to_proto() on the class?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That wasn't written by me, but I can change it if necessary. @oavdeev any thoughts / reasons not to change this?

p.s. I don't like this becoming a public function on SparkJob because the users will see it, and it's not a publicly facing functionality. I think a private function on SparkJob would be better.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it could be a standalone function too. I didn't want to put it on SparkJob to keep it a pure interface. Besides, it is only used by JobServiceServicer.

@woop woop Nov 6, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to put it on SparkJob to keep it a pure interface

Wouldn't it still be a pure interface? I mean we don't have to implement to_proto() on the ABC, but only the children. I don't feel super strongly either way, but I dont think it should be a method.

Comment thread sdk/python/feast/job_service.py Outdated
jobs_by_hash = list_jobs_by_hash(self.client, include_terminated=False)
if job_hash not in jobs_by_hash:
raise RuntimeError(
"Feast Job Service has control loop enabled, but couldn't find the existing stream ingestion job for the given FeatureTable"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use lower case for concepts as a rule of thumb? feature table.

@woop woop Nov 6, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, are we really going to raise an exception if the job doesn't exist when they run a start method? Doesn't that create a race condition?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you describe the race condition? The idea is that if the job doesn't already exist and the control loop is working in the background, it'll probably start this job soon. So here we don't start the spark job, hence no race condition. We would have the race condition if we tried to start the stream ingestion job here, since the control loop could also be trying to do the same at the same time.

Decided to throw an exception instead of returning response with empty job_id, because even if we return the response the job.get_status() and other methods will fail anyways, since job_id is empty. And that error will be more difficult to debug to users than the explicit exception.

request.table_name, request.project
)

if self.client._config.getboolean(CONFIG_JOB_SERVICE_ENABLE_CONTROL_LOOP):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tsotnet it seems like we can take two approaches with the whole job service structure.

  1. A thin wrapper on the Feast Client. Don't access internal methods directly and maintain abstraction. Don't duplicate logic in two places (inside and outside the client).
  2. Provide a shared library as the backend for both the client and service. So the service doesnt have any client instances, but it creates its own config, launches its own jobs, etc. It still uses the same methods as the client, but it just doesnt call the client.

It seems like the current approach is a mix of these two. We pull in the client for config, and then we access internal methods directly. Are we concerned about the internal dependencies we are creating here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I agree with this. The launcher layer also extensively uses client, so they aren't clearly decoupled from each other. If we really want the good decoupling, not only should job service not use client, but launcher should also not use client at all. We can do that, but not sure this PR is the right place for it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The launcher layer also extensively uses client, so they aren't clearly decoupled from each other.

Yes. I don't think it's a good idea to have the client in the launcher.

If we really want the good decoupling, not only should job service not use client, but launcher should also not use client at all.

Well which approach do you think is preferrable? I am ok with the JS using the client, I just dont think we should mix the two approaches (use client and then bypass client).

but not sure this PR is the right place for it.

Knowing that we don't want to increase the internal dependencies (bypassing Client), can we try and not make things worse with this PR at least?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi for others: we discussed this in Zoom. Decided to move ensure_stream_ingestion_jobs in client and job service will call that. In a separate PR we will remove usage of client in launcher and replace it with config reader. In job service as well, we'll replace client with config object whenever possible.

params = get_stream_to_online_ingestion_params(
self.client, request.project, feature_table, []
)
job_hash = params.get_job_hash()

@woop woop Nov 6, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to use __hash__() on Python objects and to use a class/object of the job here instead of params?

@tsotnet tsotnet Nov 6, 2020

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't want to reuse __hash__ which is intended for a different purpose (e.g. deduping in dictionaries & sets). The params class is more general to define its __hash__ for this purpose only.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__hash__ wouldn't really work here anyway since it would change if you restart the pod/process

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I thought Willem meant overriding & implementing __hash__ function instead of get_job_hash. Using existing __hash__ directly would just not work as Oleg stated.

@woop woop Nov 7, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant overriding the __hash__ function so that the unique (and unchanging) properties of the job are used to create a hash. That way we can have Job1 == Job1 and get away from writing custom getters/setters and storage for hash.

@tsotnet do you think this is a bad idea? I don't think it has to be in this PR, and there are other problems like params and jobs being separate, but do you think job level hashes at the __hash__ level is something we can use to simplify the code base later? It seems like we don't need to have separate storage of hash values if we have this.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd def prefer an explicit get_job_hash() and make it part of the params interface (like it is rn). If I was reading feast code, overriding __hash__ in this matter to do this would be surprising to me.

Comment thread sdk/python/feast/job_service.py Outdated
return job_hash_to_table_refs


def ensure_stream_ingestion_jobs(client: feast.Client):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add docstrings to methods with types.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It really seems like this ensure function should be a method on the client itself and not specific to the job service.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate, why? It seems like this is a specific functionality to Job Service. Why should client worry about this? Also, this'll make it difficult to decouple client & job service.

@woop woop Nov 6, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensure is the same as start all jobs necessary to be started. If we believe that starting a specific job is important (from client) then ensure is also important. In fact there is already a use case for this when you cant run stateful processes but you can launch jobs (Databricks scheduler or Github Actions launching Spark jobs on Databricks/EMR).

Also, this'll make it difficult to decouple client & job service.

How so? Wouldn't it improve the decoupling if the logic is only in the client?

@tsotnet tsotnet Nov 6, 2020

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi for others: discussed in Zoom, see above comment.

Comment thread sdk/python/feast/job_service.py Outdated
return GetJobResponse(job=self._job_to_proto(job))


def get_expected_job_hash_to_table_refs(client) -> Dict[str, Tuple[str, str]]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are missing a type definition here and a complete docstring. Also, should this be a public method?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably shouldn't be a public method. Do we need all functions to be compliant to the generated docstrings (with parameters & return type). Sometimes it's overly verbose when the function is simple to describe.

@woop woop Nov 6, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have to follow it religiously. For simple methods we can just have a sentence explaining the method. It's ok to skip in this case I guess.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I'll add a more extensive comment since I'm moving this to the client and modifying it a little.

self.hash_by_id = {}
self.lock = threading.RLock()

def add_job(self, job_id: str, job_hash: Optional[str], job: SparkJob) -> None:

@woop woop Nov 6, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oavdeev @tsotnet Isn't it possible for us to simplify things to def add_job(self, job: SparkJob)? And have the id and hash be a property of the job? It seems like we are doing a lot of bookkeeping here with state that is somewhere derived from each other (id and hash from job).

Just a thought.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible - I'm working on it. I'm combining this idea with what I've discussed with Oleg before. Basically, job hash should be part of StreamIngestionJob. In this case a lot of things become simpler to do - e.g. launcher won't have the ugly new list_jobs_by_hash method anymore.

- Move ensure_stream_ingestion_jobs to client
- Move _job_to_proto to SparkJob & its subclasses (as .to_proto methods)
- Remove list_jobs_by_hash from job launcher layer
- Add .get_hash() to StreamIngestionJob objects, which replaces list_jobs_by_hash in a much nicer way
- Add logic in dataproc launcher (completes all 3 spark modes)
- Add hash to Job proto & RemoteStreamIngestionJob
- Add bunch of docstrings

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
- Start all non-existent jobs that should be running

Args:
all_projects (bool, optional): If true, runs the check for all project.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run the check is a bit vague, maybe ensures stream ingestion jobs are running for all projects?

job_hashes_to_start = expected_job_hashes - existing_job_hashes

logging.info(
f"existing_job_hashes = {sorted(list(existing_job_hashes))} expected_job_hashes = {sorted(list(expected_job_hashes))}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you see a Feast admin using this information @tsotnet?

def get_hash(self) -> str:
"""Gets the consistent hash of this stream ingestion job.

The hash needs to be persisted at the data processing layer, s.t.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s.t?

job = JobProto()
job.id = self.get_id()
status = self.get_status()
if status == SparkJobStatus.COMPLETED:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What the difference between SparkJobStatus & JobStatusProto?
Why can't we have only proto version used everywhere instead of SparkJobStatus and omit this mapping?

@oavdeev oavdeev Nov 7, 2020

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep SparkJob a pure abstract class, even if we have to duplicate a couple lines of code. This version is substantially less readable to me than the previous one, since now JobProto construction logic is spread across the class hierarchy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pyalex i agree seems like there no reason why we shouldn't unify those. I'd address this in a separate PR though (after all, that enum wasn't introduced or even changed in this PR).

woop added a commit that referenced this pull request Nov 14, 2020
* Implement Job Service control loop for stream ingestion jobs

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Update sdk/python/feast/job_service.py

Co-authored-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Update sdk/python/feast/job_service.py

Co-authored-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Ensure jobservice isn't running with failed control loop; also other small fixes

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Code restructure (see commit message details)

- Move ensure_stream_ingestion_jobs to client
- Move _job_to_proto to SparkJob & its subclasses (as .to_proto methods)
- Remove list_jobs_by_hash from job launcher layer
- Add .get_hash() to StreamIngestionJob objects, which replaces list_jobs_by_hash in a much nicer way
- Add logic in dataproc launcher (completes all 3 spark modes)
- Add hash to Job proto & RemoteStreamIngestionJob
- Add bunch of docstrings

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Fix super -> super()

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* make _job_to_proto a function

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* move ensure_stream_ingestion_jobs back out of the client

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* add tests for the job control loop

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* fix _job_to_proto

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* fix job cache state leak between tests

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* fix docker-compose test

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* Add retries to prevent download failure

Signed-off-by: Willem Pienaar <git@willem.co>

* Remove MAVEN_OPTS again

Signed-off-by: Willem Pienaar <git@willem.co>

Co-authored-by: Tsotne Tabidze <tsotnet@gmail.com>
Co-authored-by: Willem Pienaar <git@willem.co>
@oavdeev oavdeev closed this Nov 20, 2020
pyalex pushed a commit that referenced this pull request Nov 24, 2020
* Implement Job Service control loop for stream ingestion jobs

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Update sdk/python/feast/job_service.py

Co-authored-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Update sdk/python/feast/job_service.py

Co-authored-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Ensure jobservice isn't running with failed control loop; also other small fixes

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Code restructure (see commit message details)

- Move ensure_stream_ingestion_jobs to client
- Move _job_to_proto to SparkJob & its subclasses (as .to_proto methods)
- Remove list_jobs_by_hash from job launcher layer
- Add .get_hash() to StreamIngestionJob objects, which replaces list_jobs_by_hash in a much nicer way
- Add logic in dataproc launcher (completes all 3 spark modes)
- Add hash to Job proto & RemoteStreamIngestionJob
- Add bunch of docstrings

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* Fix super -> super()

Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>

* make _job_to_proto a function

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* move ensure_stream_ingestion_jobs back out of the client

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* add tests for the job control loop

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* fix _job_to_proto

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* fix job cache state leak between tests

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* fix docker-compose test

Signed-off-by: Oleg Avdeev <oleg.v.avdeev@gmail.com>

* Add retries to prevent download failure

Signed-off-by: Willem Pienaar <git@willem.co>

* Remove MAVEN_OPTS again

Signed-off-by: Willem Pienaar <git@willem.co>

Co-authored-by: Tsotne Tabidze <tsotnet@gmail.com>
Co-authored-by: Willem Pienaar <git@willem.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/feature New feature or request size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants