[th/nft-mem] reorder code so we can release temporary object earlier during nft cmd#1303
[th/nft-mem] reorder code so we can release temporary object earlier during nft cmd#1303thom311 wants to merge 15 commits intofirewalld:mainfrom
Conversation
a34b3af to
2a86f73
Compare
|
previously, there was a patch here which dropped the chunking from |
f61d03c to
4658174
Compare
With patch |
c207bc9 to
ea7be70
Compare
| return True | ||
|
|
||
|
|
||
| def iter_reiterable(iterable): |
There was a problem hiding this comment.
Why have this function vs just calling list(thing) where you need it?
Yes. It makes a copy where you may avoid it in some cases, but this function is just obfuscating things.
There was a problem hiding this comment.
Most callers probably are just lists, so re-iterating them is fine. It would be wasteful to clone the list for that. But more importantly, on the next review, somebody thinks "oh, this clone is unneecessary". Then I would have to add a code comment explaining why the clone is even there. At this point, I wouldn't clone the list.
But that makes the function less general and the caller needs to make sure not to mess up (in non-obvious ways). The function no longer accepts arbitrary iterables, only iterables that can be iterated more than once. So, maybe I should add a code comment there that indicates that this function can only be called with certain iterables. But all callers currently are lists (probably?), so I wouldn't want to add such a code comment either.
iter_reiterable() solves this. More than anything, it clearly expresses the intent of making the implementation safe. The caller no longer need to worry. The reviewer of the code also sees clearly why this is done, and can reason why this is the correct thing to do.
It doesn't matter whether all callers today(!!) pass a list(). Using iter_reiterable() in a function makes the function more accepting to input and less of a foot gun.
| def removeFile(filename): | ||
| try: | ||
| os.remove(filename) | ||
| except OSError: |
There was a problem hiding this comment.
Remove (delete) the file path. If path is a directory, an OSError is raised.
Seems like we want propagate the error in this case.
There was a problem hiding this comment.
I think not. At least, not for the caller at hand.
The caller is nft_cmd(), and that one really does not want to be bothered with this. It certainly shouldn' t just let the exception escape, so, it would need to handle it. But how? Logging a message?
If the caller still needs to handle OSError (e.g. because the path is a file, or because the file has wrong permissions), then they should handle FileNotFoundError themselves. This function is not for them. This function is for "I don't care about OSError and don't want to write 4 extra lines of code for that" (and that's the only purpose).
There was a problem hiding this comment.
If you think that nft_cmd() needs to handle IsADirectoryError (e.g. by logging a warning), then that's another thing.
| self.nftables = Nftables() | ||
| self.nftables.set_echo_output(True) | ||
| self.nftables.set_handle_output(True) | ||
| self._nft_handle = Nftables() |
There was a problem hiding this comment.
handle is confusing with rule handles. Maybe _nft_ctx is a better name? Or _nftables_obj.
I prefer to drop this commit. What are you grepping for? In this file "nftables" shows up more as a string than as an object/identifier.
There was a problem hiding this comment.
I search for self._nft_handle (or whatever it's called).
It's in general good to name different things differently. Extra bonus points for unique names.
Rename to "_nft_ctx".
| if isinstance(json_root, list): | ||
| # The caller gave us the JSON in a list, so we can drop the | ||
| # reference to the memory. Unpack the list, and delete the entry. | ||
| l = json_root | ||
| (json_root,) = l | ||
| del l[0] |
There was a problem hiding this comment.
I don't see any cases where json_root would be a list. It's always a dict.
This code also doesn't work. If json_root is a list then you can't assign l to a tuple.
>>> foo=[1,2,3,4,5]
>>> (blah,) = foo
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 1)
Or json_root is a list of one element, then this does nothing.
There was a problem hiding this comment.
I think it works as intended. (blah,) = foo is tuple unpacking, and unpacks an interable of one element. It checks that the number of element matches, which is the reason for doing this instead of blah = foo[0].
Or json_root is a list of one element, then this does nothing.
If the caller passes json_root as a list, then it MUST be a list of one element (otherwise, the tuple unpacking fails). But then it does the right thing. Unpack the list, and clear the element from the list.
I don't see any cases where json_root would be a list. It's always a dict.
yes, it was previously unused. I now added a two commits
- improvement(nftables): avoid creating JSON string twice before nft_cmd()
- improvement(nftables): free json data earlier during nft_cmd()
| (json_root,) = l | ||
| del l[0] | ||
| json_root_str = json.dumps(json_root) | ||
| del json_root |
There was a problem hiding this comment.
I'm not sure this does anything if other areas of the code, e.g. the caller, are holding a reference. It would have to be json_root.clear() to remove the dict entries.
There was a problem hiding this comment.
it would, if json_root argument were a list.
I added commit "improvement(nftables): free json data earlier during nft_cmd()" to do that.
| json_root_str = json_root_str.encode("utf-8") | ||
| rc, output, error = self._nft_handle.cmd(json_root_str) | ||
|
|
||
| if is_large and hasattr(self._nft_handle, "cmd_from_file"): |
There was a problem hiding this comment.
This hasattr() guard is weird. AFAIK, python-nftables has always had this function.
There was a problem hiding this comment.
it's only "recently" added to the python library, here: https://git.netfilter.org/nftables/commit/?id=448bd104d3530ed2870921a35ebf54589cdb274c
This is recent enough, that github CI tests would break without this.
| else: | ||
| json_root_str = json.dumps(json_root) | ||
| del json_root | ||
| json_root_str = json_root_str.encode("utf-8") | ||
| rc, output, error = self._nft_handle.cmd(json_root_str) |
There was a problem hiding this comment.
Instead of adding complexity by sometimes doing it directly and sometimes doing it via a file, why not just always do it from a file?
There was a problem hiding this comment.
I think it will be slower to write a small file. The branch change should not make some cases slower.
There was a problem hiding this comment.
It was more about consistency and reducing complexity. One code path for all operations.
There was a problem hiding this comment.
This function is unused by the end of this changeset. Please drop this commit.
|
There are a lot of changes here that I'm not convinced are providing real benefit. The ipset entry changes are for sure. But some of the explicit I'd like to see the ipset entry change in a separate PR. Do you have any benchmarks about the improvements from |
- extract a tempDir() function. - make the function configurable. In particular, all files created with the same prefix is bad, because if we have a left over file on disk, it's hard to tell where the file comes from.
It swallows IO exceptions. We often want to just get rid of a file, and don't bother with a failure to do so.
Most of the time, when the caller constructs a list of rules and calls set_rules(), the don'tt need the rules anymore. We could release them from memory earlier, right after set_rules() processed the list further. Add an argument "rules_clear" (default to False) to set_rules(). If true, set_rules() will clear the list after it no longer needs it. That may allow some memory to be reclaimed early.
…sing rules early These callers don't actually care about the list that they pass to set_rules(). By setting rules_clear=True, set_rules() will clear the list after using it, and thereby potentially already free some objects earlier, that we don't need anymore. The point is to reduce spikes in memory, by more eagerly release data after we no longer need it.
From libnftables we import nftables.nftables.Nftables. The "nftables.py" module brings a class nftables.nftables. Don't also call the Nftable attribute self.nftables. Instead, rename to "self._nft_ctx". The main point is using a distinct name, so we can grep for it.
We will rework the invocation of the JSON command, by writing the JSON to file. For that, move the call to self._nft_ctx.json_cmd() to a new helper. Note that libnftables' json_cmd() is itself just a small wrapper around json.dumps() and Nftables.cmd(). As we want to get close to a place where we don't generate a JSON string in memory, take the json_cmd() implementation and re-implement it. Later we will modify it further.
This allows to first `del json_blob` and free the data (if any). Before we start allocating new data while parsing the JSON response.
Instead of passing the JSON to libnftables, write the JSON to a file in /run/firewalld, and use cmd_from_file() to load it from there. The ultimate goal is to reduce the amount of memory we keep around. Note that the test don't create a separate mount namespace and don't remount /run. They maybe should do that. But with this change, we have yet another place where we write temporary files to /run. And if we run tests in parallel, they could theoretically interfere. In practice they don't, because the temporary files have unique names. In any case, if this is a problem, the solution is that tests mount a private /run/firewalld.
When we configure real rules, we request the handles from the nft call. Otherwise, that data is not used. Only request echo output, when we actually use the information.
Previously, when creating an ipset, we would create for each entry a
separate "add" rule, like
{
"add": {
"element": {
"family": "inet",
"table": "firewalld",
"name": "foobar",
"elem": [
{
"prefix": {
"addr": "10.10.1.1",
"len": 32
}
}
]
}
}
},
{
"add": {
"element": {
"family": "inet",
"table": "firewalld",
"name": "foobar",
"elem": [
{
"prefix": {
"addr": "10.10.1.2",
"len": 32
}
}
]
}
}
},
...
This is much slowing in libnftables (or kernel) to process, then having all entries
at once. Combine them, now we have:
{
"add": {
"element": {
"family": "inet",
"table": "firewalld",
"name": "foobar",
"elem": [
{
"prefix": {
"addr": "10.10.1.1",
"len": 32
}
},
{
"prefix": {
"addr": "10.10.1.2",
"len": 32
}
},
...
The problem with chunking is that it splits the transaction, and what should be an atomic update, is not. libnftables is supposed to provide us with a concept of transactions, if we cannot use that because the transaction is too large, then that's a fail. We changed the format of the JSON for ipset entries, instead of an "add" command for each entry, they are now all combed in one. This significantly makes the operation faster, and we shouldn't need the chunking anymore. This in parts reverts 9a86f11 ('fix(ipset): chunk entries when restoring set'), however, in the meantime it is thought that the chunking is no longer necessary.
The JSON data may be large, and we have to convert it to string. Optimally, we free it as soon as we no longer need it. Previously, if nft_cmd() failed, we would raise a ValueError() with the full JSON blob as error message. That means, we had to keep the JSON blob around for longer. It's questionable whether we really need this. Note that if you run with debug logging, then we log the full JSON before making the call. If we don't have debug logging enabled, then we shouldn't embed the full JSON data in our error message. By not embedding the JSON string in the ValueError() after failure, we can get rid of it earlier.
If we already convert the JSON to string for logging purposes, don't do it a second time for nft_cmd(). Instead, reuse the string.
No. You brought up how the ipset transaction has to be split due to memory (peak) usage. The deletes and clears allow it that memory that is no longer needed gets released earlier. I think they are successful at that, because (from review) it is clear that the variables are not aliased (have no other references). Also, CPython uses reference counting (and a GC for reference loops), so once those (loopless) variables get freed right away. Does it measurably help performance? That is hard to say. Equally important, does it hurt readability/maintainability? I don't think so. A Too bad, that |
During nft command, we may (temporarily) create a lot of data to track the rules. This increases the memory usage, even if only for a short time.
Rework some code, so that we can drop references earlier when we no longer need them.