Implement Job Service control loop for stream ingestion jobs - #1140
Implement Job Service control loop for stream ingestion jobs#1140tsotnet wants to merge 7 commits into
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: tsotnet The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
c23f09a to
41512ed
Compare
There was a problem hiding this comment.
This log is a bit vague? What do I as a a Feast admin understand from this?
There was a problem hiding this comment.
By the way, why is JOB_CACHE all caps?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
So how is this client different from self.client?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Would expected_job_hashes be a better name?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
How about job_hash_to_table_refs to make it little shorter?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 )
|
/test test-end-to-end |
|
/test DCO |
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>
6503931 to
d85115a
Compare
Signed-off-by: Tsotne Tabidze <tsotnet@gmail.com>
| def __init__(self, client): | ||
| self.client = client | ||
|
|
||
| def _job_to_proto(self, spark_job: SparkJob) -> JobProto: |
There was a problem hiding this comment.
Question: Why is _job_to_proto a method and not a function? or better yet, why not to_proto() on the class?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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" |
There was a problem hiding this comment.
Can we use lower case for concepts as a rule of thumb? feature table.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
@tsotnet it seems like we can take two approaches with the whole job service structure.
- 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).
- 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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
Would it be better to use __hash__() on Python objects and to use a class/object of the job here instead of params?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
__hash__ wouldn't really work here anyway since it would change if you restart the pod/process
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| return job_hash_to_table_refs | ||
|
|
||
|
|
||
| def ensure_stream_ingestion_jobs(client: feast.Client): |
There was a problem hiding this comment.
Please add docstrings to methods with types.
There was a problem hiding this comment.
It really seems like this ensure function should be a method on the client itself and not specific to the job service.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
fyi for others: discussed in Zoom, see above comment.
| return GetJobResponse(job=self._job_to_proto(job)) | ||
|
|
||
|
|
||
| def get_expected_job_hash_to_table_refs(client) -> Dict[str, Tuple[str, str]]: |
There was a problem hiding this comment.
You are missing a type definition here and a complete docstring. Also, should this be a public method?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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))}" |
There was a problem hiding this comment.
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. |
| job = JobProto() | ||
| job.id = self.get_id() | ||
| status = self.get_status() | ||
| if status == SparkJobStatus.COMPLETED: |
There was a problem hiding this comment.
What the difference between SparkJobStatus & JobStatusProto?
Why can't we have only proto version used everywhere instead of SparkJobStatus and omit this mapping?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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).
* 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>
* 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>
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?: