apache-airflow-providers-edge3 1.1.0rc1__py3-none-any.whl → 1.1.1rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@ from airflow import __version__ as airflow_version
29
29
 
30
30
  __all__ = ["__version__"]
31
31
 
32
- __version__ = "1.1.0"
32
+ __version__ = "1.1.1"
33
33
 
34
34
  if packaging.version.parse(packaging.version.parse(airflow_version).base_version) < packaging.version.parse(
35
35
  "2.10.0"
@@ -24,45 +24,31 @@ import sys
24
24
  from dataclasses import asdict
25
25
  from datetime import datetime
26
26
  from getpass import getuser
27
- from http import HTTPStatus
28
- from multiprocessing import Process
29
27
  from pathlib import Path
30
- from subprocess import Popen
31
28
  from time import sleep, time
32
- from typing import TYPE_CHECKING
33
29
 
34
30
  import psutil
35
- from lockfile.pidlockfile import read_pid_from_pidfile, remove_existing_pidfile, write_pid_to_pidfile
36
- from requests import HTTPError
37
31
 
38
- from airflow import __version__ as airflow_version, settings
32
+ from airflow import settings
39
33
  from airflow.cli.cli_config import ARG_PID, ARG_VERBOSE, ActionCommand, Arg
40
34
  from airflow.cli.commands.daemon_utils import run_command_with_daemon_option
41
35
  from airflow.cli.simple_table import AirflowConsole
42
36
  from airflow.configuration import conf
43
- from airflow.providers.edge3 import __version__ as edge_provider_version
44
- from airflow.providers.edge3.cli.api_client import (
45
- jobs_fetch,
46
- jobs_set_state,
47
- logs_logfile_path,
48
- logs_push,
49
- worker_register,
50
- worker_set_state,
37
+ from airflow.providers.edge3.cli.dataclasses import MaintenanceMarker, WorkerStatus
38
+ from airflow.providers.edge3.cli.signalling import (
39
+ EDGE_WORKER_PROCESS_NAME,
40
+ get_pid,
41
+ maintenance_marker_file_path,
42
+ pid_file_path,
43
+ status_file_path,
51
44
  )
52
- from airflow.providers.edge3.cli.dataclasses import Job, MaintenanceMarker, WorkerStatus
53
- from airflow.providers.edge3.models.edge_worker import EdgeWorkerState, EdgeWorkerVersionException
54
- from airflow.providers.edge3.version_compat import AIRFLOW_V_3_0_PLUS
55
- from airflow.utils import cli as cli_utils, timezone
45
+ from airflow.providers.edge3.cli.worker import SIG_STATUS, EdgeWorker
46
+ from airflow.providers.edge3.models.edge_worker import EdgeWorkerState
47
+ from airflow.utils import cli as cli_utils
56
48
  from airflow.utils.net import getfqdn
57
- from airflow.utils.platform import IS_WINDOWS
58
49
  from airflow.utils.providers_configuration_loader import providers_configuration_loaded
59
- from airflow.utils.state import TaskInstanceState
60
-
61
- if TYPE_CHECKING:
62
- from airflow.providers.edge3.worker_api.datamodels import EdgeJobFetched
63
50
 
64
51
  logger = logging.getLogger(__name__)
65
- EDGE_WORKER_PROCESS_NAME = "edge-worker"
66
52
  EDGE_WORKER_HEADER = "\n".join(
67
53
  [
68
54
  r" ____ __ _ __ __",
@@ -98,411 +84,13 @@ def force_use_internal_api_on_edge_worker():
98
84
  force_use_internal_api_on_edge_worker()
99
85
 
100
86
 
101
- def _status_signal() -> signal.Signals:
102
- if IS_WINDOWS:
103
- return signal.SIGBREAK # type: ignore[attr-defined]
104
- return signal.SIGUSR2
105
-
106
-
107
- SIG_STATUS = _status_signal()
108
-
109
-
110
- def _pid_file_path(pid_file: str | None) -> str:
111
- return cli_utils.setup_locations(process=EDGE_WORKER_PROCESS_NAME, pid=pid_file)[0]
112
-
113
-
114
- def _get_pid(pid_file: str | None) -> int:
115
- pid = read_pid_from_pidfile(_pid_file_path(pid_file))
116
- if not pid:
117
- logger.warning("Could not find PID of worker.")
118
- sys.exit(1)
119
- return pid
120
-
121
-
122
- def _status_file_path(pid_file: str | None) -> str:
123
- return cli_utils.setup_locations(process=EDGE_WORKER_PROCESS_NAME, pid=pid_file)[1]
124
-
125
-
126
- def _maintenance_marker_file_path(pid_file: str | None) -> str:
127
- return cli_utils.setup_locations(process=EDGE_WORKER_PROCESS_NAME, pid=pid_file)[1][:-4] + ".in"
128
-
129
-
130
- def _write_pid_to_pidfile(pid_file_path: str):
131
- """Write PIDs for Edge Workers to disk, handling existing PID files."""
132
- if Path(pid_file_path).exists():
133
- # Handle existing PID files on disk
134
- logger.info("An existing PID file has been found: %s.", pid_file_path)
135
- pid_stored_in_pid_file = read_pid_from_pidfile(pid_file_path)
136
- if os.getpid() == pid_stored_in_pid_file:
137
- raise SystemExit("A PID file has already been written")
138
- # PID file was written by dead or already running instance
139
- if psutil.pid_exists(pid_stored_in_pid_file):
140
- # case 1: another instance uses the same path for its PID file
141
- raise SystemExit(
142
- f"The PID file {pid_file_path} contains the PID of another running process. "
143
- "Configuration issue: edge worker instance must use different PID file paths!"
144
- )
145
- # case 2: previous instance crashed without cleaning up its PID file
146
- logger.warning("PID file is orphaned. Cleaning up.")
147
- remove_existing_pidfile(pid_file_path)
148
- logger.debug("PID file written to %s.", pid_file_path)
149
- write_pid_to_pidfile(pid_file_path)
150
-
151
-
152
- def _edge_hostname() -> str:
153
- """Get the hostname of the edge worker that should be reported by tasks."""
154
- return os.environ.get("HOSTNAME", getfqdn())
155
-
156
-
157
- class _EdgeWorkerCli:
158
- """Runner instance which executes the Edge Worker."""
159
-
160
- jobs: list[Job] = []
161
- """List of jobs that the worker is running currently."""
162
- last_hb: datetime | None = None
163
- """Timestamp of last heart beat sent to server."""
164
- drain: bool = False
165
- """Flag if job processing should be completed and no new jobs fetched for a graceful stop/shutdown."""
166
- maintenance_mode: bool = False
167
- """Flag if job processing should be completed and no new jobs fetched for maintenance mode. """
168
- maintenance_comments: str | None = None
169
- """Comments for maintenance mode."""
170
- edge_instance: _EdgeWorkerCli | None = None
171
- """Singleton instance of the worker."""
172
-
173
- def __init__(
174
- self,
175
- pid_file_path: str,
176
- hostname: str,
177
- queues: list[str] | None,
178
- concurrency: int,
179
- job_poll_interval: int,
180
- heartbeat_interval: int,
181
- daemon: bool = False,
182
- ):
183
- self.pid_file_path = pid_file_path
184
- self.job_poll_interval = job_poll_interval
185
- self.hb_interval = heartbeat_interval
186
- self.hostname = hostname
187
- self.queues = queues
188
- self.concurrency = concurrency
189
- self.free_concurrency = concurrency
190
- self.daemon = daemon
191
-
192
- _EdgeWorkerCli.edge_instance = self
193
-
194
- @staticmethod
195
- def signal_handler(sig: signal.Signals, frame):
196
- if sig == SIG_STATUS:
197
- marker_path = Path(_maintenance_marker_file_path(None))
198
- if marker_path.exists():
199
- request = MaintenanceMarker.from_json(marker_path.read_text())
200
- logger.info("Requested to set maintenance mode to %s", request.maintenance)
201
- _EdgeWorkerCli.maintenance_mode = request.maintenance == "on"
202
- if _EdgeWorkerCli.maintenance_mode and request.comments:
203
- logger.info("Comments: %s", request.comments)
204
- _EdgeWorkerCli.maintenance_comments = request.comments
205
- marker_path.unlink()
206
- # send heartbeat immediately to update state
207
- if _EdgeWorkerCli.edge_instance:
208
- _EdgeWorkerCli.edge_instance.heartbeat(_EdgeWorkerCli.maintenance_comments)
209
- else:
210
- logger.info("Request to get status of Edge Worker received.")
211
- status_path = Path(_status_file_path(None))
212
- status_path.write_text(
213
- WorkerStatus(
214
- job_count=len(_EdgeWorkerCli.jobs),
215
- jobs=[job.edge_job.key for job in _EdgeWorkerCli.jobs],
216
- state=_EdgeWorkerCli._get_state(),
217
- maintenance=_EdgeWorkerCli.maintenance_mode,
218
- maintenance_comments=_EdgeWorkerCli.maintenance_comments,
219
- drain=_EdgeWorkerCli.drain,
220
- ).json
221
- )
222
- else:
223
- logger.info("Request to shut down Edge Worker received, waiting for jobs to complete.")
224
- _EdgeWorkerCli.drain = True
225
-
226
- def shutdown_handler(self, sig, frame):
227
- logger.info("SIGTERM received. Terminating all jobs and quit")
228
- for job in _EdgeWorkerCli.jobs:
229
- os.killpg(job.process.pid, signal.SIGTERM)
230
- _EdgeWorkerCli.drain = True
231
-
232
- def _get_sysinfo(self) -> dict:
233
- """Produce the sysinfo from worker to post to central site."""
234
- return {
235
- "airflow_version": airflow_version,
236
- "edge_provider_version": edge_provider_version,
237
- "concurrency": self.concurrency,
238
- "free_concurrency": self.free_concurrency,
239
- }
240
-
241
- @staticmethod
242
- def _get_state() -> EdgeWorkerState:
243
- """State of the Edge Worker."""
244
- if _EdgeWorkerCli.jobs:
245
- if _EdgeWorkerCli.drain:
246
- return EdgeWorkerState.TERMINATING
247
- if _EdgeWorkerCli.maintenance_mode:
248
- return EdgeWorkerState.MAINTENANCE_PENDING
249
- return EdgeWorkerState.RUNNING
250
-
251
- if _EdgeWorkerCli.drain:
252
- if _EdgeWorkerCli.maintenance_mode:
253
- return EdgeWorkerState.OFFLINE_MAINTENANCE
254
- return EdgeWorkerState.OFFLINE
255
-
256
- if _EdgeWorkerCli.maintenance_mode:
257
- return EdgeWorkerState.MAINTENANCE_MODE
258
- return EdgeWorkerState.IDLE
259
-
260
- def _launch_job_af3(self, edge_job: EdgeJobFetched) -> tuple[Process, Path]:
261
- if TYPE_CHECKING:
262
- from airflow.executors.workloads import ExecuteTask
263
-
264
- def _run_job_via_supervisor(
265
- workload: ExecuteTask,
266
- ) -> int:
267
- from setproctitle import setproctitle
268
-
269
- from airflow.sdk.execution_time.supervisor import supervise
270
-
271
- # Ignore ctrl-c in this process -- we don't want to kill _this_ one. we let tasks run to completion
272
- signal.signal(signal.SIGINT, signal.SIG_IGN)
273
-
274
- logger.info("Worker starting up pid=%d", os.getpid())
275
- setproctitle(f"airflow edge worker: {workload.ti.key}")
276
-
277
- try:
278
- base_url = conf.get("api", "base_url", fallback="/")
279
- # If it's a relative URL, use localhost:8080 as the default
280
- if base_url.startswith("/"):
281
- base_url = f"http://localhost:8080{base_url}"
282
- default_execution_api_server = f"{base_url.rstrip('/')}/execution/"
283
-
284
- supervise(
285
- # This is the "wrong" ti type, but it duck types the same. TODO: Create a protocol for this.
286
- # Same like in airflow/executors/local_executor.py:_execute_work()
287
- ti=workload.ti, # type: ignore[arg-type]
288
- dag_rel_path=workload.dag_rel_path,
289
- bundle_info=workload.bundle_info,
290
- token=workload.token,
291
- server=conf.get(
292
- "core", "execution_api_server_url", fallback=default_execution_api_server
293
- ),
294
- log_path=workload.log_path,
295
- )
296
- return 0
297
- except Exception as e:
298
- logger.exception("Task execution failed: %s", e)
299
- return 1
300
-
301
- workload: ExecuteTask = edge_job.command
302
- process = Process(
303
- target=_run_job_via_supervisor,
304
- kwargs={"workload": workload},
305
- )
306
- process.start()
307
- base_log_folder = conf.get("logging", "base_log_folder", fallback="NOT AVAILABLE")
308
- if TYPE_CHECKING:
309
- assert workload.log_path # We need to assume this is defined in here
310
- logfile = Path(base_log_folder, workload.log_path)
311
- return process, logfile
312
-
313
- def _launch_job_af2_10(self, edge_job: EdgeJobFetched) -> tuple[Popen, Path]:
314
- """Compatibility for Airflow 2.10 Launch."""
315
- env = os.environ.copy()
316
- env["AIRFLOW__CORE__DATABASE_ACCESS_ISOLATION"] = "True"
317
- env["AIRFLOW__CORE__INTERNAL_API_URL"] = conf.get("edge", "api_url")
318
- env["_AIRFLOW__SKIP_DATABASE_EXECUTOR_COMPATIBILITY_CHECK"] = "1"
319
- command: list[str] = edge_job.command # type: ignore[assignment]
320
- process = Popen(command, close_fds=True, env=env, start_new_session=True)
321
- logfile = logs_logfile_path(edge_job.key)
322
- return process, logfile
323
-
324
- def _launch_job(self, edge_job: EdgeJobFetched):
325
- """Get the received job executed."""
326
- process: Popen | Process
327
- if AIRFLOW_V_3_0_PLUS:
328
- process, logfile = self._launch_job_af3(edge_job)
329
- else:
330
- # Airflow 2.10
331
- process, logfile = self._launch_job_af2_10(edge_job)
332
- _EdgeWorkerCli.jobs.append(Job(edge_job, process, logfile, 0))
333
-
334
- def start(self):
335
- """Start the execution in a loop until terminated."""
336
- try:
337
- self.last_hb = worker_register(
338
- self.hostname, EdgeWorkerState.STARTING, self.queues, self._get_sysinfo()
339
- ).last_update
340
- except EdgeWorkerVersionException as e:
341
- logger.info("Version mismatch of Edge worker and Core. Shutting down worker.")
342
- raise SystemExit(str(e))
343
- except HTTPError as e:
344
- if e.response.status_code == HTTPStatus.NOT_FOUND:
345
- raise SystemExit("Error: API endpoint is not ready, please set [edge] api_enabled=True.")
346
- raise SystemExit(str(e))
347
- if not self.daemon:
348
- _write_pid_to_pidfile(self.pid_file_path)
349
- signal.signal(signal.SIGINT, _EdgeWorkerCli.signal_handler)
350
- signal.signal(SIG_STATUS, _EdgeWorkerCli.signal_handler)
351
- signal.signal(signal.SIGTERM, self.shutdown_handler)
352
- os.environ["HOSTNAME"] = self.hostname
353
- os.environ["AIRFLOW__CORE__HOSTNAME_CALLABLE"] = f"{_edge_hostname.__module__}._edge_hostname"
354
- try:
355
- self.worker_state_changed = self.heartbeat()
356
- self.last_hb = datetime.now()
357
- while not _EdgeWorkerCli.drain or _EdgeWorkerCli.jobs:
358
- self.loop()
359
-
360
- logger.info("Quitting worker, signal being offline.")
361
- try:
362
- worker_set_state(
363
- self.hostname,
364
- EdgeWorkerState.OFFLINE_MAINTENANCE
365
- if _EdgeWorkerCli.maintenance_mode
366
- else EdgeWorkerState.OFFLINE,
367
- 0,
368
- self.queues,
369
- self._get_sysinfo(),
370
- )
371
- except EdgeWorkerVersionException:
372
- logger.info("Version mismatch of Edge worker and Core. Quitting worker anyway.")
373
- finally:
374
- if not self.daemon:
375
- remove_existing_pidfile(self.pid_file_path)
376
-
377
- def loop(self):
378
- """Run a loop of scheduling and monitoring tasks."""
379
- new_job = False
380
- previous_jobs = _EdgeWorkerCli.jobs
381
- if not any((_EdgeWorkerCli.drain, _EdgeWorkerCli.maintenance_mode)) and self.free_concurrency > 0:
382
- new_job = self.fetch_job()
383
- self.check_running_jobs()
384
-
385
- if (
386
- _EdgeWorkerCli.drain
387
- or datetime.now().timestamp() - self.last_hb.timestamp() > self.hb_interval
388
- or self.worker_state_changed # send heartbeat immediately if the state is different in db
389
- or bool(previous_jobs) != bool(_EdgeWorkerCli.jobs) # when number of jobs changes from/to 0
390
- ):
391
- self.worker_state_changed = self.heartbeat()
392
- self.last_hb = datetime.now()
393
-
394
- if not new_job:
395
- self.interruptible_sleep()
396
-
397
- def fetch_job(self) -> bool:
398
- """Fetch and start a new job from central site."""
399
- logger.debug("Attempting to fetch a new job...")
400
- edge_job = jobs_fetch(self.hostname, self.queues, self.free_concurrency)
401
- if edge_job:
402
- logger.info("Received job: %s", edge_job)
403
- self._launch_job(edge_job)
404
- jobs_set_state(edge_job.key, TaskInstanceState.RUNNING)
405
- return True
406
-
407
- logger.info(
408
- "No new job to process%s",
409
- f", {len(_EdgeWorkerCli.jobs)} still running" if _EdgeWorkerCli.jobs else "",
410
- )
411
- return False
412
-
413
- def check_running_jobs(self) -> None:
414
- """Check which of the running tasks/jobs are completed and report back."""
415
- used_concurrency = 0
416
- for i in range(len(_EdgeWorkerCli.jobs) - 1, -1, -1):
417
- job = _EdgeWorkerCli.jobs[i]
418
- if not job.is_running:
419
- _EdgeWorkerCli.jobs.remove(job)
420
- if job.is_success:
421
- logger.info("Job completed: %s", job.edge_job)
422
- jobs_set_state(job.edge_job.key, TaskInstanceState.SUCCESS)
423
- else:
424
- logger.error("Job failed: %s", job.edge_job)
425
- jobs_set_state(job.edge_job.key, TaskInstanceState.FAILED)
426
- else:
427
- used_concurrency += job.edge_job.concurrency_slots
428
-
429
- if job.logfile.exists() and job.logfile.stat().st_size > job.logsize:
430
- with job.logfile.open("rb") as logfile:
431
- push_log_chunk_size = conf.getint("edge", "push_log_chunk_size")
432
- logfile.seek(job.logsize, os.SEEK_SET)
433
- read_data = logfile.read()
434
- job.logsize += len(read_data)
435
- # backslashreplace to keep not decoded characters and not raising exception
436
- # replace null with question mark to fix issue during DB push
437
- log_data = read_data.decode(errors="backslashreplace").replace("\x00", "\ufffd")
438
- while True:
439
- chunk_data = log_data[:push_log_chunk_size]
440
- log_data = log_data[push_log_chunk_size:]
441
- if not chunk_data:
442
- break
443
-
444
- logs_push(
445
- task=job.edge_job.key,
446
- log_chunk_time=timezone.utcnow(),
447
- log_chunk_data=chunk_data,
448
- )
449
-
450
- self.free_concurrency = self.concurrency - used_concurrency
451
-
452
- def heartbeat(self, new_maintenance_comments: str | None = None) -> bool:
453
- """Report liveness state of worker to central site with stats."""
454
- state = _EdgeWorkerCli._get_state()
455
- sysinfo = self._get_sysinfo()
456
- worker_state_changed: bool = False
457
- try:
458
- worker_info = worker_set_state(
459
- self.hostname,
460
- state,
461
- len(_EdgeWorkerCli.jobs),
462
- self.queues,
463
- sysinfo,
464
- new_maintenance_comments,
465
- )
466
- self.queues = worker_info.queues
467
- if worker_info.state == EdgeWorkerState.MAINTENANCE_REQUEST:
468
- logger.info("Maintenance mode requested!")
469
- _EdgeWorkerCli.maintenance_mode = True
470
- elif (
471
- worker_info.state in [EdgeWorkerState.IDLE, EdgeWorkerState.RUNNING]
472
- and _EdgeWorkerCli.maintenance_mode
473
- ):
474
- logger.info("Exit Maintenance mode requested!")
475
- _EdgeWorkerCli.maintenance_mode = False
476
- if _EdgeWorkerCli.maintenance_mode:
477
- _EdgeWorkerCli.maintenance_comments = worker_info.maintenance_comments
478
- else:
479
- _EdgeWorkerCli.maintenance_comments = None
480
- if worker_info.state == EdgeWorkerState.SHUTDOWN_REQUEST:
481
- logger.info("Shutdown requested!")
482
- _EdgeWorkerCli.drain = True
483
-
484
- worker_state_changed = worker_info.state != state
485
- except EdgeWorkerVersionException:
486
- logger.info("Version mismatch of Edge worker and Core. Shutting down worker.")
487
- _EdgeWorkerCli.drain = True
488
- return worker_state_changed
489
-
490
- def interruptible_sleep(self):
491
- """Sleeps but stops sleeping if drain is made."""
492
- drain_before_sleep = _EdgeWorkerCli.drain
493
- for _ in range(0, self.job_poll_interval * 10):
494
- sleep(0.1)
495
- if drain_before_sleep != _EdgeWorkerCli.drain:
496
- return
497
-
498
-
499
87
  @providers_configuration_loaded
500
88
  def _launch_worker(args):
501
89
  print(settings.HEADER)
502
90
  print(EDGE_WORKER_HEADER)
503
91
 
504
- edge_worker = _EdgeWorkerCli(
505
- pid_file_path=_pid_file_path(args.pid),
92
+ edge_worker = EdgeWorker(
93
+ pid_file_path=pid_file_path(args.pid),
506
94
  hostname=args.edge_hostname or getfqdn(),
507
95
  queues=args.queues.split(",") if args.queues else None,
508
96
  concurrency=args.concurrency,
@@ -524,7 +112,7 @@ def worker(args):
524
112
  process_name=EDGE_WORKER_PROCESS_NAME,
525
113
  callback=lambda: _launch_worker(args),
526
114
  should_setup_logging=True,
527
- pid_file=_pid_file_path(args.pid),
115
+ pid_file=pid_file_path(args.pid),
528
116
  umask=umask,
529
117
  )
530
118
 
@@ -533,12 +121,12 @@ def worker(args):
533
121
  @providers_configuration_loaded
534
122
  def status(args):
535
123
  """Check for Airflow Local Edge Worker status."""
536
- pid = _get_pid(args.pid)
124
+ pid = get_pid(args.pid)
537
125
 
538
126
  # Send Signal as notification to drop status JSON
539
127
  logger.debug("Sending SIGUSR2 to worker pid %i.", pid)
540
128
  status_min_date = time() - 1
541
- status_path = Path(_status_file_path(args.pid))
129
+ status_path = Path(status_file_path(args.pid))
542
130
  worker_process = psutil.Process(pid)
543
131
  worker_process.send_signal(SIG_STATUS)
544
132
  while psutil.pid_exists(pid) and (
@@ -563,12 +151,12 @@ def maintenance(args):
563
151
  logger.error("Comments are required when setting maintenance mode.")
564
152
  sys.exit(4)
565
153
 
566
- pid = _get_pid(args.pid)
154
+ pid = get_pid(args.pid)
567
155
 
568
156
  # Write marker JSON file
569
157
  from getpass import getuser
570
158
 
571
- marker_path = Path(_maintenance_marker_file_path(args.pid))
159
+ marker_path = Path(maintenance_marker_file_path(args.pid))
572
160
  logger.debug("Writing maintenance marker file to %s.", marker_path)
573
161
  marker_path.write_text(
574
162
  MaintenanceMarker(
@@ -583,7 +171,7 @@ def maintenance(args):
583
171
  # Send Signal as notification to fetch maintenance marker
584
172
  logger.debug("Sending SIGUSR2 to worker pid %i.", pid)
585
173
  status_min_date = time() - 1
586
- status_path = Path(_status_file_path(args.pid))
174
+ status_path = Path(status_file_path(args.pid))
587
175
  worker_process = psutil.Process(pid)
588
176
  worker_process.send_signal(SIG_STATUS)
589
177
  while psutil.pid_exists(pid) and (
@@ -630,7 +218,7 @@ def maintenance(args):
630
218
  @providers_configuration_loaded
631
219
  def stop(args):
632
220
  """Stop a running local Airflow Edge Worker."""
633
- pid = _get_pid(args.pid)
221
+ pid = get_pid(args.pid)
634
222
  # Send SIGINT
635
223
  logger.info("Sending SIGINT to worker pid %i.", pid)
636
224
  worker_process = psutil.Process(pid)
@@ -674,9 +262,26 @@ def list_edge_workers(args) -> None:
674
262
  "worker_name",
675
263
  "state",
676
264
  "queues",
265
+ "jobs_active",
266
+ "concurrency",
267
+ "free_concurrency",
677
268
  "maintenance_comment",
678
269
  ]
679
- all_hosts = [{f: host.__getattribute__(f) for f in fields} for host in all_hosts_iter]
270
+
271
+ all_hosts = []
272
+ for host in all_hosts_iter:
273
+ host_data = {
274
+ f: getattr(host, f, None) for f in fields if f not in ("concurrency", "free_concurrency")
275
+ }
276
+ try:
277
+ sysinfo = json.loads(host.sysinfo or "{}")
278
+ host_data["concurrency"] = sysinfo.get("concurrency")
279
+ host_data["free_concurrency"] = sysinfo.get("free_concurrency")
280
+ except (json.JSONDecodeError, TypeError):
281
+ host_data["concurrency"] = None
282
+ host_data["free_concurrency"] = None
283
+ all_hosts.append(host_data)
284
+
680
285
  AirflowConsole().print_as(data=all_hosts, output=args.output)
681
286
 
682
287
 
@@ -0,0 +1,88 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import signal
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ import psutil
26
+ from lockfile.pidlockfile import (
27
+ read_pid_from_pidfile,
28
+ remove_existing_pidfile,
29
+ write_pid_to_pidfile as write_pid,
30
+ )
31
+
32
+ from airflow.utils import cli as cli_utils
33
+ from airflow.utils.platform import IS_WINDOWS
34
+
35
+ EDGE_WORKER_PROCESS_NAME = "edge-worker"
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _status_signal() -> signal.Signals:
41
+ if IS_WINDOWS:
42
+ return signal.SIGBREAK # type: ignore[attr-defined]
43
+ return signal.SIGUSR2
44
+
45
+
46
+ SIG_STATUS = _status_signal()
47
+
48
+
49
+ def write_pid_to_pidfile(pid_file_path: str):
50
+ """Write PIDs for Edge Workers to disk, handling existing PID files."""
51
+ if Path(pid_file_path).exists():
52
+ # Handle existing PID files on disk
53
+ logger.info("An existing PID file has been found: %s.", pid_file_path)
54
+ pid_stored_in_pid_file = read_pid_from_pidfile(pid_file_path)
55
+ if os.getpid() == pid_stored_in_pid_file:
56
+ raise SystemExit("A PID file has already been written")
57
+ # PID file was written by dead or already running instance
58
+ if psutil.pid_exists(pid_stored_in_pid_file):
59
+ # case 1: another instance uses the same path for its PID file
60
+ raise SystemExit(
61
+ f"The PID file {pid_file_path} contains the PID of another running process. "
62
+ "Configuration issue: edge worker instance must use different PID file paths!"
63
+ )
64
+ # case 2: previous instance crashed without cleaning up its PID file
65
+ logger.warning("PID file is orphaned. Cleaning up.")
66
+ remove_existing_pidfile(pid_file_path)
67
+ logger.debug("PID file written to %s.", pid_file_path)
68
+ write_pid(pid_file_path)
69
+
70
+
71
+ def pid_file_path(pid_file: str | None) -> str:
72
+ return cli_utils.setup_locations(process=EDGE_WORKER_PROCESS_NAME, pid=pid_file)[0]
73
+
74
+
75
+ def get_pid(pid_file: str | None) -> int:
76
+ pid = read_pid_from_pidfile(pid_file_path(pid_file))
77
+ if not pid:
78
+ logger.warning("Could not find PID of worker.")
79
+ sys.exit(1)
80
+ return pid
81
+
82
+
83
+ def status_file_path(pid_file: str | None) -> str:
84
+ return cli_utils.setup_locations(process=EDGE_WORKER_PROCESS_NAME, pid=pid_file)[1]
85
+
86
+
87
+ def maintenance_marker_file_path(pid_file: str | None) -> str:
88
+ return cli_utils.setup_locations(process=EDGE_WORKER_PROCESS_NAME, pid=pid_file)[1][:-4] + ".in"
@@ -0,0 +1,407 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import signal
22
+ from datetime import datetime
23
+ from http import HTTPStatus
24
+ from multiprocessing import Process
25
+ from pathlib import Path
26
+ from subprocess import Popen
27
+ from time import sleep
28
+ from typing import TYPE_CHECKING
29
+
30
+ from lockfile.pidlockfile import remove_existing_pidfile
31
+ from requests import HTTPError
32
+
33
+ from airflow import __version__ as airflow_version
34
+ from airflow.configuration import conf
35
+ from airflow.providers.edge3 import __version__ as edge_provider_version
36
+ from airflow.providers.edge3.cli.api_client import (
37
+ jobs_fetch,
38
+ jobs_set_state,
39
+ logs_logfile_path,
40
+ logs_push,
41
+ worker_register,
42
+ worker_set_state,
43
+ )
44
+ from airflow.providers.edge3.cli.dataclasses import Job, MaintenanceMarker, WorkerStatus
45
+ from airflow.providers.edge3.cli.signalling import (
46
+ SIG_STATUS,
47
+ maintenance_marker_file_path,
48
+ status_file_path,
49
+ write_pid_to_pidfile,
50
+ )
51
+ from airflow.providers.edge3.models.edge_worker import EdgeWorkerState, EdgeWorkerVersionException
52
+ from airflow.providers.edge3.version_compat import AIRFLOW_V_3_0_PLUS
53
+ from airflow.utils import timezone
54
+ from airflow.utils.net import getfqdn
55
+ from airflow.utils.state import TaskInstanceState
56
+
57
+ if TYPE_CHECKING:
58
+ from airflow.providers.edge3.worker_api.datamodels import EdgeJobFetched
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+
63
+ def _edge_hostname() -> str:
64
+ """Get the hostname of the edge worker that should be reported by tasks."""
65
+ return os.environ.get("HOSTNAME", getfqdn())
66
+
67
+
68
+ class EdgeWorker:
69
+ """Runner instance which executes the Edge Worker."""
70
+
71
+ jobs: list[Job] = []
72
+ """List of jobs that the worker is running currently."""
73
+ last_hb: datetime | None = None
74
+ """Timestamp of last heart beat sent to server."""
75
+ drain: bool = False
76
+ """Flag if job processing should be completed and no new jobs fetched for a graceful stop/shutdown."""
77
+ maintenance_mode: bool = False
78
+ """Flag if job processing should be completed and no new jobs fetched for maintenance mode. """
79
+ maintenance_comments: str | None = None
80
+ """Comments for maintenance mode."""
81
+ edge_instance: EdgeWorker | None = None
82
+ """Singleton instance of the worker."""
83
+
84
+ def __init__(
85
+ self,
86
+ pid_file_path: str,
87
+ hostname: str,
88
+ queues: list[str] | None,
89
+ concurrency: int,
90
+ job_poll_interval: int,
91
+ heartbeat_interval: int,
92
+ daemon: bool = False,
93
+ ):
94
+ self.pid_file_path = pid_file_path
95
+ self.job_poll_interval = job_poll_interval
96
+ self.hb_interval = heartbeat_interval
97
+ self.hostname = hostname
98
+ self.queues = queues
99
+ self.concurrency = concurrency
100
+ self.free_concurrency = concurrency
101
+ self.daemon = daemon
102
+
103
+ EdgeWorker.edge_instance = self
104
+
105
+ @staticmethod
106
+ def signal_handler(sig: signal.Signals, frame):
107
+ if sig == SIG_STATUS:
108
+ marker_path = Path(maintenance_marker_file_path(None))
109
+ if marker_path.exists():
110
+ request = MaintenanceMarker.from_json(marker_path.read_text())
111
+ logger.info("Requested to set maintenance mode to %s", request.maintenance)
112
+ EdgeWorker.maintenance_mode = request.maintenance == "on"
113
+ if EdgeWorker.maintenance_mode and request.comments:
114
+ logger.info("Comments: %s", request.comments)
115
+ EdgeWorker.maintenance_comments = request.comments
116
+ marker_path.unlink()
117
+ # send heartbeat immediately to update state
118
+ if EdgeWorker.edge_instance:
119
+ EdgeWorker.edge_instance.heartbeat(EdgeWorker.maintenance_comments)
120
+ else:
121
+ logger.info("Request to get status of Edge Worker received.")
122
+ status_path = Path(status_file_path(None))
123
+ status_path.write_text(
124
+ WorkerStatus(
125
+ job_count=len(EdgeWorker.jobs),
126
+ jobs=[job.edge_job.key for job in EdgeWorker.jobs],
127
+ state=EdgeWorker._get_state(),
128
+ maintenance=EdgeWorker.maintenance_mode,
129
+ maintenance_comments=EdgeWorker.maintenance_comments,
130
+ drain=EdgeWorker.drain,
131
+ ).json
132
+ )
133
+ else:
134
+ logger.info("Request to shut down Edge Worker received, waiting for jobs to complete.")
135
+ EdgeWorker.drain = True
136
+
137
+ def shutdown_handler(self, sig, frame):
138
+ logger.info("SIGTERM received. Terminating all jobs and quit")
139
+ for job in EdgeWorker.jobs:
140
+ os.killpg(job.process.pid, signal.SIGTERM)
141
+ EdgeWorker.drain = True
142
+
143
+ def _get_sysinfo(self) -> dict:
144
+ """Produce the sysinfo from worker to post to central site."""
145
+ return {
146
+ "airflow_version": airflow_version,
147
+ "edge_provider_version": edge_provider_version,
148
+ "concurrency": self.concurrency,
149
+ "free_concurrency": self.free_concurrency,
150
+ }
151
+
152
+ @staticmethod
153
+ def _get_state() -> EdgeWorkerState:
154
+ """State of the Edge Worker."""
155
+ if EdgeWorker.jobs:
156
+ if EdgeWorker.drain:
157
+ return EdgeWorkerState.TERMINATING
158
+ if EdgeWorker.maintenance_mode:
159
+ return EdgeWorkerState.MAINTENANCE_PENDING
160
+ return EdgeWorkerState.RUNNING
161
+
162
+ if EdgeWorker.drain:
163
+ if EdgeWorker.maintenance_mode:
164
+ return EdgeWorkerState.OFFLINE_MAINTENANCE
165
+ return EdgeWorkerState.OFFLINE
166
+
167
+ if EdgeWorker.maintenance_mode:
168
+ return EdgeWorkerState.MAINTENANCE_MODE
169
+ return EdgeWorkerState.IDLE
170
+
171
+ def _launch_job_af3(self, edge_job: EdgeJobFetched) -> tuple[Process, Path]:
172
+ if TYPE_CHECKING:
173
+ from airflow.executors.workloads import ExecuteTask
174
+
175
+ def _run_job_via_supervisor(
176
+ workload: ExecuteTask,
177
+ ) -> int:
178
+ from setproctitle import setproctitle
179
+
180
+ from airflow.sdk.execution_time.supervisor import supervise
181
+
182
+ # Ignore ctrl-c in this process -- we don't want to kill _this_ one. we let tasks run to completion
183
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
184
+
185
+ logger.info("Worker starting up pid=%d", os.getpid())
186
+ setproctitle(f"airflow edge worker: {workload.ti.key}")
187
+
188
+ try:
189
+ base_url = conf.get("api", "base_url", fallback="/")
190
+ # If it's a relative URL, use localhost:8080 as the default
191
+ if base_url.startswith("/"):
192
+ base_url = f"http://localhost:8080{base_url}"
193
+ default_execution_api_server = f"{base_url.rstrip('/')}/execution/"
194
+
195
+ supervise(
196
+ # This is the "wrong" ti type, but it duck types the same. TODO: Create a protocol for this.
197
+ # Same like in airflow/executors/local_executor.py:_execute_work()
198
+ ti=workload.ti, # type: ignore[arg-type]
199
+ dag_rel_path=workload.dag_rel_path,
200
+ bundle_info=workload.bundle_info,
201
+ token=workload.token,
202
+ server=conf.get(
203
+ "core", "execution_api_server_url", fallback=default_execution_api_server
204
+ ),
205
+ log_path=workload.log_path,
206
+ )
207
+ return 0
208
+ except Exception as e:
209
+ logger.exception("Task execution failed: %s", e)
210
+ return 1
211
+
212
+ workload: ExecuteTask = edge_job.command
213
+ process = Process(
214
+ target=_run_job_via_supervisor,
215
+ kwargs={"workload": workload},
216
+ )
217
+ process.start()
218
+ base_log_folder = conf.get("logging", "base_log_folder", fallback="NOT AVAILABLE")
219
+ if TYPE_CHECKING:
220
+ assert workload.log_path # We need to assume this is defined in here
221
+ logfile = Path(base_log_folder, workload.log_path)
222
+ return process, logfile
223
+
224
+ def _launch_job_af2_10(self, edge_job: EdgeJobFetched) -> tuple[Popen, Path]:
225
+ """Compatibility for Airflow 2.10 Launch."""
226
+ env = os.environ.copy()
227
+ env["AIRFLOW__CORE__DATABASE_ACCESS_ISOLATION"] = "True"
228
+ env["AIRFLOW__CORE__INTERNAL_API_URL"] = conf.get("edge", "api_url")
229
+ env["_AIRFLOW__SKIP_DATABASE_EXECUTOR_COMPATIBILITY_CHECK"] = "1"
230
+ command: list[str] = edge_job.command # type: ignore[assignment]
231
+ process = Popen(command, close_fds=True, env=env, start_new_session=True)
232
+ logfile = logs_logfile_path(edge_job.key)
233
+ return process, logfile
234
+
235
+ def _launch_job(self, edge_job: EdgeJobFetched):
236
+ """Get the received job executed."""
237
+ process: Popen | Process
238
+ if AIRFLOW_V_3_0_PLUS:
239
+ process, logfile = self._launch_job_af3(edge_job)
240
+ else:
241
+ # Airflow 2.10
242
+ process, logfile = self._launch_job_af2_10(edge_job)
243
+ EdgeWorker.jobs.append(Job(edge_job, process, logfile, 0))
244
+
245
+ def start(self):
246
+ """Start the execution in a loop until terminated."""
247
+ try:
248
+ self.last_hb = worker_register(
249
+ self.hostname, EdgeWorkerState.STARTING, self.queues, self._get_sysinfo()
250
+ ).last_update
251
+ except EdgeWorkerVersionException as e:
252
+ logger.info("Version mismatch of Edge worker and Core. Shutting down worker.")
253
+ raise SystemExit(str(e))
254
+ except HTTPError as e:
255
+ if e.response.status_code == HTTPStatus.NOT_FOUND:
256
+ raise SystemExit("Error: API endpoint is not ready, please set [edge] api_enabled=True.")
257
+ raise SystemExit(str(e))
258
+ if not self.daemon:
259
+ write_pid_to_pidfile(self.pid_file_path)
260
+ signal.signal(signal.SIGINT, EdgeWorker.signal_handler)
261
+ signal.signal(SIG_STATUS, EdgeWorker.signal_handler)
262
+ signal.signal(signal.SIGTERM, self.shutdown_handler)
263
+ os.environ["HOSTNAME"] = self.hostname
264
+ os.environ["AIRFLOW__CORE__HOSTNAME_CALLABLE"] = f"{_edge_hostname.__module__}._edge_hostname"
265
+ try:
266
+ self.worker_state_changed = self.heartbeat()
267
+ self.last_hb = datetime.now()
268
+ while not EdgeWorker.drain or EdgeWorker.jobs:
269
+ self.loop()
270
+
271
+ logger.info("Quitting worker, signal being offline.")
272
+ try:
273
+ worker_set_state(
274
+ self.hostname,
275
+ EdgeWorkerState.OFFLINE_MAINTENANCE
276
+ if EdgeWorker.maintenance_mode
277
+ else EdgeWorkerState.OFFLINE,
278
+ 0,
279
+ self.queues,
280
+ self._get_sysinfo(),
281
+ )
282
+ except EdgeWorkerVersionException:
283
+ logger.info("Version mismatch of Edge worker and Core. Quitting worker anyway.")
284
+ finally:
285
+ if not self.daemon:
286
+ remove_existing_pidfile(self.pid_file_path)
287
+
288
+ def loop(self):
289
+ """Run a loop of scheduling and monitoring tasks."""
290
+ new_job = False
291
+ previous_jobs = EdgeWorker.jobs
292
+ if not any((EdgeWorker.drain, EdgeWorker.maintenance_mode)) and self.free_concurrency > 0:
293
+ new_job = self.fetch_job()
294
+ self.check_running_jobs()
295
+
296
+ if (
297
+ EdgeWorker.drain
298
+ or datetime.now().timestamp() - self.last_hb.timestamp() > self.hb_interval
299
+ or self.worker_state_changed # send heartbeat immediately if the state is different in db
300
+ or bool(previous_jobs) != bool(EdgeWorker.jobs) # when number of jobs changes from/to 0
301
+ ):
302
+ self.worker_state_changed = self.heartbeat()
303
+ self.last_hb = datetime.now()
304
+
305
+ if not new_job:
306
+ self.interruptible_sleep()
307
+
308
+ def fetch_job(self) -> bool:
309
+ """Fetch and start a new job from central site."""
310
+ logger.debug("Attempting to fetch a new job...")
311
+ edge_job = jobs_fetch(self.hostname, self.queues, self.free_concurrency)
312
+ if edge_job:
313
+ logger.info("Received job: %s", edge_job)
314
+ self._launch_job(edge_job)
315
+ jobs_set_state(edge_job.key, TaskInstanceState.RUNNING)
316
+ return True
317
+
318
+ logger.info(
319
+ "No new job to process%s",
320
+ f", {len(EdgeWorker.jobs)} still running" if EdgeWorker.jobs else "",
321
+ )
322
+ return False
323
+
324
+ def check_running_jobs(self) -> None:
325
+ """Check which of the running tasks/jobs are completed and report back."""
326
+ used_concurrency = 0
327
+ for i in range(len(EdgeWorker.jobs) - 1, -1, -1):
328
+ job = EdgeWorker.jobs[i]
329
+ if not job.is_running:
330
+ EdgeWorker.jobs.remove(job)
331
+ if job.is_success:
332
+ logger.info("Job completed: %s", job.edge_job)
333
+ jobs_set_state(job.edge_job.key, TaskInstanceState.SUCCESS)
334
+ else:
335
+ logger.error("Job failed: %s", job.edge_job)
336
+ jobs_set_state(job.edge_job.key, TaskInstanceState.FAILED)
337
+ else:
338
+ used_concurrency += job.edge_job.concurrency_slots
339
+
340
+ if job.logfile.exists() and job.logfile.stat().st_size > job.logsize:
341
+ with job.logfile.open("rb") as logfile:
342
+ push_log_chunk_size = conf.getint("edge", "push_log_chunk_size")
343
+ logfile.seek(job.logsize, os.SEEK_SET)
344
+ read_data = logfile.read()
345
+ job.logsize += len(read_data)
346
+ # backslashreplace to keep not decoded characters and not raising exception
347
+ # replace null with question mark to fix issue during DB push
348
+ log_data = read_data.decode(errors="backslashreplace").replace("\x00", "\ufffd")
349
+ while True:
350
+ chunk_data = log_data[:push_log_chunk_size]
351
+ log_data = log_data[push_log_chunk_size:]
352
+ if not chunk_data:
353
+ break
354
+
355
+ logs_push(
356
+ task=job.edge_job.key,
357
+ log_chunk_time=timezone.utcnow(),
358
+ log_chunk_data=chunk_data,
359
+ )
360
+
361
+ self.free_concurrency = self.concurrency - used_concurrency
362
+
363
+ def heartbeat(self, new_maintenance_comments: str | None = None) -> bool:
364
+ """Report liveness state of worker to central site with stats."""
365
+ state = EdgeWorker._get_state()
366
+ sysinfo = self._get_sysinfo()
367
+ worker_state_changed: bool = False
368
+ try:
369
+ worker_info = worker_set_state(
370
+ self.hostname,
371
+ state,
372
+ len(EdgeWorker.jobs),
373
+ self.queues,
374
+ sysinfo,
375
+ new_maintenance_comments,
376
+ )
377
+ self.queues = worker_info.queues
378
+ if worker_info.state == EdgeWorkerState.MAINTENANCE_REQUEST:
379
+ logger.info("Maintenance mode requested!")
380
+ EdgeWorker.maintenance_mode = True
381
+ elif (
382
+ worker_info.state in [EdgeWorkerState.IDLE, EdgeWorkerState.RUNNING]
383
+ and EdgeWorker.maintenance_mode
384
+ ):
385
+ logger.info("Exit Maintenance mode requested!")
386
+ EdgeWorker.maintenance_mode = False
387
+ if EdgeWorker.maintenance_mode:
388
+ EdgeWorker.maintenance_comments = worker_info.maintenance_comments
389
+ else:
390
+ EdgeWorker.maintenance_comments = None
391
+ if worker_info.state == EdgeWorkerState.SHUTDOWN_REQUEST:
392
+ logger.info("Shutdown requested!")
393
+ EdgeWorker.drain = True
394
+
395
+ worker_state_changed = worker_info.state != state
396
+ except EdgeWorkerVersionException:
397
+ logger.info("Version mismatch of Edge worker and Core. Shutting down worker.")
398
+ EdgeWorker.drain = True
399
+ return worker_state_changed
400
+
401
+ def interruptible_sleep(self):
402
+ """Sleeps but stops sleeping if drain is made."""
403
+ drain_before_sleep = EdgeWorker.drain
404
+ for _ in range(0, self.job_poll_interval * 10):
405
+ sleep(0.1)
406
+ if drain_before_sleep != EdgeWorker.drain:
407
+ return
@@ -41,7 +41,7 @@ if TYPE_CHECKING:
41
41
 
42
42
 
43
43
  class NotepadOperator(BaseOperator):
44
- """Example Operator Implementation which starts a Notepod.exe on WIndows."""
44
+ """Example Operator Implementation which starts a ``Notepad.exe`` on Windows."""
45
45
 
46
46
  template_fields: Sequence[str] = "text"
47
47
 
@@ -47,9 +47,10 @@ if TYPE_CHECKING:
47
47
 
48
48
  from sqlalchemy.engine.base import Engine
49
49
 
50
- from airflow.executors.base_executor import CommandType
51
50
  from airflow.models.taskinstancekey import TaskInstanceKey
52
51
 
52
+ # TODO: Airflow 2 type hints; remove when Airflow 2 support is removed
53
+ CommandType = Sequence[str]
53
54
  # Task tuple to send to be executed
54
55
  TaskTuple = tuple[TaskInstanceKey, CommandType, Optional[str], Optional[Any]]
55
56
 
@@ -72,12 +73,23 @@ class EdgeExecutor(BaseExecutor):
72
73
  inspector = inspect(engine)
73
74
  edge_job_columns = None
74
75
  with contextlib.suppress(NoSuchTableError):
75
- edge_job_columns = [column["name"] for column in inspector.get_columns("edge_job")]
76
+ edge_job_schema = inspector.get_columns("edge_job")
77
+ edge_job_columns = [column["name"] for column in edge_job_schema]
78
+ for column in edge_job_schema:
79
+ if column["name"] == "command":
80
+ edge_job_command_len = column["type"].length
76
81
 
77
82
  # version 0.6.0rc1 added new column concurrency_slots
78
83
  if edge_job_columns and "concurrency_slots" not in edge_job_columns:
79
84
  EdgeJobModel.metadata.drop_all(engine, tables=[EdgeJobModel.__table__])
80
85
 
86
+ # version 1.1.0 the command column was changed to VARCHAR(2048)
87
+ elif edge_job_command_len and edge_job_command_len != 2048:
88
+ with Session(engine) as session:
89
+ query = "ALTER TABLE edge_job ALTER COLUMN command TYPE VARCHAR(2048);"
90
+ session.execute(text(query))
91
+ session.commit()
92
+
81
93
  edge_worker_columns = None
82
94
  with contextlib.suppress(NoSuchTableError):
83
95
  edge_worker_columns = [column["name"] for column in inspector.get_columns("edge_worker")]
@@ -108,7 +120,7 @@ class EdgeExecutor(BaseExecutor):
108
120
  Store queued_tasks in own var to be able to access this in execute_async function.
109
121
  """
110
122
  self.edge_queued_tasks = deepcopy(self.queued_tasks)
111
- super()._process_tasks(task_tuples)
123
+ super()._process_tasks(task_tuples) # type: ignore[misc]
112
124
 
113
125
  @provide_session
114
126
  def execute_async(
@@ -122,10 +134,11 @@ class EdgeExecutor(BaseExecutor):
122
134
  """Execute asynchronously. Airflow 2.10 entry point to execute a task."""
123
135
  # Use of a temponary trick to get task instance, will be changed with Airflow 3.0.0
124
136
  # code works together with _process_tasks overwrite to get task instance.
125
- task_instance = self.edge_queued_tasks[key][3] # TaskInstance in fourth element
137
+ # TaskInstance in fourth element
138
+ task_instance = self.edge_queued_tasks[key][3] # type: ignore[index]
126
139
  del self.edge_queued_tasks[key]
127
140
 
128
- self.validate_airflow_tasks_run_command(command)
141
+ self.validate_airflow_tasks_run_command(command) # type: ignore[attr-defined]
129
142
  session.add(
130
143
  EdgeJobModel(
131
144
  dag_id=key.dag_id,
@@ -49,7 +49,7 @@ class EdgeJobModel(Base, LoggingMixin):
49
49
  state = Column(String(20))
50
50
  queue = Column(String(256))
51
51
  concurrency_slots = Column(Integer)
52
- command = Column(String(1000))
52
+ command = Column(String(2048))
53
53
  queued_dttm = Column(UtcDateTime)
54
54
  edge_worker = Column(String(64))
55
55
  last_update = Column(UtcDateTime)
@@ -245,7 +245,11 @@ def remove_worker(worker_name: str, session: Session = NEW_SESSION) -> None:
245
245
  """Remove a worker that is offline or just gone from DB."""
246
246
  query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name)
247
247
  worker: EdgeWorkerModel = session.scalar(query)
248
- if worker.state in (EdgeWorkerState.OFFLINE, EdgeWorkerState.OFFLINE_MAINTENANCE):
248
+ if worker.state in (
249
+ EdgeWorkerState.OFFLINE,
250
+ EdgeWorkerState.OFFLINE_MAINTENANCE,
251
+ EdgeWorkerState.UNKNOWN,
252
+ ):
249
253
  session.execute(delete(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == worker_name))
250
254
  else:
251
255
  error_message = f"Cannot remove edge worker {worker_name} as it is in {worker.state} state!"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apache-airflow-providers-edge3
3
- Version: 1.1.0rc1
3
+ Version: 1.1.1rc1
4
4
  Summary: Provider package apache-airflow-providers-edge3 for Apache Airflow
5
5
  Keywords: airflow-provider,edge3,airflow,integration
6
6
  Author-email: Apache Software Foundation <dev@airflow.apache.org>
@@ -25,8 +25,8 @@ Requires-Dist: apache-airflow-providers-fab>=1.5.3rc1
25
25
  Requires-Dist: pydantic>=2.11.0
26
26
  Requires-Dist: retryhttp>=1.2.0,!=1.3.0
27
27
  Project-URL: Bug Tracker, https://github.com/apache/airflow/issues
28
- Project-URL: Changelog, https://airflow.staged.apache.org/docs/apache-airflow-providers-edge3/1.1.0/changelog.html
29
- Project-URL: Documentation, https://airflow.staged.apache.org/docs/apache-airflow-providers-edge3/1.1.0
28
+ Project-URL: Changelog, https://airflow.staged.apache.org/docs/apache-airflow-providers-edge3/1.1.1/changelog.html
29
+ Project-URL: Documentation, https://airflow.staged.apache.org/docs/apache-airflow-providers-edge3/1.1.1
30
30
  Project-URL: Mastodon, https://fosstodon.org/@airflow
31
31
  Project-URL: Slack Chat, https://s.apache.org/airflow-slack
32
32
  Project-URL: Source Code, https://github.com/apache/airflow
@@ -57,7 +57,7 @@ Project-URL: YouTube, https://www.youtube.com/channel/UCSXwxpWZQ7XZ1WL3wqevChA/
57
57
 
58
58
  Package ``apache-airflow-providers-edge3``
59
59
 
60
- Release: ``1.1.0``
60
+ Release: ``1.1.1``
61
61
 
62
62
 
63
63
  Handle edge workers on remote sites via HTTP(s) connection and orchestrates work over distributed sites.
@@ -82,7 +82,7 @@ This is a provider package for ``edge3`` provider. All classes for this provider
82
82
  are in ``airflow.providers.edge3`` python package.
83
83
 
84
84
  You can find package information and changelog for the provider
85
- in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-edge3/1.1.0/>`_.
85
+ in the `documentation <https://airflow.apache.org/docs/apache-airflow-providers-edge3/1.1.1/>`_.
86
86
 
87
87
  Installation
88
88
  ------------
@@ -125,5 +125,5 @@ Dependent package
125
125
  ============================================================================================== =======
126
126
 
127
127
  The changelog for the provider package can be found in the
128
- `changelog <https://airflow.apache.org/docs/apache-airflow-providers-edge3/1.1.0/changelog.html>`_.
128
+ `changelog <https://airflow.apache.org/docs/apache-airflow-providers-edge3/1.1.1/changelog.html>`_.
129
129
 
@@ -1,21 +1,23 @@
1
1
  airflow/providers/edge3/LICENSE,sha256=gXPVwptPlW1TJ4HSuG5OMPg-a3h43OGMkZRR1rpwfJA,10850
2
- airflow/providers/edge3/__init__.py,sha256=NrV-3Uc00pr80NbPVUZWK9JYceO_jK26xp9BmvwFGnU,1494
2
+ airflow/providers/edge3/__init__.py,sha256=lWNqnEBB7q4ggTGo6urdvAwUV0msxvtkIHymUhwdweg,1494
3
3
  airflow/providers/edge3/get_provider_info.py,sha256=Ek27-dB4UALHUFYoYjtoQIGq0p7zeHcEgmELHvpVmCU,6836
4
4
  airflow/providers/edge3/version_compat.py,sha256=j5PCtXvZ71aBjixu-EFTNtVDPsngzzs7os0ZQDgFVDk,1536
5
5
  airflow/providers/edge3/cli/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
6
6
  airflow/providers/edge3/cli/api_client.py,sha256=334KHVB4eMSzRpQ5emS56o-RTUJQprxf5Q3xQldCHDQ,7440
7
7
  airflow/providers/edge3/cli/dataclasses.py,sha256=JUuvvmzSVWvG9uOEfzLIiXrTZ-HbESvu50jkPpVIYVw,2895
8
- airflow/providers/edge3/cli/edge_command.py,sha256=dRKC1VPAUIvJnzvPlmcT98oBGlG0_MK7TBTNvjDEI2k,35658
8
+ airflow/providers/edge3/cli/edge_command.py,sha256=Yggt-hBcf3rq7cMelsP0Jx9QfSkR07YU229ZIc7pZYY,18276
9
+ airflow/providers/edge3/cli/signalling.py,sha256=sf4S6j6OoP0bLkda3UlCmlZabjv5wsMypy3kAvx56Z0,3220
10
+ airflow/providers/edge3/cli/worker.py,sha256=AqyvJyH5hn4dumt5iP_XEdiJ94F8KaYz4rVlZQR-y8E,17212
9
11
  airflow/providers/edge3/example_dags/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
10
12
  airflow/providers/edge3/example_dags/integration_test.py,sha256=kevxwDePjTRqLWJGV-nKEQQWb7qb-y2oedm3mdqi6-8,6127
11
- airflow/providers/edge3/example_dags/win_notepad.py,sha256=2evbqiupi5Ko4tyRkIEC5TPbc2c0wyR2YpsDYsBLMMM,2828
13
+ airflow/providers/edge3/example_dags/win_notepad.py,sha256=zYcrKqODN4KLZQ-5wNnZQQskrDd5LA-nKJNgKQDntSE,2832
12
14
  airflow/providers/edge3/example_dags/win_test.py,sha256=GegWqjvbsSdbsA_f3S9_FRYftVO0pggXwQQggB9Vvz4,13220
13
15
  airflow/providers/edge3/executors/__init__.py,sha256=y830gGSKCvjOcLwLuCDp84NCrHWWB9RSSH1qvJpFhyY,923
14
- airflow/providers/edge3/executors/edge_executor.py,sha256=Z7q1Ph_SvAuT1cR4YYcEmKYt4Yi711q65VrzofQ5t1A,15791
16
+ airflow/providers/edge3/executors/edge_executor.py,sha256=bCdLaknCo3LIuwpYw1u68xMedDXvVaqG4ZG0QKeM7xU,16474
15
17
  airflow/providers/edge3/models/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
16
- airflow/providers/edge3/models/edge_job.py,sha256=rdl9cH1bBrc7id8zkZ7uxsCJNPLG-8o9cnspGwBPBcQ,3167
18
+ airflow/providers/edge3/models/edge_job.py,sha256=3D5HAzcVkyI2bxl3pVbbRxjIz--Tnr_eNFiw2oI6gEQ,3167
17
19
  airflow/providers/edge3/models/edge_logs.py,sha256=bNstp7gR54O2vbxzz4NTL0erbifFbGUjZ-YOM0I4sqk,2768
18
- airflow/providers/edge3/models/edge_worker.py,sha256=7U74k24xw36X8gQkrac3yWbtFBHbopvmRIWsjpFY8Og,10693
20
+ airflow/providers/edge3/models/edge_worker.py,sha256=4qr5K-QU5yTd2p3AtdVhWlbvToxFFBWk2qIRFpJToWo,10749
19
21
  airflow/providers/edge3/openapi/__init__.py,sha256=0O-WvmDx8GeKSoECpHYrbe0hW-LgjlKny3VqTCpBQeQ,927
20
22
  airflow/providers/edge3/openapi/edge_worker_api_v1.yaml,sha256=GAE2IdOXmcUueNy5KFkLBgNpoWnOjnHT9TrW5NZEWpI,24938
21
23
  airflow/providers/edge3/plugins/__init__.py,sha256=9hdXHABrVpkbpjZgUft39kOFL2xSGeG4GEua0Hmelus,785
@@ -33,7 +35,7 @@ airflow/providers/edge3/worker_api/routes/health.py,sha256=XxqIppnRA138Q6mAHCdyL
33
35
  airflow/providers/edge3/worker_api/routes/jobs.py,sha256=UK1w6nXEUadOLwE9abZ4jHH4KtbvXcwaAF0EnwSa3y4,5733
34
36
  airflow/providers/edge3/worker_api/routes/logs.py,sha256=uk0SZ5hAimj3sAcq1FYCDu0AXYNeTeyjZDGBvw-986E,4945
35
37
  airflow/providers/edge3/worker_api/routes/worker.py,sha256=BGARu1RZ74lW9X-ltuMYbbVXczm_MZdqHaai2MhDWtY,8969
36
- apache_airflow_providers_edge3-1.1.0rc1.dist-info/entry_points.txt,sha256=7WUIGfd3o9NvvbK5trbZxNXTgYGc6pqg74wZPigbx5o,206
37
- apache_airflow_providers_edge3-1.1.0rc1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
38
- apache_airflow_providers_edge3-1.1.0rc1.dist-info/METADATA,sha256=3kJ3dEUkqjhppUJvNuD0I4r7MOg0pZ1kjmWcOrOARS0,5876
39
- apache_airflow_providers_edge3-1.1.0rc1.dist-info/RECORD,,
38
+ apache_airflow_providers_edge3-1.1.1rc1.dist-info/entry_points.txt,sha256=7WUIGfd3o9NvvbK5trbZxNXTgYGc6pqg74wZPigbx5o,206
39
+ apache_airflow_providers_edge3-1.1.1rc1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
40
+ apache_airflow_providers_edge3-1.1.1rc1.dist-info/METADATA,sha256=FSMB2NwsD5pBBbwSBx3BQVnvPXLrLp9YVnJQR1BTf_o,5876
41
+ apache_airflow_providers_edge3-1.1.1rc1.dist-info/RECORD,,