Skip to content

Commit de820be

Browse files
oavdeevtsotnetwoop
authored
Jobservice Control loop (based on feast-dev#1140) (feast-dev#1156)
* 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>
1 parent a22658d commit de820be

15 files changed

Lines changed: 569 additions & 78 deletions

File tree

infra/scripts/test-docker-compose.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,4 @@ docker exec \
6969
-e DISABLE_FEAST_SERVICE_FIXTURES=true \
7070
--user root \
7171
feast_jupyter_1 bash \
72-
-c 'cd /feast/tests && python -m pip install -r requirements.txt && pytest e2e/ --ingestion-jar https://storage.googleapis.com/feast-jobs/spark/ingestion/feast-ingestion-spark-${FEAST_VERSION}.jar --redis-url redis:6379 --core-url core:6565 --serving-url online_serving:6566 --job-service-url jobservice:6568 --staging-path file:///shared/staging/ --kafka-brokers kafka:9092'
72+
-c 'cd /feast/tests && python -m pip install -r requirements.txt && pytest e2e/ --ingestion-jar https://storage.googleapis.com/feast-jobs/spark/ingestion/feast-ingestion-spark-${FEAST_VERSION}.jar --redis-url redis:6379 --core-url core:6565 --serving-url online_serving:6566 --job-service-url jobservice:6568 --staging-path file:///shared/staging/ --kafka-brokers kafka:9092 --feast-version develop'

protos/feast/core/JobService.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ message Job {
7272
JobType type = 2;
7373
// Current job status
7474
JobStatus status = 3;
75+
// Deterministic hash of the Job
76+
string hash = 8;
7577

7678
message RetrievalJobMeta {
7779
string output_location = 4;

sdk/python/feast/client.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,12 +1098,15 @@ def start_offline_to_online_ingestion(
10981098
)
10991099

11001100
def start_stream_to_online_ingestion(
1101-
self, feature_table: FeatureTable, extra_jars: Optional[List[str]] = None,
1101+
self,
1102+
feature_table: FeatureTable,
1103+
extra_jars: Optional[List[str]] = None,
1104+
project: str = None,
11021105
) -> SparkJob:
11031106
if not self._use_job_service:
11041107
return start_stream_to_online_ingestion(
11051108
client=self,
1106-
project=self.project,
1109+
project=project or self.project,
11071110
feature_table=feature_table,
11081111
extra_jars=extra_jars or [],
11091112
)
@@ -1113,7 +1116,7 @@ def start_stream_to_online_ingestion(
11131116
)
11141117
response = self._job_service.StartStreamToOnlineIngestionJob(request)
11151118
return RemoteStreamIngestionJob(
1116-
self._job_service, self._extra_grpc_params, response.id,
1119+
self._job_service, self._extra_grpc_params, response.id
11171120
)
11181121

11191122
def list_jobs(self, include_terminated: bool) -> List[SparkJob]:

sdk/python/feast/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class AuthProvider(Enum):
5252
CONFIG_JOB_SERVICE_URL_KEY = "job_service_url"
5353
CONFIG_JOB_SERVICE_ENABLE_SSL_KEY = "job_service_enable_ssl"
5454
CONFIG_JOB_SERVICE_SERVER_SSL_CERT_KEY = "job_service_server_ssl_cert"
55+
CONFIG_JOB_SERVICE_ENABLE_CONTROL_LOOP = "job_service_enable_control_loop"
5556
CONFIG_GRPC_CONNECTION_TIMEOUT_DEFAULT_KEY = "grpc_connection_timeout_default"
5657
CONFIG_GRPC_CONNECTION_TIMEOUT_APPLY_KEY = "grpc_connection_timeout_apply"
5758
CONFIG_BATCH_FEATURE_REQUEST_WAIT_TIME_SECONDS_KEY = (
@@ -143,6 +144,8 @@ class AuthProvider(Enum):
143144
CONFIG_JOB_SERVICE_ENABLE_SSL_KEY: "False",
144145
# Path to certificate(s) to secure connection to Feast Job Service
145146
CONFIG_JOB_SERVICE_SERVER_SSL_CERT_KEY: "",
147+
# Disable control loop by default for now
148+
CONFIG_JOB_SERVICE_ENABLE_CONTROL_LOOP: "False",
146149
CONFIG_STATSD_ENABLED: "False",
147150
# IngestionJob DeadLetter Destination
148151
CONFIG_DEADLETTER_PATH: "",

sdk/python/feast/job_service.py

Lines changed: 172 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import logging
2+
import os
3+
import signal
4+
import threading
5+
import time
6+
import traceback
27
from concurrent.futures import ThreadPoolExecutor
8+
from typing import Dict, List, Tuple
39

410
import grpc
511

612
import feast
13+
from feast.constants import CONFIG_JOB_SERVICE_ENABLE_CONTROL_LOOP
714
from feast.core import JobService_pb2_grpc
815
from feast.core.JobService_pb2 import (
916
CancelJobResponse,
@@ -31,6 +38,7 @@
3138
)
3239
from feast.pyspark.launcher import (
3340
get_job_by_id,
41+
get_stream_to_online_ingestion_params,
3442
list_jobs,
3543
start_historical_feature_retrieval_job,
3644
start_offline_to_online_ingestion,
@@ -43,36 +51,37 @@
4351
)
4452

4553

54+
def _job_to_proto(spark_job: SparkJob) -> JobProto:
55+
job = JobProto()
56+
job.id = spark_job.get_id()
57+
status = spark_job.get_status()
58+
if status == SparkJobStatus.COMPLETED:
59+
job.status = JobStatus.JOB_STATUS_DONE
60+
elif status == SparkJobStatus.IN_PROGRESS:
61+
job.status = JobStatus.JOB_STATUS_RUNNING
62+
elif status == SparkJobStatus.FAILED:
63+
job.status = JobStatus.JOB_STATUS_ERROR
64+
elif status == SparkJobStatus.STARTING:
65+
job.status = JobStatus.JOB_STATUS_PENDING
66+
else:
67+
raise ValueError(f"Invalid job status {status}")
68+
69+
if isinstance(spark_job, RetrievalJob):
70+
job.type = JobType.RETRIEVAL_JOB
71+
job.retrieval.output_location = spark_job.get_output_file_uri(block=False)
72+
elif isinstance(spark_job, BatchIngestionJob):
73+
job.type = JobType.BATCH_INGESTION_JOB
74+
elif isinstance(spark_job, StreamIngestionJob):
75+
job.type = JobType.STREAM_INGESTION_JOB
76+
else:
77+
raise ValueError(f"Invalid job type {job}")
78+
79+
return job
80+
81+
4682
class JobServiceServicer(JobService_pb2_grpc.JobServiceServicer):
47-
def __init__(self):
48-
self.client = feast.Client()
49-
50-
def _job_to_proto(self, spark_job: SparkJob) -> JobProto:
51-
job = JobProto()
52-
job.id = spark_job.get_id()
53-
status = spark_job.get_status()
54-
if status == SparkJobStatus.COMPLETED:
55-
job.status = JobStatus.JOB_STATUS_DONE
56-
elif status == SparkJobStatus.IN_PROGRESS:
57-
job.status = JobStatus.JOB_STATUS_RUNNING
58-
elif status == SparkJobStatus.FAILED:
59-
job.status = JobStatus.JOB_STATUS_ERROR
60-
elif status == SparkJobStatus.STARTING:
61-
job.status = JobStatus.JOB_STATUS_PENDING
62-
else:
63-
raise ValueError(f"Invalid job status {status}")
64-
65-
if isinstance(spark_job, RetrievalJob):
66-
job.type = JobType.RETRIEVAL_JOB
67-
job.retrieval.output_location = spark_job.get_output_file_uri(block=False)
68-
elif isinstance(spark_job, BatchIngestionJob):
69-
job.type = JobType.BATCH_INGESTION_JOB
70-
elif isinstance(spark_job, StreamIngestionJob):
71-
job.type = JobType.STREAM_INGESTION_JOB
72-
else:
73-
raise ValueError(f"Invalid job type {job}")
74-
75-
return job
83+
def __init__(self, client):
84+
self.client = client
7685

7786
def StartOfflineToOnlineIngestionJob(
7887
self, request: StartOfflineToOnlineIngestionJobRequest, context
@@ -117,6 +126,20 @@ def StartStreamToOnlineIngestionJob(
117126
feature_table = self.client.get_feature_table(
118127
request.table_name, request.project
119128
)
129+
130+
if self.client._config.getboolean(CONFIG_JOB_SERVICE_ENABLE_CONTROL_LOOP):
131+
# If the control loop is enabled, return existing stream ingestion job id instead of starting a new one
132+
params = get_stream_to_online_ingestion_params(
133+
self.client, request.project, feature_table, []
134+
)
135+
job_hash = params.get_job_hash()
136+
for job in list_jobs(include_terminated=True, client=self.client):
137+
if isinstance(job, StreamIngestionJob) and job.get_hash() == job_hash:
138+
return StartStreamToOnlineIngestionJobResponse(id=job.get_id())
139+
raise RuntimeError(
140+
"Feast Job Service has control loop enabled, but couldn't find the existing stream ingestion job for the given FeatureTable"
141+
)
142+
120143
# TODO: add extra_jars to request
121144
job = start_stream_to_online_ingestion(
122145
client=self.client,
@@ -131,7 +154,7 @@ def ListJobs(self, request, context):
131154
jobs = list_jobs(
132155
include_terminated=request.include_terminated, client=self.client
133156
)
134-
return ListJobsResponse(jobs=[self._job_to_proto(job) for job in jobs])
157+
return ListJobsResponse(jobs=[_job_to_proto(job) for job in jobs])
135158

136159
def CancelJob(self, request, context):
137160
"""Stop a single job"""
@@ -142,7 +165,30 @@ def CancelJob(self, request, context):
142165
def GetJob(self, request, context):
143166
"""Get details of a single job"""
144167
job = get_job_by_id(request.job_id, client=self.client)
145-
return GetJobResponse(job=self._job_to_proto(job))
168+
return GetJobResponse(job=_job_to_proto(job))
169+
170+
171+
def start_control_loop() -> None:
172+
"""Starts control loop that continuously ensures that correct jobs are being run.
173+
174+
Currently this affects only the stream ingestion jobs. Please refer to
175+
ensure_stream_ingestion_jobs for full documentation on how the check works.
176+
177+
"""
178+
logging.info(
179+
"Feast Job Service is starting a control loop in a background thread, "
180+
"which will ensure that stream ingestion jobs are successfully running."
181+
)
182+
try:
183+
client = feast.Client()
184+
while True:
185+
ensure_stream_ingestion_jobs(client, all_projects=True)
186+
time.sleep(1)
187+
except Exception:
188+
traceback.print_exc()
189+
finally:
190+
# Send interrupt signal to the main thread to kill the server if control loop fails
191+
os.kill(os.getpid(), signal.SIGINT)
146192

147193

148194
class HealthServicer(HealthService_pb2_grpc.HealthServicer):
@@ -156,18 +202,110 @@ def intercept_service(self, continuation, handler_call_details):
156202
return continuation(handler_call_details)
157203

158204

159-
def start_job_service():
205+
def start_job_service() -> None:
160206
"""
161207
Start Feast Job Service
162208
"""
163209

164210
log_fmt = "%(asctime)s %(levelname)s %(message)s"
165211
logging.basicConfig(level=logging.INFO, format=log_fmt)
166212

213+
client = feast.Client()
214+
215+
if client._config.getboolean(CONFIG_JOB_SERVICE_ENABLE_CONTROL_LOOP):
216+
# Start the control loop thread only if it's enabled from configs
217+
thread = threading.Thread(target=start_control_loop, daemon=True)
218+
thread.start()
219+
167220
server = grpc.server(ThreadPoolExecutor(), interceptors=(LoggingInterceptor(),))
168-
JobService_pb2_grpc.add_JobServiceServicer_to_server(JobServiceServicer(), server)
221+
JobService_pb2_grpc.add_JobServiceServicer_to_server(
222+
JobServiceServicer(client), server
223+
)
169224
HealthService_pb2_grpc.add_HealthServicer_to_server(HealthServicer(), server)
170225
server.add_insecure_port("[::]:6568")
171226
server.start()
172-
print("Feast job server listening on port :6568")
227+
logging.info("Feast Job Service is listening on port :6568")
173228
server.wait_for_termination()
229+
230+
231+
def _get_expected_job_hash_to_table_refs(
232+
client: feast.Client, projects: List[str]
233+
) -> Dict[str, Tuple[str, str]]:
234+
"""
235+
Checks all feature tables for the requires project(s) and determines all required stream
236+
ingestion jobs from them. Outputs a map of the expected job_hash to a tuple of (project, table_name).
237+
238+
Args:
239+
all_projects (bool): If true, runs the check for all project.
240+
Otherwise only checks the current project.
241+
242+
Returns:
243+
Dict[str, Tuple[str, str]]: Map of job_hash -> (project, table_name) for expected stream ingestion jobs
244+
"""
245+
job_hash_to_table_refs = {}
246+
247+
for project in projects:
248+
feature_tables = client.list_feature_tables(project)
249+
for feature_table in feature_tables:
250+
if feature_table.stream_source is not None:
251+
params = get_stream_to_online_ingestion_params(
252+
client, project, feature_table, []
253+
)
254+
job_hash = params.get_job_hash()
255+
job_hash_to_table_refs[job_hash] = (project, feature_table.name)
256+
257+
return job_hash_to_table_refs
258+
259+
260+
def ensure_stream_ingestion_jobs(client: feast.Client, all_projects: bool):
261+
"""Ensures all required stream ingestion jobs are running and cleans up the unnecessary jobs.
262+
263+
More concretely, it will determine
264+
- which stream ingestion jobs are running
265+
- which stream ingestion jobs should be running
266+
And it'll do 2 kinds of operations
267+
- Cancel all running jobs that should not be running
268+
- Start all non-existent jobs that should be running
269+
270+
Args:
271+
all_projects (bool): If true, runs the check for all project.
272+
Otherwise only checks the client's current project.
273+
"""
274+
275+
projects = client.list_projects() if all_projects else [client.project]
276+
277+
expected_job_hash_to_table_refs = _get_expected_job_hash_to_table_refs(
278+
client, projects
279+
)
280+
281+
expected_job_hashes = set(expected_job_hash_to_table_refs.keys())
282+
283+
jobs_by_hash: Dict[str, StreamIngestionJob] = {}
284+
for job in client.list_jobs(include_terminated=False):
285+
if isinstance(job, StreamIngestionJob):
286+
jobs_by_hash[job.get_hash()] = job
287+
288+
existing_job_hashes = set(jobs_by_hash.keys())
289+
290+
job_hashes_to_cancel = existing_job_hashes - expected_job_hashes
291+
job_hashes_to_start = expected_job_hashes - existing_job_hashes
292+
293+
logging.debug(
294+
f"existing_job_hashes = {sorted(list(existing_job_hashes))} expected_job_hashes = {sorted(list(expected_job_hashes))}"
295+
)
296+
297+
for job_hash in job_hashes_to_cancel:
298+
job = jobs_by_hash[job_hash]
299+
logging.info(
300+
f"Cancelling a stream ingestion job with job_hash={job_hash} job_id={job.get_id()} status={job.get_status()}"
301+
)
302+
job.cancel()
303+
304+
for job_hash in job_hashes_to_start:
305+
# Any job that we wish to start should be among expected table refs map
306+
project, table_name = expected_job_hash_to_table_refs[job_hash]
307+
logging.info(
308+
f"Starting a stream ingestion job for project={project}, table_name={table_name} with job_hash={job_hash}"
309+
)
310+
feature_table = client.get_feature_table(name=table_name, project=project)
311+
client.start_stream_to_online_ingestion(feature_table, [], project=project)

sdk/python/feast/pyspark/abc.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import abc
2+
import hashlib
23
import json
34
import os
45
from datetime import datetime
@@ -456,6 +457,13 @@ def get_arguments(self) -> List[str]:
456457
"online",
457458
]
458459

460+
def get_job_hash(self) -> str:
461+
job_json = json.dumps(
462+
{"source": self._source, "feature_table": self._feature_table},
463+
sort_keys=True,
464+
)
465+
return hashlib.md5(job_json.encode()).hexdigest()
466+
459467

460468
class BatchIngestionJob(SparkJob):
461469
"""
@@ -468,6 +476,17 @@ class StreamIngestionJob(SparkJob):
468476
Container for the streaming ingestion job result
469477
"""
470478

479+
def get_hash(self) -> str:
480+
"""Gets the consistent hash of this stream ingestion job.
481+
482+
The hash needs to be persisted at the data processing layer, so that we can get the same
483+
hash when retrieving the job from Spark.
484+
485+
Returns:
486+
str: The hash for this streaming ingestion job
487+
"""
488+
raise NotImplementedError
489+
471490

472491
class JobLauncher(abc.ABC):
473492
"""

0 commit comments

Comments
 (0)