Repository: locustio/locust

Issue:
--tags / --exclude-tags seems not taken in consideration when running distributed unless they are specified in every worker
I'm not sure this is a bug, it's just quite inconvenient to have to specify --tags and --excluded-tags for each worker. It's indeed working if the tags are specified at wokrer level (the `--tags` and `--exlcuded-tags` is completely ignored for the master)

### Describe the bug
I run the master with `--tags  process_text` because I want to only run one one of the tasks

### Expected behavior
Only the tasks I specify in `--tags` are taken in account, or the ones that are in `--excluded-tags` are excluded 

### Actual behavior
All the tasks are executed 

### Steps to reproduce
Only the tasks that are selected/excluded should be only selected / excluded

### Environment

- OS: Linux (Centos, Ubuntu)
- Python version: 3.9.6
- Locust version: 2.2.0 (on all machines)
- Locust command line that you ran: `locust --config yomite-1h-50u-master.conf --tags process_text` See [config](https://github.com/lfoppiano/grobid-superconductors-performance/blob/main/yomite-1h-50u-master.conf). 
- Locust file contents (anonymized if necessary): See [here](https://github.com/lfoppiano/grobid-superconductors-performance/blob/main/locustfiles/locustfile_java.py). The data is not available, though.


Discussion:
I agree, this behaviour is not good. You may need to look into fixing it yourself though, as I dont think there are many people depending on this behaviour…
Thanks anyway. 
I'm not sure it's a quick fix or not, but I wanted to have it down so that is not forgotten ;-) 

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 10 days.
This issue was closed because it has been stalled for 10 days with no activity. This does not necessarily mean that the issue is bad, but it most likely means that nobody is willing to take the time to fix it. If you have found Locust useful, then consider contributing a fix yourself!

Resolving patch:
diff --git a/locust/env.py b/locust/env.py
--- a/locust/env.py
+++ b/locust/env.py
@@ -67,9 +67,9 @@ def __init__(
         self.shape_class = shape_class
         """A shape class to control the shape of the load test"""
         self.tags = tags
-        """If set, only tasks that are tagged by tags in this list will be executed"""
+        """If set, only tasks that are tagged by tags in this list will be executed. Leave this as None to use the one from parsed_options"""
         self.exclude_tags = exclude_tags
-        """If set, only tasks that aren't tagged by tags in this list will be executed"""
+        """If set, only tasks that aren't tagged by tags in this list will be executed. Leave this as None to use the one from parsed_options"""
         self.stats = RequestStats()
         """Reference to RequestStats instance"""
         self.host = host
@@ -89,8 +89,6 @@ def __init__(
         self.parsed_options = parsed_options
         """Reference to the parsed command line options (used to pre-populate fields in Web UI). May be None when using Locust as a library"""
 
-        self._filter_tasks_by_tags()
-
         self._remove_user_classes_with_weight_zero()
 
         # Validate there's no class with the same name but in different modules
@@ -195,13 +193,26 @@ def _filter_tasks_by_tags(self):
         Filter the tasks on all the user_classes recursively, according to the tags and
         exclude_tags attributes
         """
+        if getattr(self, "_tasks_filtered", False):
+            return  # only filter once
+        self._tasks_filtered = True
+
         if self.tags is not None:
-            self.tags = set(self.tags)
+            tags = set(self.tags)
+        elif self.parsed_options and self.parsed_options.tags:
+            tags = set(self.parsed_options.tags)
+        else:
+            tags = None
+
         if self.exclude_tags is not None:
-            self.exclude_tags = set(self.exclude_tags)
+            exclude_tags = set(self.exclude_tags)
+        elif self.parsed_options and self.parsed_options.exclude_tags:
+            exclude_tags = set(self.parsed_options.exclude_tags)
+        else:
+            exclude_tags = None
 
         for user_class in self.user_classes:
-            filter_tasks_by_tags(user_class, self.tags, self.exclude_tags)
+            filter_tasks_by_tags(user_class, tags, exclude_tags)
 
     def _remove_user_classes_with_weight_zero(self):
         """
diff --git a/locust/main.py b/locust/main.py
--- a/locust/main.py
+++ b/locust/main.py
@@ -108,8 +108,6 @@ def create_environment(user_classes, options, events=None, shape_class=None, loc
         locustfile=locustfile,
         user_classes=user_classes,
         shape_class=shape_class,
-        tags=options.tags,
-        exclude_tags=options.exclude_tags,
         events=events,
         host=options.host,
         reset_stats=options.reset_stats,
diff --git a/locust/runners.py b/locust/runners.py
--- a/locust/runners.py
+++ b/locust/runners.py
@@ -311,6 +311,7 @@ def start(self, user_count: int, spawn_rate: float, wait: bool = False):
             self.exceptions = {}
             self.cpu_warning_emitted = False
             self.worker_cpu_warning_emitted = False
+            self.environment._filter_tasks_by_tags()
             self.environment.events.test_start.fire(environment=self.environment)
 
         if wait and user_count - self.user_count > spawn_rate:
@@ -694,6 +695,7 @@ def start(self, user_count: int, spawn_rate: float, wait=False) -> None:
         if self.state != STATE_RUNNING and self.state != STATE_SPAWNING:
             self.stats.clear_all()
             self.exceptions = {}
+            self.environment._filter_tasks_by_tags()
             self.environment.events.test_start.fire(environment=self.environment)
             if self.environment.shape_class:
                 self.environment.shape_class.reset_time()
@@ -1085,6 +1087,7 @@ def start_worker(self, user_classes_count: Dict[str, int], **kwargs):
             self.exceptions = {}
             self.cpu_warning_emitted = False
             self.worker_cpu_warning_emitted = False
+            self.environment._filter_tasks_by_tags()
             self.environment.events.test_start.fire(environment=self.environment)
 
         self.worker_state = STATE_SPAWNING
@@ -1164,8 +1167,8 @@ def worker(self):
                     k: v
                     for k, v in job["parsed_options"].items()
                     if k not in argument_parser.default_args_dict()
-                    # expect_workers is sometimes needed on workers, e.g. in locust-plugins global rps limiter
-                    or k == "expect_workers"
+                    # these settings are sometimes needed on workers
+                    or k in ["expect_workers", "tags", "exclude_tags"]
                 }
                 vars(self.environment.parsed_options).update(custom_args_from_master)
 


Test patch:
diff --git a/locust/test/test_main.py b/locust/test/test_main.py
--- a/locust/test/test_main.py
+++ b/locust/test/test_main.py
@@ -712,7 +712,6 @@ def on_test_stop(environment, **kwargs):
         print("test_stop on worker")
 """
         )
-        print(content)
         with mock_locustfile(content=content) as mocked:
             proc = subprocess.Popen(
                 [
@@ -757,3 +756,68 @@ def on_test_stop(environment, **kwargs):
             self.assertIn("test_start on worker", stdout_worker)
             self.assertEqual(0, proc.returncode)
             self.assertEqual(0, proc_worker.returncode)
+
+    def test_distributed_tags(self):
+        content = (
+            MOCK_LOCUSTFILE_CONTENT
+            + """
+from locust import tag
+class SecondUser(HttpUser):
+    host = "http://127.0.0.1:8089"
+    wait_time = between(0, 0.1)
+    @tag("tag1")
+    @task
+    def task1(self):
+        print("task1")
+
+    @tag("tag2")
+    @task
+    def task2(self):
+        print("task2")
+"""
+        )
+        with mock_locustfile(content=content) as mocked:
+            proc = subprocess.Popen(
+                [
+                    "locust",
+                    "-f",
+                    mocked.file_path,
+                    "--headless",
+                    "--master",
+                    "--expect-workers",
+                    "1",
+                    "-t",
+                    "1",
+                    "-u",
+                    "2",
+                    "--exit-code-on-error",
+                    "0",
+                    "-L",
+                    "DEBUG",
+                    "--tags",
+                    "tag1",
+                ],
+                stdout=PIPE,
+                stderr=PIPE,
+            )
+            proc_worker = subprocess.Popen(
+                [
+                    "locust",
+                    "-f",
+                    mocked.file_path,
+                    "--worker",
+                    "-L",
+                    "DEBUG",
+                ],
+                stdout=PIPE,
+                stderr=PIPE,
+            )
+            stdout, stderr = proc.communicate()
+            stderr = stderr.decode("utf-8")
+            stdout = stdout.decode("utf-8")
+            stdout_worker, stderr_worker = proc_worker.communicate()
+            stdout_worker = stdout_worker.decode("utf-8")
+            self.assertIn("task1", stdout_worker)
+            self.assertNotIn("task2", stdout_worker)
+            self.assertEqual(0, proc.returncode)
+            self.assertEqual(0, proc_worker.returncode)